Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
ecf2679
updates to gaussian fitting and supporting modules
dmehring Mar 19, 2026
9227f80
updated docs
dmehring Mar 21, 2026
c558640
large refactor for maintainability
dmehring Mar 21, 2026
8244ca2
Tighten multi-Gaussian bounds semantics
dmehring Mar 23, 2026
20fad7e
Validate ordered principal-axis bounds
dmehring Mar 23, 2026
82089d6
add optional title
dmehring Mar 26, 2026
9955694
mostly doc updates and more forgiving param handling
dmehring Mar 26, 2026
9331db3
warning for malchunked dask arrays, lots of comments about axis order…
dmehring Mar 26, 2026
e1843e5
allow caller to specify axes convention for unlabled arrays, lots of …
dmehring Mar 27, 2026
d4aea79
various updates to fitter, plotting
dmehring Mar 30, 2026
6f53b6b
trivial change to rerun CI
dmehring Mar 31, 2026
795655b
note explicitly pixel coord conventoin
dmehring Mar 31, 2026
6b41a95
code cleanup
dmehring Mar 31, 2026
a2e0681
black
dmehring Mar 31, 2026
98cb3a8
mitigate xy, yx amibiguity
dmehring Mar 31, 2026
28491cf
add docstrings to tests
dmehring Mar 31, 2026
35f6330
update stale docs
dmehring Mar 31, 2026
b542de7
bug fix interpolating decreasing axis
dmehring Mar 31, 2026
02f82b8
doc clean up
dmehring Mar 31, 2026
eb0f832
test clean up
dmehring Mar 31, 2026
5d380fd
black
dmehring Mar 31, 2026
485c39c
flat array FWHM convert to sigma correctly
dmehring Mar 31, 2026
f913810
Fix multi-Gaussian plotting and flat-init edge cases
dmehring Mar 31, 2026
a796979
Clarify component model raw-axis docs
dmehring Mar 31, 2026
f098141
black
dmehring Mar 31, 2026
a0e6807
Fix point-source Dask chunk alignment
dmehring Mar 31, 2026
4b2fc82
better initial guesses for quicker convergence
dmehring Apr 1, 2026
d750701
Fix multi-Gaussian call provenance metadata
dmehring Apr 1, 2026
506d172
Gate multi-Gaussian world outputs on coords
dmehring Apr 1, 2026
6eb25e6
Fix interpolation helper monotonicity check
dmehring Apr 1, 2026
287a1fa
Refactor pixel-center interpolation prep helper
dmehring Apr 1, 2026
b8364d5
Clean up fit provenance default metadata
dmehring Apr 1, 2026
5042498
Clarify pixel-center interpolation docstring
dmehring Apr 1, 2026
d401c37
Remove stale world-center helper stub
dmehring Apr 1, 2026
991d91b
Fix metadata helper axis-order typing
dmehring Apr 1, 2026
617aee8
Preserve raw-axis metadata in component plots
dmehring Apr 1, 2026
5e9774d
Refresh raw-axis wrapping comment
dmehring Apr 1, 2026
c0468b8
Correct variance-explained metadata note
dmehring Apr 1, 2026
448563b
Validate public fit angle selectors
dmehring Apr 1, 2026
071e6f1
Fix multi-Gaussian header path comment
dmehring Apr 1, 2026
7a00262
Avoid eager values access in point-source models
dmehring Apr 1, 2026
4a2bbd2
Reuse stored fit dims in component plots
dmehring Apr 1, 2026
9be63d5
Refine coordinate publication semantics
dmehring Apr 1, 2026
9201b20
Use published frames for component overlays
dmehring Apr 1, 2026
9bf0bb2
Align plotting tests with x-y dims contract
dmehring Apr 1, 2026
610af8b
Match unlabeled-axis validation to API contract
dmehring Apr 1, 2026
43ab5b2
Update tests/benchmark/distributed/image_analysis/benchmark_multi_gau…
dmehring Apr 1, 2026
88ed196
Merge branch '215-multi_gaussian2d_fitpy-yx---xy-and-notebook-clarifi…
dmehring Apr 1, 2026
d25fd39
black
dmehring Apr 1, 2026
c50daff
updated notebook
dmehring Apr 2, 2026
9d3187b
Extract FWHM/sigma conversion into shared _gaussian_math utility
dmehring Apr 2, 2026
5783772
Extract PA↔math angle conversion into _gaussian_math utility
dmehring Apr 2, 2026
2382853
Remove generate_astro_plot; all callers migrated to generate_plot
dmehring Apr 2, 2026
219ee7b
Fix init_list component 2 to use fwhm_major/fwhm_minor matching array…
dmehring Apr 2, 2026
f8186ba
missed a plot function name change
dmehring Apr 2, 2026
2a74189
black updated
dmehring Apr 2, 2026
99daf41
Defer DataArray materialization in _resolve_plot_coords
dmehring Apr 2, 2026
757816f
Use WCSAxes coords API for axis labels in WCS plot branch
dmehring Apr 2, 2026
0ea5d9b
Update tests/benchmark/distributed/image_analysis/benchmark_multi_gau…
dmehring Apr 2, 2026
a108a59
Update tests/benchmark/distributed/image_analysis/benchmark_multi_gau…
dmehring Apr 2, 2026
6af8d59
Merge branch '218-image-analysis-and-image-plotting-helper-refactor' …
dmehring Apr 2, 2026
90491a7
black
dmehring Apr 2, 2026
5278589
Accept numpy arrays as per-component fwhm bounds
dmehring Apr 2, 2026
092542c
Update tests/benchmark/distributed/image_analysis/benchmark_multi_gau…
dmehring Apr 2, 2026
e15b2e3
Merge branch '218-image-analysis-and-image-plotting-helper-refactor' …
dmehring Apr 2, 2026
d4b2ec7
Handle (y, x) DataArray ordering in plotting helpers
dmehring Apr 2, 2026
2a07354
Don't override WCSAxes world-coordinate labels with generic dim names
dmehring Apr 2, 2026
0b9a28a
Clarify _prepare_pixel_center_interp docstring re: coord monotonicity
dmehring Apr 2, 2026
d62d0f2
Drop unused _FWHM2SIG import from component_models
dmehring Apr 2, 2026
6133788
Skip coord float-cast in imshow/WCS modes to tolerate non-numeric coords
dmehring Apr 2, 2026
38013ac
Update tests/benchmark/distributed/image_analysis/benchmark_multi_gau…
dmehring Apr 2, 2026
581d99a
Update tests/benchmark/distributed/image_analysis/benchmark_multi_gau…
dmehring Apr 2, 2026
c8f8522
Merge branch '218-image-analysis-and-image-plotting-helper-refactor' …
dmehring Apr 2, 2026
a29e7f3
Correct type annotation and docstring for make_scene_via_component_mo…
dmehring Apr 2, 2026
6906854
Validate string coord selectors before need_coords early return
dmehring Apr 2, 2026
ab655ee
Update tests/benchmark/distributed/image_analysis/benchmark_multi_gau…
dmehring Apr 2, 2026
e3f2375
Update tests/benchmark/distributed/image_analysis/benchmark_multi_gau…
dmehring Apr 2, 2026
0ff4dfd
Merge branch '218-image-analysis-and-image-plotting-helper-refactor' …
dmehring Apr 2, 2026
ade6292
Allow string coord selectors as label-only overrides when need_coords…
dmehring Apr 2, 2026
7cc988e
Defer materialization of DataArray values to generate_plot rendering …
dmehring Apr 2, 2026
6d35106
Widen _resolve_plot_coords return type to tuple[Any, ndarray, ndarray]
dmehring Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,170 changes: 921 additions & 3,249 deletions docs/multi_gaussian2d_fit.ipynb

