From e713ad1d3d285a25b02bac37a5a0b0ba92c73fac Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 30 Sep 2026 20:12:59 +0100 Subject: [PATCH] feat(interferometer): visualizer on array-free datasets (streaming phase 3, PyAutoArray#596) Natural-weighted fit properties and array-free branches in subplot_fit (2x3), subplot_fit_dirty_images, subplot_fit_interferometer_combined, subplot_fit_real_space and subplot_tracer_from_fit; positions image from dirty_image_natural. Both visualizers' InversionException fallbacks called logger(...) (TypeError) and now log a warning (the imaging one is the same defect, fixed in passing). Stale dirty_images.fits test assertion fixed and the dead tracked file removed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JZZksyZ8LTA4LLxoZjQMNF --- autolens/imaging/model/visualizer.py | 2 +- autolens/interferometer/fit_interferometer.py | 61 +++++++ autolens/interferometer/model/visualizer.py | 13 +- .../plot/fit_interferometer_plots.py | 122 ++++++++++++-- .../model/files/dirty_images.fits | Bin 207360 -> 0 bytes .../model/test_plotter_interferometer.py | 158 +++++++++++++++++- .../plot/test_fit_interferometer_plots.py | 76 +++++++++ .../interferometer/test_fit_interferometer.py | 132 +++++++++++++++ 8 files changed, 542 insertions(+), 22 deletions(-) delete mode 100644 test_autolens/interferometer/model/files/dirty_images.fits diff --git a/autolens/imaging/model/visualizer.py b/autolens/imaging/model/visualizer.py index 46d4ce9b86..2bdf1cb167 100644 --- a/autolens/imaging/model/visualizer.py +++ b/autolens/imaging/model/visualizer.py @@ -140,7 +140,7 @@ def visualize( try: fit.inversion.reconstruction except exc.InversionException: - logger( + logger.warning( ag.exc.invalid_linear_algebra_for_visualization_message() ) return diff --git a/autolens/interferometer/fit_interferometer.py b/autolens/interferometer/fit_interferometer.py index 2cb50cca99..cbc9152b21 100644 --- a/autolens/interferometer/fit_interferometer.py +++ b/autolens/interferometer/fit_interferometer.py @@ -342,6 +342,67 @@ def galaxy_image_dict(self) -> Dict[ag.Galaxy, np.ndarray]: return {**galaxy_image_dict, **galaxy_linear_obj_image_dict} + @property + def model_image_natural(self) -> aa.Array2D: + """ + The real-space (image-plane) model image `m` of the fit, on the dataset's `real_space_mask`: the lensed + image of every ordinary (non-linear) light profile (`profile_image`) plus, when the fit has an + inversion, the solved linear objects' reconstruction mapped to the image plane + (`inversion.mapped_reconstructed_data`, linear light profiles and pixelized sources) -- the real-space + image whose visibilities are `model_data`. + + It is built from these two terms rather than from `galaxy_image_dict`, whose entry for a galaxy with + both ordinary and linear light holds only the linear reconstruction. + + It needs neither visibilities nor a transformer, so it is available on an array-free dataset (built by + `Interferometer.from_stream` / `from_sparse_terms`), where it is the image the natural-weighted dirty + model image `dirty_model_image_natural` is formed from. (There `profile_image` is all zeros, because a + fit with ordinary light on an array-free dataset raises before it gets here.) + """ + image = np.asarray( + getattr(self.profile_image, "array", self.profile_image), dtype=np.float64 + ) + + if self.inversion is not None: + reconstruction = self.inversion.mapped_reconstructed_data + image = image + np.asarray( + getattr(reconstruction, "array", reconstruction), dtype=np.float64 + ) + + return aa.Array2D( + values=image, + mask=self.dataset.real_space_mask, + ) + + @property + def dirty_model_image_natural(self) -> aa.Array2D: + """ + The naturally weighted, normalised dirty image of the model visibilities, `W~ m / sum(w)`, formed from + `model_image_natural` with the dataset's `sparse_operator` (see + `autoarray.fit.fit_interferometer.dirty_model_image_natural_from`). + + It is the model counterpart of the dataset's `dirty_image_natural` and needs no visibilities, so it is + how a fit on an array-free dataset is visualized. It is available on any dataset carrying a + `sparse_operator` (array-free, or in-memory after `apply_sparse_operator()`); otherwise it raises an + `aa.exc.DatasetException`. + """ + return aa.fit.fit_interferometer.dirty_model_image_natural_from( + dataset=self.dataset, image=self.model_image_natural + ) + + @property + def dirty_residual_map_natural(self) -> aa.Array2D: + """ + The naturally weighted dirty residual map, `dirty_image_natural - dirty_model_image_natural`, which is + `Re(F^H W (d - F m)) / sum(w)`: the natural dirty image of the visibility residuals, computed without + them. + """ + return aa.Array2D( + values=np.asarray(self.dataset.dirty_image_natural.array) + - np.asarray(self.dirty_model_image_natural.array), + mask=self.dataset.real_space_mask, + ) + @property def galaxy_signal_to_noise_map_dict(self) -> Dict[ag.Galaxy, np.ndarray]: """ diff --git a/autolens/interferometer/model/visualizer.py b/autolens/interferometer/model/visualizer.py index 893c9d06b6..5bdc344088 100644 --- a/autolens/interferometer/model/visualizer.py +++ b/autolens/interferometer/model/visualizer.py @@ -49,9 +49,14 @@ def visualize_before_fit( positions = ag.Grid2DIrregular(positions_list) - plotter.image_with_positions( - image=analysis.dataset.dirty_image, positions=positions - ) + # An array-free dataset (from_stream / from_sparse_terms) has no visibilities to + # form the unweighted dirty image from, so its natural-weighted one is shown. + if analysis.dataset.is_array_free: + image = analysis.dataset.dirty_image_natural + else: + image = analysis.dataset.dirty_image + + plotter.image_with_positions(image=image, positions=positions) if analysis.adapt_images is not None: plotter.adapt_images(adapt_images=analysis.adapt_images) @@ -116,7 +121,7 @@ def visualize( source_plane_line_colors=sp_colors, ) except exc.InversionException: - logger(ag.exc.invalid_linear_algebra_for_visualization_message()) + logger.warning(ag.exc.invalid_linear_algebra_for_visualization_message()) return if quick_update: diff --git a/autolens/interferometer/plot/fit_interferometer_plots.py b/autolens/interferometer/plot/fit_interferometer_plots.py index 01162898ec..97b91f7ac6 100644 --- a/autolens/interferometer/plot/fit_interferometer_plots.py +++ b/autolens/interferometer/plot/fit_interferometer_plots.py @@ -142,6 +142,10 @@ def subplot_fit( * Dirty chi-squared map * Source plane image (full extent) + On an array-free dataset (``fit.dataset.is_array_free``) there are no visibilities, so a + 2 × 3 grid of the natural-weighted dirty image, dirty model image and dirty residual map + plus the zoomed and unzoomed source plane is written to the same filename instead. + Parameters ---------- fit : FitInterferometer @@ -164,6 +168,37 @@ def subplot_fit( ) _pf = (lambda t: f"{title_prefix.rstrip()} {t}") if title_prefix else (lambda t: t) + + if fit.dataset.is_array_free: + # An array-free dataset (from_stream / from_sparse_terms) has no visibilities, so the + # visibility-space and unweighted dirty panels are replaced by the natural-weighted + # dirty image / model / residual (from the sparse terms) and the source plane. + fig, axes = subplots(2, 3, figsize=conf_subplot_figsize(2, 3)) + axes_flat = list(axes.flatten()) + + plot_array(array=fit.dataset.dirty_image_natural, ax=axes_flat[0], + title=_pf("Dirty Image (Natural)"), colormap=colormap) + plot_array(array=fit.dirty_model_image_natural, ax=axes_flat[1], + title=_pf("Dirty Model Image (Natural)"), colormap=colormap, + lines=image_plane_lines, line_colors=image_plane_line_colors) + plot_array(array=fit.dirty_residual_map_natural, ax=axes_flat[2], + title=_pf("Dirty Residual Map (Natural)"), colormap=colormap) + _plot_source_plane(fit, axes_flat[3], final_plane_index, + zoom_to_brightest=True, colormap=colormap, + title=_pf("Source Plane (Zoomed)"), + lines=source_plane_lines, + line_colors=source_plane_line_colors) + _plot_source_plane(fit, axes_flat[4], final_plane_index, + zoom_to_brightest=False, colormap=colormap, + title=_pf("Source Plane (No Zoom)"), + lines=source_plane_lines, + line_colors=source_plane_line_colors) + axes_flat[5].axis("off") + + tight_layout() + save_figure(fig, path=output_path, filename="fit", format=output_format) + return + fig, axes = subplots(3, 4, figsize=conf_subplot_figsize(3, 4)) axes_flat = list(axes.flatten()) @@ -265,6 +300,9 @@ def subplot_fit_dirty_images( Dirty Image | Dirty Signal-To-Noise Map | Dirty Model Image (critical curves) Dirty Residual Map | Dirty Norm Residual Map | Dirty Chi-Squared Map + On an array-free dataset (``fit.dataset.is_array_free``) a 1 × 3 subplot of the + natural-weighted dirty image, dirty model image and dirty residual map is written instead. + Parameters ---------- fit : FitInterferometer @@ -286,6 +324,25 @@ def subplot_fit_dirty_images( ) _pf = (lambda t: f"{title_prefix.rstrip()} {t}") if title_prefix else (lambda t: t) + + if fit.dataset.is_array_free: + fig, axes = subplots(1, 3, figsize=conf_subplot_figsize(1, 3)) + axes_flat = list(axes.flatten()) + + plot_array(array=fit.dataset.dirty_image_natural, ax=axes_flat[0], + title=_pf("Dirty Image (Natural)"), colormap=colormap, + use_log10=use_log10) + plot_array(array=fit.dirty_model_image_natural, ax=axes_flat[1], + title=_pf("Dirty Model Image (Natural)"), colormap=colormap, + use_log10=use_log10, lines=image_plane_lines, + line_colors=image_plane_line_colors) + plot_array(array=fit.dirty_residual_map_natural, ax=axes_flat[2], + title=_pf("Dirty Residual Map (Natural)"), colormap=colormap) + + tight_layout() + save_figure(fig, path=output_path, filename="fit_dirty_images", format=output_format) + return + fig, axes = subplots(2, 3, figsize=conf_subplot_figsize(2, 3)) axes_flat = list(axes.flatten()) @@ -329,6 +386,9 @@ def subplot_fit_interferometer_combined( different panel choice because interferometer fits are most informatively visualised in dirty-image space. + A fit on an array-free dataset (``fit.dataset.is_array_free``) has no visibilities, so its row + shows the natural-weighted dirty image, dirty model image, source plane and dirty residual map. + Parameters ---------- fit_list : list of FitInterferometer @@ -362,17 +422,23 @@ def subplot_fit_interferometer_combined( tracer, cc_grid ) + array_free = fit.dataset.is_array_free + plot_array( - array=fit.dirty_image, + array=fit.dataset.dirty_image_natural if array_free else fit.dirty_image, ax=row_axes[0], - title=_pf(f"Dirty Image (ch {row})"), + title=_pf( + f"Dirty Image (Natural) (ch {row})" + if array_free + else f"Dirty Image (ch {row})" + ), colormap=colormap, ) plot_array( - array=fit.dirty_model_image, + array=fit.dirty_model_image_natural if array_free else fit.dirty_model_image, ax=row_axes[1], - title=_pf("Dirty Model Image"), + title=_pf("Dirty Model Image (Natural)" if array_free else "Dirty Model Image"), colormap=colormap, lines=ip_lines, line_colors=ip_colors, @@ -391,13 +457,21 @@ def subplot_fit_interferometer_combined( except Exception: row_axes[2].axis("off") - plot_array( - array=fit.dirty_normalized_residual_map, - ax=row_axes[3], - title=_pf("Dirty Norm Residual"), - colormap=colormap, - cb_unit=r"$\sigma$", - ) + if array_free: + plot_array( + array=fit.dirty_residual_map_natural, + ax=row_axes[3], + title=_pf("Dirty Residual Map (Natural)"), + colormap=colormap, + ) + else: + plot_array( + array=fit.dirty_normalized_residual_map, + ax=row_axes[3], + title=_pf("Dirty Norm Residual"), + colormap=colormap, + cb_unit=r"$\sigma$", + ) tight_layout() save_figure(fig, path=output_path, filename="fit_combined", format=output_format) @@ -456,8 +530,18 @@ def subplot_fit_real_space( lines=source_plane_lines, line_colors=source_plane_line_colors) else: # Pixelized source: dirty model image + source reconstruction - plot_array(array=fit.dirty_model_image, ax=axes_flat[0], - title=_pf("Reconstructed Image"), colormap=colormap) + # An array-free dataset has no transformer: its dirty model image is the + # natural-weighted one formed from the sparse terms. + plot_array( + array=( + fit.dirty_model_image_natural + if fit.dataset.is_array_free + else fit.dirty_model_image + ), + ax=axes_flat[0], + title=_pf("Reconstructed Image"), + colormap=colormap, + ) _plot_source_plane(fit, axes_flat[1], final_plane_index, zoom_to_brightest=True, colormap=colormap, title=_pf("Source Reconstruction"), @@ -532,9 +616,15 @@ def subplot_tracer_from_fit( axes_flat = list(axes.flatten()) # Panel 0: Dirty Model Image - plot_array(array=fit.dirty_model_image, ax=axes_flat[0], title=_pf("Dirty Model Image"), - lines=image_plane_lines, line_colors=image_plane_line_colors, - colormap=colormap) + if fit.dataset.is_array_free: + plot_array(array=fit.dirty_model_image_natural, ax=axes_flat[0], + title=_pf("Dirty Model Image (Natural)"), + lines=image_plane_lines, line_colors=image_plane_line_colors, + colormap=colormap) + else: + plot_array(array=fit.dirty_model_image, ax=axes_flat[0], title=_pf("Dirty Model Image"), + lines=image_plane_lines, line_colors=image_plane_line_colors, + colormap=colormap) # Panel 1: Lensed source image (image-plane projection). # Use galaxy_image_dict so that pixelized (inversion) sources are included. diff --git a/test_autolens/interferometer/model/files/dirty_images.fits b/test_autolens/interferometer/model/files/dirty_images.fits deleted file mode 100644 index 9aa1b9c8aa21322d68b6aa0e33d64fcdabca7b2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 207360 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 0.0 + assert np.abs(fit.inversion.mapped_reconstructed_data.array).max() > 0.0 + + np.testing.assert_allclose( + fit.model_image_natural.array, + fit.profile_image.array + fit.inversion.mapped_reconstructed_data.array, + rtol=1.0e-12, + ) + + expected = _natural_dirty_image_of_model_data(dataset=dataset, fit=fit) + + np.testing.assert_allclose( + fit.dirty_model_image_natural.array, + expected.array, + rtol=1.0e-8, + atol=1.0e-8 * np.abs(expected.array).max(), + ) + np.testing.assert_allclose( + fit.dirty_residual_map_natural.array, + dataset.dirty_image_natural.array - expected.array, + rtol=1.0e-8, + atol=1.0e-8 * np.abs(expected.array).max(), + )