From 6958c0bf0498330a19bba502d164324ebe8e1072 Mon Sep 17 00:00:00 2001 From: NiekWielders Date: Tue, 4 Aug 2026 14:44:00 +0100 Subject: [PATCH 1/2] feat: make the minimum MGE Gaussian sigma configurable `mge_model_from` hard-coded the lower end of the log-spaced sigma list at 10**-4 arcsec, so the basis always spent components on scales far below the resolution of any real dataset (and the comment claimed 0.01 arcsec, which it never was). Add a `sigma_min` argument (default `1e-4`, preserving existing behaviour) setting the smallest Gaussian width in arcseconds, validated to be positive and no larger than `mask_radius`. Co-Authored-By: Claude Opus 5 --- autogalaxy/analysis/model_util.py | 24 +++++++++++-- test_autogalaxy/analysis/test_model_util.py | 37 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/autogalaxy/analysis/model_util.py b/autogalaxy/analysis/model_util.py index c61f0124..ba512196 100644 --- a/autogalaxy/analysis/model_util.py +++ b/autogalaxy/analysis/model_util.py @@ -17,6 +17,7 @@ def mge_model_from( ell_comps_uniform_width: float = 0.2, ell_comps_sigma : float = 0.3, use_spherical: bool = False, + sigma_min: float = 1e-4, ) -> af.Collection: """ Construct a Multi-Gaussian Expansion (MGE) for the lens or source galaxy light. @@ -50,6 +51,11 @@ def mge_model_from( mask_radius The outer radius (in arcseconds) of the circular mask applied to the data. This determines the maximum Gaussian width (`sigma`) used in the MGE. + sigma_min + The smallest Gaussian width (`sigma`) in arcseconds, which sets the lower end + of the log-spaced sigma values. Defaults to ``1e-4``. Increase it (e.g. to the + pixel scale or half the PSF FWHM) to stop the basis wasting components on + scales the data cannot resolve. total_gaussians Total number of Gaussian light profiles used in each basis. gaussian_per_basis @@ -97,8 +103,22 @@ def mge_model_from( from autogalaxy.profiles.light.linear import Gaussian, GaussianSph from autogalaxy.profiles.basis import Basis - # The sigma values of the Gaussians will be fixed to values spanning 0.01 to the mask radius. - log10_sigma_list = np.linspace(-4, np.log10(mask_radius), total_gaussians) + if sigma_min <= 0.0: + raise ValueError( + f"mge_model_from requires sigma_min > 0.0, got {sigma_min}." + ) + + if sigma_min > mask_radius: + raise ValueError( + f"mge_model_from requires sigma_min <= mask_radius, got sigma_min=" + f"{sigma_min} and mask_radius={mask_radius}." + ) + + # The sigma values of the Gaussians are fixed to log-spaced values spanning + # `sigma_min` (default 0.0001") to the mask radius. + log10_sigma_list = np.linspace( + np.log10(sigma_min), np.log10(mask_radius), total_gaussians + ) if use_spherical: model_cls = GaussianSph diff --git a/test_autogalaxy/analysis/test_model_util.py b/test_autogalaxy/analysis/test_model_util.py index ed7e79a7..db7fd85c 100644 --- a/test_autogalaxy/analysis/test_model_util.py +++ b/test_autogalaxy/analysis/test_model_util.py @@ -105,6 +105,43 @@ def test__mge_model_from__total_gaussians_per_basis(): assert len(instance.profile_list) == 10 +def test__mge_model_from__sigma_min_default_spans_1e4_to_mask_radius(): + model = ag.model_util.mge_model_from(mask_radius=3.0, total_gaussians=5) + + instance = model.instance_from_prior_medians() + sigma_list = [profile.sigma for profile in instance.profile_list] + + assert sigma_list[0] == pytest.approx(1e-4, 1.0e-8) + assert sigma_list[-1] == pytest.approx(3.0, 1.0e-8) + + +def test__mge_model_from__sigma_min_input_sets_smallest_gaussian(): + model = ag.model_util.mge_model_from( + mask_radius=3.0, total_gaussians=5, sigma_min=0.01 + ) + + instance = model.instance_from_prior_medians() + sigma_list = [profile.sigma for profile in instance.profile_list] + + assert sigma_list[0] == pytest.approx(0.01, 1.0e-8) + assert sigma_list[-1] == pytest.approx(3.0, 1.0e-8) + assert sigma_list == pytest.approx( + list(10 ** np.linspace(np.log10(0.01), np.log10(3.0), 5)), 1.0e-8 + ) + + +def test__mge_model_from__sigma_min_invalid_raises(): + with pytest.raises(ValueError): + ag.model_util.mge_model_from( + mask_radius=3.0, total_gaussians=5, sigma_min=0.0 + ) + + with pytest.raises(ValueError): + ag.model_util.mge_model_from( + mask_radius=3.0, total_gaussians=5, sigma_min=4.0 + ) + + def test__mge_point_model_from__returns_basis_model_with_correct_gaussians(): """ mge_point_model_from should return an af.Model wrapping a Basis whose From 4a9084d5c7bc6b4d7a895ee49302d67a3ff52892 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 4 Aug 2026 15:10:16 +0100 Subject: [PATCH 2/2] test: lock the default MGE sigma ladders, add sigma_min to mge_point_model_from Follow-up to the `sigma_min` argument, covering three things: - Regression tests asserting the DEFAULT sigma ladders of both `mge_model_from` (sigma_min=1e-4) and `mge_point_model_from` (sigma_min=0.01) reproduce the previously hardcoded `-4` / `-2` linspace values EXACTLY. Every fixed sigma feeds the PyAutoFit run identifier, so drift would orphan the output directories of existing fits. The assertions use `==` rather than `pytest.approx`, which only fails once the ladder has moved past the point the identifier changes. - `mge_point_model_from` gains the same `sigma_min` argument (default 0.01, reproducing the hardcoded `min_log10_sigma = -2.0` exactly), since it had the identical hardcoded floor and is the function that already receives `pixel_scales`. - `sigma_min` docstring entry moved to match its position in the signature. Verified bit-identical: `np.log10(1e-4)` is exactly -4.0 and `np.log10(0.01)` exactly -2.0, so both linspace calls receive unchanged endpoints. Diffed model-by-model against main across 1440 configurations of mask_radius x total_gaussians x gaussian_per_basis x use_spherical x centre prior modes: zero sigma differences, zero identifier differences. Co-Authored-By: Claude Opus 5 --- autogalaxy/analysis/model_util.py | 37 +++++++---- test_autogalaxy/analysis/test_model_util.py | 74 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/autogalaxy/analysis/model_util.py b/autogalaxy/analysis/model_util.py index ba512196..66db8131 100644 --- a/autogalaxy/analysis/model_util.py +++ b/autogalaxy/analysis/model_util.py @@ -51,11 +51,6 @@ def mge_model_from( mask_radius The outer radius (in arcseconds) of the circular mask applied to the data. This determines the maximum Gaussian width (`sigma`) used in the MGE. - sigma_min - The smallest Gaussian width (`sigma`) in arcseconds, which sets the lower end - of the log-spaced sigma values. Defaults to ``1e-4``. Increase it (e.g. to the - pixel scale or half the PSF FWHM) to stop the basis wasting components on - scales the data cannot resolve. total_gaussians Total number of Gaussian light profiles used in each basis. gaussian_per_basis @@ -87,6 +82,11 @@ def mge_model_from( use_spherical If True, use ``GaussianSph`` (no ell_comps). If False (default), use ``Gaussian`` with ellipticity. + sigma_min + The smallest Gaussian width (`sigma`) in arcseconds, which sets the lower end + of the log-spaced sigma values. Defaults to ``1e-4``. Increase it (e.g. to a + tenth of the pixel scale) to stop the basis wasting components on scales the + data cannot resolve. Returns ------- @@ -199,13 +199,14 @@ def mge_point_model_from( pixel_scales: float, total_gaussians: int = 10, centre: Tuple[float, float] = (0.0, 0.0), + sigma_min: float = 0.01, ) -> af.Model: """ Construct a Multi-Gaussian Expansion (MGE) model for a compact or unresolved point-like component (e.g. a nuclear starburst, AGN, or unresolved bulge). The model is composed of ``total_gaussians`` linear Gaussians whose sigma values - are logarithmically spaced between 0.01 arcseconds and twice the pixel scale. + are logarithmically spaced between ``sigma_min`` and twice the pixel scale. All Gaussians share the same centre and ellipticity components, keeping the parameter count low while capturing a realistic PSF-convolved point source. @@ -220,6 +221,11 @@ def mge_point_model_from( centre (y, x) centre of the point source in arc-seconds. A ±0.1 arcsecond uniform prior is placed on each coordinate. + sigma_min + The smallest Gaussian width (`sigma`) in arcseconds, which sets the lower end + of the log-spaced sigma values. Defaults to ``0.01``. Increase it (e.g. to a + tenth of the pixel scale) to stop the basis wasting components on scales the + data cannot resolve. Returns ------- @@ -240,14 +246,19 @@ def mge_point_model_from( f"mge_point_model_from requires pixel_scales > 0, got {pixel_scales}." ) - # Sigma values are logarithmically spaced between 0.01 arcsec (10**-2) - # and twice the pixel scale, with a floor to avoid taking log10 of - # very small or non-positive values. - min_log10_sigma = -2.0 # corresponds to 0.01 arcsec - max_sigma = max(2.0 * pixel_scales, 10**min_log10_sigma) - max_log10_sigma = np.log10(max_sigma) + if sigma_min <= 0.0: + raise ValueError( + f"mge_point_model_from requires sigma_min > 0.0, got {sigma_min}." + ) - log10_sigma_list = np.linspace(min_log10_sigma, max_log10_sigma, total_gaussians) + # Sigma values are logarithmically spaced between `sigma_min` (default 0.01") + # and twice the pixel scale, with a floor to keep the upper end of the list + # at or above `sigma_min` when the pixel scale is very small. + max_sigma = max(2.0 * pixel_scales, sigma_min) + + log10_sigma_list = np.linspace( + np.log10(sigma_min), np.log10(max_sigma), total_gaussians + ) centre_0 = af.UniformPrior(lower_limit=centre[0] - 0.1, upper_limit=centre[0] + 0.1) centre_1 = af.UniformPrior(lower_limit=centre[1] - 0.1, upper_limit=centre[1] + 0.1) diff --git a/test_autogalaxy/analysis/test_model_util.py b/test_autogalaxy/analysis/test_model_util.py index db7fd85c..1fa11e52 100644 --- a/test_autogalaxy/analysis/test_model_util.py +++ b/test_autogalaxy/analysis/test_model_util.py @@ -142,6 +142,35 @@ def test__mge_model_from__sigma_min_invalid_raises(): ) +def test__mge_model_from__default_sigma_list_is_bitwise_unchanged(): + """ + The default `sigma_min=1e-4` must reproduce the hardcoded `np.linspace(-4, ...)` + ladder that predates the `sigma_min` argument EXACTLY, not approximately. + + Every fixed `sigma` feeds the PyAutoFit identifier of a run, so drift here gives + existing fits a new `unique_id`, orphaning their output directories and silently + restarting them from scratch. The identifier quantizes floats at + `RESOLUTION = 1e-8` (see `autofit.mapper.identifier`), so it does not in fact move + for drift below that -- but exact equality is the stronger guarantee and costs + nothing, catching drift ~8 orders of magnitude earlier than the identifier does. + + `pytest.approx(rel=1e-8)` is deliberately NOT used: it only fails once the ladder + has moved by a relative ~1e-7, which is already past the point where the + identifier changes. + """ + for mask_radius, total_gaussians in [(3.0, 20), (3.5, 30), (7.5, 10), (1.0, 5)]: + model = ag.model_util.mge_model_from( + mask_radius=mask_radius, total_gaussians=total_gaussians + ) + + instance = model.instance_from_prior_medians() + sigma_list = [profile.sigma for profile in instance.profile_list] + + assert sigma_list == list( + 10 ** np.linspace(-4, np.log10(mask_radius), total_gaussians) + ) + + def test__mge_point_model_from__returns_basis_model_with_correct_gaussians(): """ mge_point_model_from should return an af.Model wrapping a Basis whose @@ -173,6 +202,51 @@ def test__mge_point_model_from__sigma_values_span_correct_range(): assert gaussian_list[-1].sigma == pytest.approx(pixel_scales * 2.0, rel=1.0e-4) +def test__mge_point_model_from__default_sigma_list_is_bitwise_unchanged(): + """ + As for `mge_model_from`, the default `sigma_min=0.01` must reproduce the + hardcoded `min_log10_sigma = -2.0` ladder that predates the argument EXACTLY, + so the identifier of an existing point-source fit does not change. + """ + for pixel_scales, total_gaussians in [(0.1, 10), (0.05, 5), (0.2, 3), (0.001, 4)]: + model = ag.model_util.mge_point_model_from( + pixel_scales=pixel_scales, total_gaussians=total_gaussians + ) + + sigma_list = [gaussian.sigma for gaussian in model.profile_list] + + max_sigma = max(2.0 * pixel_scales, 10**-2.0) + + assert sigma_list == list( + 10 ** np.linspace(-2.0, np.log10(max_sigma), total_gaussians) + ) + + +def test__mge_point_model_from__sigma_min_input_sets_smallest_gaussian(): + total_gaussians = 5 + + model = ag.model_util.mge_point_model_from( + pixel_scales=0.1, total_gaussians=total_gaussians, sigma_min=0.01 / 10.0 + ) + + sigma_list = [gaussian.sigma for gaussian in model.profile_list] + + assert sigma_list[0] == pytest.approx(0.001, 1.0e-8) + assert sigma_list[-1] == pytest.approx(0.2, 1.0e-8) + assert sigma_list == pytest.approx( + list(10 ** np.linspace(np.log10(0.001), np.log10(0.2), total_gaussians)), + 1.0e-8, + ) + + +def test__mge_point_model_from__sigma_min_invalid_raises(): + with pytest.raises(ValueError): + ag.model_util.mge_point_model_from(pixel_scales=0.1, sigma_min=0.0) + + with pytest.raises(ValueError): + ag.model_util.mge_point_model_from(pixel_scales=0.1, sigma_min=-1.0) + + def test__mge_point_model_from__shared_centre_and_ell_comps(): """ All Gaussians must share exactly the same centre prior objects and ell_comps