Large diffs are not rendered by default.

2,845 changes: 210 additions & 2,635 deletions docs/tutorials/feather_tutorial.ipynb

Large diffs are not rendered by default.

129 changes: 10 additions & 119 deletions docs/utils/plotting.ipynb

Large diffs are not rendered by default.

3,422 changes: 2,537 additions & 885 deletions src/astroviper/distributed/image_analysis/multi_gaussian2d_fit.py

Large diffs are not rendered by default.

145 changes: 86 additions & 59 deletions src/astroviper/distributed/model/component_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
import xarray as xr
import dask.array as da

from ...utils._gaussian_math import (
sigma_from_fwhm as _sigma_from_fwhm,
normalize_angle as _normalize_theta,
)

ArrayLike2D = Union[np.ndarray, da.Array]
OutputKind = Literal["match", "xarray", "numpy", "dask"]
ReturnType = Union[xr.DataArray, np.ndarray, da.Array]
Expand Down Expand Up @@ -40,14 +45,18 @@ def _coerce_to_xda(
Input grid. Either an ``xarray.DataArray`` or a 2-D NumPy/Dask array.
x_coord, y_coord
Names of the coordinate variables representing the horizontal and
vertical axes in world units.
vertical axes.
coords
Required when ``data`` is a NumPy/Dask array. A mapping that must include
1-D arrays for ``x_coord`` and ``y_coord`` whose lengths match the array
width and height, respectively.
width (horizontal) and height (vertical), respectively.
This module's public convention for unlabeled 2-D arrays is that axis 0
is horizontal (x) and axis 1 is vertical (y). This is an Astroviper API
choice for packed array inputs and should not be confused with the usual
NumPy row/column indexing convention ``array[y, x]``.
dims
Optional dimension names to assign when wrapping a NumPy/Dask array.
Defaults to ``(y_coord, x_coord)``.
Defaults to ``(x_coord, y_coord)``.

Returns
-------
Expand Down Expand Up @@ -83,8 +92,11 @@ def _coerce_to_xda(

x_vals = np.asarray(coords[x_coord])
y_vals = np.asarray(coords[y_coord])

H, W = data.shape
# Public API convention in this module: unlabeled 2-D arrays are interpreted
# as packed (x, y), so x coords map to axis 0 and y coords map to axis 1.
# This is an Astroviper convention for raw inputs, not a claim about NumPy
# row/column storage order in Astropy.
W, H = data.shape
if y_vals.shape[0] != H or x_vals.shape[0] != W:
raise ValueError(
f"Coordinate lengths must match array shape: "
Expand All @@ -93,9 +105,9 @@ def _coerce_to_xda(
)

if dims is None:
dims = (y_coord, x_coord)
dims = (x_coord, y_coord)

return xr.DataArray(data, coords={y_coord: y_vals, x_coord: x_vals}, dims=dims)
return xr.DataArray(data, coords={x_coord: x_vals, y_coord: y_vals}, dims=dims)


def _rotated_coords(
Expand Down Expand Up @@ -302,20 +314,6 @@ def _infer_handedness(
return "left" if dx[0] * dy[0] < 0 else "right"


def _normalize_theta(
angle_value: float, *, angle: Literal["pa", "math"], degrees: bool
) -> float:
"""Convert a user-provided angle to the internal math convention in radians.

"math" is measured from +x toward +y.
"pa" is astronomical position angle, measured from +y toward +x.

Relation: theta_math = (pi/2) - PA.
"""
theta = float(np.deg2rad(angle_value) if degrees else angle_value)
return (np.pi / 2.0) - theta if angle == "pa" else theta


def _validate_ab_theta_center(
a: float,
b: float,
Expand Down Expand Up @@ -481,7 +479,10 @@ def make_disk(
either an ``xarray.DataArray`` of any dimensionality that includes named
``x_coord`` and ``y_coord`` dims, or a 2-D NumPy/Dask array plus 1-D
coordinate arrays via ``coords``. All coordinates are interpreted as world
coordinates.
coordinates. For unlabeled 2-D NumPy/Dask inputs, this module's public API
convention is that axis 0 is ``x`` (horizontal) and axis 1 is ``y`` (vertical).
That is an Astroviper packed-array convention for raw inputs rather than the
usual NumPy row/column indexing order.

Behavior controlled by ``add``:
- ``add=True`` (default): **Additive** mode. ``height`` is added to the
Expand Down Expand Up @@ -509,13 +510,13 @@ def make_disk(
height
Value to write inside the ellipse, or the increment when ``add=True``.
x_coord, y_coord
Names of the horizontal and vertical coordinates or dims.
Names of the horizontal (x_coord) and vertical (y_coord) coordinates or dims.
coords
Required when ``data`` is a NumPy/Dask array. Mapping containing 1-D
arrays for ``x_coord`` and ``y_coord`` whose lengths match the array.
dims
Optional when ``data`` is a NumPy/Dask array. Defaults to
``(y_coord, x_coord)``.
``(x_coord, y_coord)``.
add
Defaults to ``True``. If ``True``, add to values inside the ellipse;
if ``False``, replace values inside the ellipse.
Expand Down Expand Up @@ -548,24 +549,28 @@ def make_disk(

Notes
-----
For DataArray inputs with extra dims (for example, ``("time", "y", "x")``),
For DataArray inputs, labeled dims determine the x/y semantics. For unlabeled
2-D NumPy/Dask arrays, axis 0 is interpreted as ``x`` (horizontal) and axis 1
as ``y`` (vertical). That is this module's packed-array API convention rather
than the usual NumPy row/column indexing order.
For DataArray inputs with extra dims (for example, ``("time", "x", "y")``),
the 2-D mask broadcasts across the remaining dims. Dask inputs remain lazy.

Examples
--------
>>> import numpy as np, xarray as xr
>>> y = np.linspace(-5, 5, 101)
>>> x = np.linspace(-5, 5, 121)
>>> base = xr.DataArray(np.zeros((y.size, x.size)), coords={"y": y, "x": x}, dims=("y", "x"))
>>> y = np.linspace(-5, 5, 101)
>>> base = xr.DataArray(np.zeros((x.size, y.size)), coords={"x": x, "y": y}, dims=("x", "y"))
>>> out = make_disk(base, a=3.0, b=1.5, theta=np.deg2rad(30), x0=0.0, y0=0.0, height=2.0)
"""
_validate_ab_theta_center(a, b, theta, x0, y0)

xda_in = _coerce_to_xda(
data, x_coord=x_coord, y_coord=y_coord, coords=coords, dims=dims
)
x_vals = np.asarray(xda_in[x_coord].values)
y_vals = np.asarray(xda_in[y_coord].values)
x_vals = np.asarray(xda_in[x_coord].data)
y_vals = np.asarray(xda_in[y_coord].data)

if angle == "auto":
handed = _infer_handedness(x_vals, y_vals)
Expand Down Expand Up @@ -609,7 +614,10 @@ def make_gauss2d(
``make_gauss2d`` produces an elliptical Gaussian with peak amplitude ``peak`` at
center ``(x0, y0)``. Inputs ``a`` and ``b`` are the **full width at half
maximum (FWHM)** along the ellipse’s principal axes. The ellipse is rotated
by ``theta`` radians measured from +x toward +y.
by ``theta`` radians measured from +x toward +y. For unlabeled 2-D NumPy/Dask
inputs, this module's public API convention is that axis 0 is ``x`` (horizontal)
and axis 1 is ``y`` (vertical). That is an Astroviper packed-array convention
for raw inputs rather than the usual NumPy row/column indexing order.

Conversion from FWHM to standard deviation:

Expand Down Expand Up @@ -652,7 +660,7 @@ def make_gauss2d(
arrays for ``x_coord`` and ``y_coord`` whose lengths match the array.
dims
Optional when ``data`` is a NumPy/Dask array. Defaults to
``(y_coord, x_coord)``.
``(x_coord, y_coord)``.
add
Defaults to ``True``. If ``True``, add the Gaussian to existing values;
if ``False``, replace by the Gaussian everywhere.
Expand Down Expand Up @@ -685,24 +693,27 @@ def make_gauss2d(

Notes
-----
Works lazily with Dask arrays. For inputs with extra dims, the 2-D Gaussian
broadcasts across the remaining dims.
Works lazily with Dask arrays. For DataArray inputs, labeled dims determine
the x/y semantics. For unlabeled 2-D NumPy/Dask arrays, axis 0 is interpreted
as ``x`` and axis 1 as ``y``. That is this module's packed-array API
convention rather than the usual NumPy row/column indexing order.
For inputs with extra dims, the 2-D Gaussian broadcasts across the remaining dims.

Examples
--------
>>> import numpy as np, xarray as xr
>>> y = np.linspace(-4, 4, 200)
>>> x = np.linspace(-5, 5, 300)
>>> base = xr.DataArray(np.zeros((y.size, x.size)), coords={"y": y, "x": x}, dims=("y", "x"))
>>> y = np.linspace(-4, 4, 200)
>>> base = xr.DataArray(np.zeros((x.size, y.size)), coords={"x": x, "y": y}, dims=("x", "y"))
>>> g = make_gauss2d(base, a=2.355, b=4.71, theta=np.deg2rad(30), x0=0.0, y0=0.0, peak=10.0)
"""
_validate_ab_theta_center(a, b, theta, x0, y0)

xda_in = _coerce_to_xda(
data, x_coord=x_coord, y_coord=y_coord, coords=coords, dims=dims
)
x_vals = np.asarray(xda_in[x_coord].values)
y_vals = np.asarray(xda_in[y_coord].values)
x_vals = np.asarray(xda_in[x_coord].data)
y_vals = np.asarray(xda_in[y_coord].data)

if angle == "auto":
handed = _infer_handedness(x_vals, y_vals)
Expand All @@ -716,9 +727,8 @@ def make_gauss2d(
xda_in, x_coord=x_coord, y_coord=y_coord, x0=x0, y0=y0, theta=theta_eff
)

denom = 2.0 * np.sqrt(2.0 * np.log(2.0))
sigma_x = a / denom
sigma_y = b / denom
sigma_x = _sigma_from_fwhm(a)
sigma_y = _sigma_from_fwhm(b)

source_array = peak * np.exp(-0.5 * ((xp / sigma_x) ** 2 + (yp / sigma_y) ** 2))
xda_out = _apply_source_array(xda_in, source_array, add=add)
Expand Down Expand Up @@ -747,7 +757,10 @@ def make_pt_sources(
the same world units as the grid coordinates. Sources are mapped to the nearest
grid point along each axis in physical distance. If a target lies exactly midway
between two coordinates along an axis, the right-hand coordinate is chosen
deterministically.
deterministically. For unlabeled 2-D NumPy/Dask inputs, this module's public
API convention is that axis 0 is ``x`` (horizontal) and axis 1 is ``y`` (vertical).
That is an Astroviper packed-array convention for raw inputs rather than the
usual NumPy row/column indexing order.

Duplicate hits are handled correctly and efficiently. If multiple sources map
to the same grid point, their amplitudes are summed in one pass using a
Expand Down Expand Up @@ -803,7 +816,7 @@ def make_pt_sources(
arrays for ``x_coord`` and ``y_coord`` whose lengths match the array.
dims
Optional when ``data`` is a NumPy/Dask array. Defaults to
``(y_coord, x_coord)``.
``(x_coord, y_coord)``.
add
Defaults to ``True``. If ``True``, add to existing values at those
pixels; if ``False``, replace values at those pixels.
Expand All @@ -829,16 +842,20 @@ def make_pt_sources(
-----
For rectilinear yet irregular grids, the nearest 2-D pixel is the Cartesian
pair of the nearest 1-D coordinates along x and y. This allows a fast,
per-axis nearest search to be combined into a correct 2-D decision.
per-axis nearest search to be combined into a correct 2-D decision. For
DataArray inputs, labeled dims determine the x/y semantics. For unlabeled
2-D NumPy/Dask arrays, axis 0 is interpreted as ``x`` (horizontal) and axis 1
as ``y`` (vertical). That is this module's packed-array API convention rather
than the usual NumPy row/column indexing order.

Examples
--------
>>> import numpy as np, xarray as xr
>>> y = np.linspace(-2, 2, 5)
>>> x = np.linspace(-3, 3, 7)
>>> base = xr.DataArray(np.zeros((y.size, x.size)),
... coords={"y": y, "x": x},
... dims=("y", "x"))
>>> y = np.linspace(-2, 2, 5)
>>> base = xr.DataArray(np.zeros((x.size, y.size)),
... coords={"x": x, "y": y},
... dims=("x", "y"))
>>> # Strict ignore (default): OOR sources are dropped
>>> out1 = make_pt_sources(base,
... amplitudes=[5.0, 3.0],
Expand All @@ -864,8 +881,8 @@ def make_pt_sources(
data, x_coord=x_coord, y_coord=y_coord, coords=coords, dims=dims
)

x_vals = np.asarray(xda_in[x_coord].values)
y_vals = np.asarray(xda_in[y_coord].values)
x_vals = np.asarray(xda_in[x_coord].data)
y_vals = np.asarray(xda_in[y_coord].data)

# Request validity masks so we can truly ignore OOR targets when desired.
xi, valid_x = _nearest_indices_1d(
Expand All @@ -880,10 +897,12 @@ def make_pt_sources(
# For "ignore" and "ignore_sloppy", only sources within policy range are kept.
valid = valid_x & valid_y

data_dtype = getattr(xda_in.data, "dtype", xda_in.dtype)

# Short-circuit if nothing to add.
if not np.any(valid):
out_dtype = np.result_type(
xda_in.values, amps.dtype if hasattr(amps, "dtype") else amps
data_dtype, amps.dtype if hasattr(amps, "dtype") else amps
)
source_array = xr.zeros_like(xda_in, dtype=out_dtype)
xda_out = _apply_source_array(xda_in, source_array, add=add)
Expand All @@ -894,34 +913,42 @@ def make_pt_sources(
yi = np.asarray(yi)[valid]
amps_kept = amps[valid]

out_dtype = np.result_type(xda_in.values, amps_kept)
out_dtype = np.result_type(data_dtype, amps_kept)

# Dimensions
height = xda_in.sizes[y_coord]
# Public convention in this module is (x, y), so axis 0 follows x.
width = xda_in.sizes[x_coord]
height = xda_in.sizes[y_coord]

# Accumulate amplitudes via bincount
lin = yi * width + xi
lin = xi * height + yi
acc = (
np.bincount(
lin,
weights=amps_kept.astype(out_dtype, copy=False),
minlength=height * width,
minlength=width * height,
)
.reshape(height, width)
.reshape(width, height)
.astype(out_dtype, copy=False)
)

# Preserve Dask laziness if input was Dask-backed
if _is_dask_array(xda_in.data):
src_data = da.from_array(acc, chunks=xda_in.data.chunks)
chunk_map = getattr(xda_in, "chunksizes", {})
if x_coord in chunk_map and y_coord in chunk_map:
# ``acc`` is constructed explicitly in packed (x, y) order, so its
# Dask chunks must be drawn from the named x/y dims rather than from
# the raw underlying chunk tuple, which may still be stored as (y, x).
chunks = (tuple(chunk_map[x_coord]), tuple(chunk_map[y_coord]))
else:
chunks = "auto"
src_data = da.from_array(acc, chunks=chunks)
else:
src_data = acc

source_array = xr.DataArray(
src_data,
coords={y_coord: xda_in[y_coord], x_coord: xda_in[x_coord]},
dims=(y_coord, x_coord),
coords={x_coord: xda_in[x_coord], y_coord: xda_in[y_coord]},
dims=(x_coord, y_coord),
)

xda_out = _apply_source_array(xda_in, source_array, add=add)
Expand Down
Loading
Loading