From f6c995bca1803f254ba58a504d985ef4896af978 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 2 Apr 2026 10:43:34 +0100 Subject: [PATCH 1/2] Visualization cleanup: sync config and rename interferometer plot function - Sync general.yaml colorbar labelsize to 18, add dpi/log10 keys, total_mappings_pixels - Rename subplot_interferometer_dirty_images import to subplot_interferometer_dataset Co-Authored-By: Claude Sonnet 4.6 --- autogalaxy/config/visualize/general.yaml | 8 ++++++-- autogalaxy/interferometer/model/plotter.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/autogalaxy/config/visualize/general.yaml b/autogalaxy/config/visualize/general.yaml index 1fe89f21..afd1c891 100644 --- a/autogalaxy/config/visualize/general.yaml +++ b/autogalaxy/config/visualize/general.yaml @@ -1,10 +1,14 @@ general: backend: default + dpi: 150 imshow_origin: upper + log10_min_value: 1.0e-4 + log10_max_value: 1.0e99 zoom_around_mask: true critical_curves_method: zero_contour inversion: reconstruction_vmax_factor: 0.5 + total_mappings_pixels: 8 zoom: plane_percent: 0.01 inversion_percent: 0.01 @@ -19,5 +23,5 @@ colorbar: fraction: 0.047 pad: 0.01 labelrotation: 90 - labelsize: 22 - labelsize_subplot: 22 \ No newline at end of file + labelsize: 18 + labelsize_subplot: 18 \ No newline at end of file diff --git a/autogalaxy/interferometer/model/plotter.py b/autogalaxy/interferometer/model/plotter.py index e464fcb3..785b9c77 100644 --- a/autogalaxy/interferometer/model/plotter.py +++ b/autogalaxy/interferometer/model/plotter.py @@ -1,6 +1,6 @@ import autoarray as aa -from autoarray.dataset.plot.interferometer_plots import subplot_interferometer_dirty_images +from autoarray.dataset.plot.interferometer_plots import subplot_interferometer_dataset from autoconf.fitsable import hdu_list_for_output_from @@ -32,7 +32,7 @@ def should_plot(name): return plot_setting(section=["dataset", "interferometer"], name=name) if should_plot("subplot_dataset"): - subplot_interferometer_dirty_images( + subplot_interferometer_dataset( dataset, output_path=self.image_path, output_filename="dataset", From c6b1b79de4170355431b6865051c14acfc62c608 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 2 Apr 2026 12:28:21 +0100 Subject: [PATCH 2/2] Visualization cleanup and NFW truncated profile enhancements Co-Authored-By: Claude Opus 4.6 (1M context) --- autogalaxy/config/visualize/general.yaml | 6 +- autogalaxy/plot/plot_utils.py | 10 +- .../profiles/mass/dark/nfw_truncated.py | 254 ++++++++++++- .../profiles/mass/dark/test_nfw_truncated.py | 359 ++++++++++++++++++ 4 files changed, 620 insertions(+), 9 deletions(-) diff --git a/autogalaxy/config/visualize/general.yaml b/autogalaxy/config/visualize/general.yaml index afd1c891..415cebc6 100644 --- a/autogalaxy/config/visualize/general.yaml +++ b/autogalaxy/config/visualize/general.yaml @@ -5,7 +5,7 @@ general: log10_min_value: 1.0e-4 log10_max_value: 1.0e99 zoom_around_mask: true - critical_curves_method: zero_contour + critical_curves_method: marching_squares inversion: reconstruction_vmax_factor: 0.5 total_mappings_pixels: 8 @@ -23,5 +23,5 @@ colorbar: fraction: 0.047 pad: 0.01 labelrotation: 90 - labelsize: 18 - labelsize_subplot: 18 \ No newline at end of file + labelsize: 16 + labelsize_subplot: 16 \ No newline at end of file diff --git a/autogalaxy/plot/plot_utils.py b/autogalaxy/plot/plot_utils.py index e45a915a..8336fc6b 100644 --- a/autogalaxy/plot/plot_utils.py +++ b/autogalaxy/plot/plot_utils.py @@ -273,22 +273,22 @@ def plot_grid( def _critical_curves_method(): """Read ``general.critical_curves_method`` from the visualize config. - Returns ``"zero_contour"`` (the default) or ``"marching_squares"``. - Any unrecognised value falls back to ``"zero_contour"`` with a warning. + Returns ``"marching_squares"`` (the default) or ``"zero_contour"``. + Any unrecognised value falls back to ``"marching_squares"`` with a warning. """ from autoconf import conf try: method = conf.instance["visualize"]["general"]["general"]["critical_curves_method"] except (KeyError, TypeError): - method = "zero_contour" + method = "marching_squares" if method not in ("zero_contour", "marching_squares"): logger.warning( f"visualize/general.yaml: unrecognised critical_curves_method " - f"'{method}'. Falling back to 'zero_contour'." + f"'{method}'. Falling back to 'marching_squares'." ) - return "zero_contour" + return "marching_squares" return method diff --git a/autogalaxy/profiles/mass/dark/nfw_truncated.py b/autogalaxy/profiles/mass/dark/nfw_truncated.py index 6894f3f4..7badabd1 100644 --- a/autogalaxy/profiles/mass/dark/nfw_truncated.py +++ b/autogalaxy/profiles/mass/dark/nfw_truncated.py @@ -1,5 +1,5 @@ import numpy as np -from typing import Tuple +from typing import Optional, Tuple import autoarray as aa @@ -106,6 +106,258 @@ def coord_func_m(self, grid_radius, xp=np): ) ) + @staticmethod + def _delta_c_from_concentration(concentration: float) -> float: + """ + NFW characteristic overdensity delta_c for a given concentration. + + This is the standard NFW normalisation: + + delta_c = (200/3) * c^3 / (ln(1+c) - c/(1+c)) + + Parameters + ---------- + concentration + NFW concentration parameter c = r_200 / r_s. + """ + return ( + 200.0 + / 3.0 + * ( + concentration**3 + / ( + np.log(1.0 + concentration) + - concentration / (1.0 + concentration) + ) + ) + ) + + @staticmethod + def _concentration_at_overdensity_factor( + concentration: float, + truncation_factor: float, + ) -> float: + """ + Solve for the concentration-like parameter ``tau`` at which the mean enclosed + density of the NFW equals ``truncation_factor`` times the critical density. + + For a truncation factor of 100, this finds ``r_100`` expressed as ``r_100 / r_s``. + The truncation radius of the tNFW profile is then ``tau * r_s``. + + Parameters + ---------- + concentration + NFW concentration parameter c = r_200 / r_s. + truncation_factor + Overdensity threshold that defines the truncation radius. The + truncation radius is the sphere within which the mean enclosed density + equals ``truncation_factor`` times the critical density. The default + value of 100 sets truncation at r_100. + """ + from scipy.optimize import fsolve + + delta_c = NFWTruncatedSph._delta_c_from_concentration(concentration) + + def equation(tau): + return ( + truncation_factor + / 3.0 + * (tau**3 / (np.log(1.0 + tau) - tau / (1.0 + tau))) + - delta_c + ) + + return float(fsolve(equation, concentration, full_output=False)[0]) + + @classmethod + def from_m200_concentration( + cls, + centre: Tuple[float, float] = (0.0, 0.0), + m200_solar_mass: float = 1e9, + concentration: float = 10.0, + redshift_halo: float = 0.5, + redshift_source: float = 1.0, + cosmology: Optional[LensingCosmology] = None, + truncation_factor: float = 100.0, + ) -> "NFWTruncatedSph": + """ + Construct an ``NFWTruncatedSph`` from the halo virial mass M_200 and + concentration rather than the lensing parameters (kappa_s, scale_radius, + truncation_radius). + + The conversion follows the standard NFW lensing procedure (He et al. 2022, + MNRAS 511 3046): + + 1. Derive the NFW scale radius and characteristic density from M_200, the + concentration, and the critical density at ``redshift_halo``. + 2. Convert to the dimensionless convergence ``kappa_s`` using the critical + surface density between ``redshift_halo`` and ``redshift_source``. + 3. Express the scale radius in arc-seconds using the angular diameter + distance to ``redshift_halo``. + 4. Set the truncation radius to ``r_t`` where the mean enclosed density + equals ``truncation_factor`` times the critical density (default is + r_100 for ``truncation_factor=100``). + + Parameters + ---------- + centre + The (y, x) arc-second coordinates of the profile centre. + m200_solar_mass + Virial mass M_200 in solar masses. + concentration + NFW concentration parameter c = r_200 / r_s. + redshift_halo + Redshift of the line-of-sight halo. + redshift_source + Redshift of the lensed background source. + cosmology + Cosmology used for distance and density calculations. Defaults to + Planck15 if not supplied. + truncation_factor + Overdensity threshold defining the truncation radius. The default + value of 100 sets the truncation at r_100. + """ + from autogalaxy.cosmology.model import Planck15 + + if cosmology is None: + cosmology = Planck15() + + critical_density = cosmology.critical_density(redshift_halo) + kpc_per_arcsec = cosmology.kpc_per_arcsec_from(redshift=redshift_halo) + critical_surface_density = cosmology.critical_surface_density_between_redshifts_solar_mass_per_kpc2_from( + redshift_0=redshift_halo, + redshift_1=redshift_source, + ) + + r200_kpc = ( + m200_solar_mass / (200.0 * critical_density * (4.0 * np.pi / 3.0)) + ) ** (1.0 / 3.0) + + delta_c = cls._delta_c_from_concentration(concentration) + rs_kpc = r200_kpc / concentration + rho_s = critical_density * delta_c + + kappa_s = rho_s * rs_kpc / critical_surface_density + scale_radius = rs_kpc / kpc_per_arcsec + + tau = cls._concentration_at_overdensity_factor(concentration, truncation_factor) + truncation_radius = tau * scale_radius + + return cls( + centre=centre, + kappa_s=kappa_s, + scale_radius=scale_radius, + truncation_radius=truncation_radius, + ) + + @staticmethod + def m200_concentration_from( + kappa_s: float, + scale_radius: float, + redshift_halo: float, + redshift_source: float, + cosmology: Optional[LensingCosmology] = None, + ) -> Tuple[float, float]: + """ + Recover the virial mass M_200 and concentration from lensing parameters. + + This is the inverse of :meth:`from_m200_concentration`. Given the + dimensionless convergence ``kappa_s`` and the scale radius in arc-seconds, + the characteristic NFW density and scale radius in kpc are recovered, and + the concentration is solved numerically from the NFW overdensity equation. + + Parameters + ---------- + kappa_s + Dimensionless NFW convergence normalisation = rho_s * r_s / Sigma_crit. + scale_radius + NFW scale radius in arc-seconds. + redshift_halo + Redshift of the halo. + redshift_source + Redshift of the background source. + cosmology + Cosmology used for distance and density calculations. Defaults to + Planck15 if not supplied. + + Returns + ------- + Tuple[float, float] + ``(m200_solar_mass, concentration)``. + """ + from scipy.optimize import fsolve + from autogalaxy.cosmology.model import Planck15 + + if cosmology is None: + cosmology = Planck15() + + critical_density = cosmology.critical_density(redshift_halo) + kpc_per_arcsec = cosmology.kpc_per_arcsec_from(redshift=redshift_halo) + critical_surface_density = cosmology.critical_surface_density_between_redshifts_solar_mass_per_kpc2_from( + redshift_0=redshift_halo, + redshift_1=redshift_source, + ) + + rs_kpc = scale_radius * kpc_per_arcsec + rho_s = kappa_s * critical_surface_density / rs_kpc + delta_c = rho_s / critical_density + + def equation(c): + return ( + 200.0 + / 3.0 + * (c**3 / (np.log(1.0 + c) - c / (1.0 + c))) + - delta_c + ) + + concentration = float(fsolve(equation, 10.0)[0]) + r200_kpc = concentration * rs_kpc + m200 = 200.0 * (4.0 / 3.0 * np.pi) * critical_density * r200_kpc**3 + + return m200, concentration + + @staticmethod + def mass_ratio_from_concentration_and_truncation_factor( + concentration: float, + truncation_factor: float = 100.0, + ) -> float: + """ + Mass ratio of a truncated NFW halo to its untruncated M_200 value. + + The truncated NFW mass is: + + M_tNFW = M_200 * tau_scale / c_scale + + where: + tau_scale = tau^2/(tau^2+1)^2 * ((tau^2-1)*ln(tau) + tau*pi - (tau^2+1)) + c_scale = ln(1+c) - c/(1+c) + + and ``tau`` is the solution to the ``_concentration_at_overdensity_factor`` + equation for the given concentration and truncation factor. + + This is the function tabulated and cubic-spline interpolated as the + ``scale_c(c)`` function in the los_pipes simulation code (He et al. 2022). + + Parameters + ---------- + concentration + NFW concentration parameter c = r_200 / r_s. + truncation_factor + Overdensity threshold defining the truncation radius (default 100). + """ + tau = NFWTruncatedSph._concentration_at_overdensity_factor( + concentration, truncation_factor + ) + + tau2 = tau**2 + tau_scale = ( + tau2 + / (tau2 + 1.0) ** 2 + * ((tau2 - 1.0) * np.log(tau) + tau * np.pi - (tau2 + 1.0)) + ) + c_scale = np.log(1.0 + concentration) - concentration / (1.0 + concentration) + + return tau_scale / c_scale + def mass_at_truncation_radius_solar_mass( self, redshift_profile, diff --git a/test_autogalaxy/profiles/mass/dark/test_nfw_truncated.py b/test_autogalaxy/profiles/mass/dark/test_nfw_truncated.py index ead49302..6af6dc22 100644 --- a/test_autogalaxy/profiles/mass/dark/test_nfw_truncated.py +++ b/test_autogalaxy/profiles/mass/dark/test_nfw_truncated.py @@ -1,7 +1,9 @@ import numpy as np import pytest +from scipy.optimize import fsolve import autogalaxy as ag +from autogalaxy.cosmology.model import Planck15 grid = ag.Grid2DIrregular([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0], [2.0, 4.0]]) @@ -155,3 +157,360 @@ def test__compare_nfw_and_truncated_nfw_with_large_truncation_radius(): ) assert truncated_nfw_deflections == pytest.approx(nfw_deflections.array, abs=1.0e-4) + + +# --------------------------------------------------------------------------- +# Helpers: reference implementation of the los_pipes unit-conversion formulas +# (convert_units.py from He et al. 2022, MNRAS 511 3046) expressed in terms of +# the PyAutoGalaxy cosmology API. These are used as ground-truth values in the +# regression tests below. +# --------------------------------------------------------------------------- + +def _los_pipes_reference_delta_c(concentration): + """NFW characteristic overdensity as computed by los_pipes.""" + c = concentration + return 200.0 / 3.0 * (c**3 / (np.log(1.0 + c) - c / (1.0 + c))) + + +def _los_pipes_reference_c50(concentration, truncation_factor): + """Concentration at the truncation overdensity (c50 in los_pipes).""" + delta_c = _los_pipes_reference_delta_c(concentration) + + def eq(tau): + return ( + truncation_factor / 3.0 * (tau**3 / (np.log(1.0 + tau) - tau / (1.0 + tau))) + - delta_c + ) + + return float(fsolve(eq, concentration)[0]) + + +def _los_pipes_reference_convert_to_lens_unit( + m200, concentration, z_halo, z_source, truncation_factor=100.0 +): + """ + Replicate los_pipes convert_to_lens_unit() using PyAutoGalaxy cosmology. + + Returns (kappa_s, scale_radius_arcsec, truncation_radius_arcsec). + """ + cosmo = Planck15() + + critical_density = cosmo.critical_density(z_halo) + kpc_per_arcsec = cosmo.kpc_per_arcsec_from(z_halo) + sigma_crit = cosmo.critical_surface_density_between_redshifts_solar_mass_per_kpc2_from( + z_halo, z_source + ) + + r200_kpc = (m200 / (200.0 * critical_density * (4.0 * np.pi / 3.0))) ** (1.0 / 3.0) + delta_c = _los_pipes_reference_delta_c(concentration) + rs_kpc = r200_kpc / concentration + rho_s = critical_density * delta_c + + kappa_s = rho_s * rs_kpc / sigma_crit + scale_radius_arcsec = rs_kpc / kpc_per_arcsec + + c50 = _los_pipes_reference_c50(concentration, truncation_factor) + truncation_radius_arcsec = c50 * scale_radius_arcsec + + return kappa_s, scale_radius_arcsec, truncation_radius_arcsec + + +def _los_pipes_reference_mass_ratio(concentration, truncation_factor=100.0): + """Replicate los_pipes scale_c(c): M_tNFW / M_200.""" + tau = _los_pipes_reference_c50(concentration, truncation_factor) + tau2 = tau**2 + tau_scale = ( + tau2 + / (tau2 + 1.0) ** 2 + * ((tau2 - 1.0) * np.log(tau) + tau * np.pi - (tau2 + 1.0)) + ) + c_scale = np.log(1.0 + concentration) - concentration / (1.0 + concentration) + return tau_scale / c_scale + + +# --------------------------------------------------------------------------- +# Tests for _delta_c_from_concentration +# --------------------------------------------------------------------------- + + +def test__delta_c_from_concentration__standard_value(): + """delta_c formula for c=10 matches the known analytic result.""" + c = 10.0 + expected = 200.0 / 3.0 * (c**3 / (np.log(1.0 + c) - c / (1.0 + c))) + result = ag.mp.NFWTruncatedSph._delta_c_from_concentration(c) + assert result == pytest.approx(expected, rel=1.0e-10) + + +def test__delta_c_from_concentration__various_concentrations(): + """delta_c increases monotonically with concentration.""" + deltas = [ + ag.mp.NFWTruncatedSph._delta_c_from_concentration(c) for c in [5.0, 10.0, 20.0] + ] + assert deltas[0] < deltas[1] < deltas[2] + + +# --------------------------------------------------------------------------- +# Tests for _concentration_at_overdensity_factor +# --------------------------------------------------------------------------- + + +def test__concentration_at_overdensity_factor__self_consistent(): + """ + For truncation_factor == 200, tau should equal the input concentration because + both solve the same overdensity condition. + """ + c = 10.0 + tau = ag.mp.NFWTruncatedSph._concentration_at_overdensity_factor(c, 200.0) + assert tau == pytest.approx(c, rel=1.0e-4) + + +def test__concentration_at_overdensity_factor__larger_for_smaller_factor(): + """ + A smaller overdensity threshold means a larger enclosed radius, so tau must + be larger when truncation_factor < 200. + """ + c = 10.0 + tau_100 = ag.mp.NFWTruncatedSph._concentration_at_overdensity_factor(c, 100.0) + tau_50 = ag.mp.NFWTruncatedSph._concentration_at_overdensity_factor(c, 50.0) + assert tau_50 > tau_100 > c + + +def test__concentration_at_overdensity_factor__matches_los_pipes_reference(): + c = 10.0 + expected = _los_pipes_reference_c50(c, 100.0) + result = ag.mp.NFWTruncatedSph._concentration_at_overdensity_factor(c, 100.0) + assert result == pytest.approx(expected, rel=1.0e-6) + + +# --------------------------------------------------------------------------- +# Tests for from_m200_concentration +# --------------------------------------------------------------------------- + + +def test__from_m200_concentration__kappa_s_matches_los_pipes(): + """kappa_s from from_m200_concentration matches los_pipes reference.""" + kappa_s_ref, _, _ = _los_pipes_reference_convert_to_lens_unit( + 1e9, 10.0, 0.5, 1.0, 100.0 + ) + nfw = ag.mp.NFWTruncatedSph.from_m200_concentration( + centre=(0.0, 0.0), + m200_solar_mass=1e9, + concentration=10.0, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + truncation_factor=100.0, + ) + assert nfw.kappa_s == pytest.approx(kappa_s_ref, rel=1.0e-6) + + +def test__from_m200_concentration__scale_radius_matches_los_pipes(): + """scale_radius_arcsec from from_m200_concentration matches los_pipes reference.""" + _, scale_radius_ref, _ = _los_pipes_reference_convert_to_lens_unit( + 1e9, 10.0, 0.5, 1.0, 100.0 + ) + nfw = ag.mp.NFWTruncatedSph.from_m200_concentration( + centre=(0.0, 0.0), + m200_solar_mass=1e9, + concentration=10.0, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + truncation_factor=100.0, + ) + assert nfw.scale_radius == pytest.approx(scale_radius_ref, rel=1.0e-6) + + +def test__from_m200_concentration__truncation_radius_matches_los_pipes(): + """truncation_radius from from_m200_concentration matches los_pipes reference.""" + _, _, truncation_radius_ref = _los_pipes_reference_convert_to_lens_unit( + 1e9, 10.0, 0.5, 1.0, 100.0 + ) + nfw = ag.mp.NFWTruncatedSph.from_m200_concentration( + centre=(0.0, 0.0), + m200_solar_mass=1e9, + concentration=10.0, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + truncation_factor=100.0, + ) + assert nfw.truncation_radius == pytest.approx(truncation_radius_ref, rel=1.0e-6) + + +def test__from_m200_concentration__different_redshifts(): + """from_m200_concentration at a different redshift pair produces valid parameters.""" + nfw = ag.mp.NFWTruncatedSph.from_m200_concentration( + centre=(0.1, -0.2), + m200_solar_mass=1e10, + concentration=7.0, + redshift_halo=0.3, + redshift_source=2.0, + cosmology=Planck15(), + truncation_factor=100.0, + ) + kappa_s_ref, rs_ref, rt_ref = _los_pipes_reference_convert_to_lens_unit( + 1e10, 7.0, 0.3, 2.0, 100.0 + ) + assert nfw.kappa_s == pytest.approx(kappa_s_ref, rel=1.0e-6) + assert nfw.scale_radius == pytest.approx(rs_ref, rel=1.0e-6) + assert nfw.truncation_radius == pytest.approx(rt_ref, rel=1.0e-6) + + +def test__from_m200_concentration__centre_is_preserved(): + nfw = ag.mp.NFWTruncatedSph.from_m200_concentration( + centre=(1.5, -0.3), + m200_solar_mass=1e9, + concentration=10.0, + redshift_halo=0.5, + redshift_source=1.0, + ) + assert nfw.centre == (1.5, -0.3) + + +def test__from_m200_concentration__default_cosmology(): + """from_m200_concentration with no cosmology argument uses Planck15.""" + nfw_default = ag.mp.NFWTruncatedSph.from_m200_concentration( + m200_solar_mass=1e9, concentration=10.0, redshift_halo=0.5, redshift_source=1.0 + ) + nfw_explicit = ag.mp.NFWTruncatedSph.from_m200_concentration( + m200_solar_mass=1e9, + concentration=10.0, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + ) + assert nfw_default.kappa_s == pytest.approx(nfw_explicit.kappa_s, rel=1.0e-10) + + +# --------------------------------------------------------------------------- +# Tests for m200_concentration_from +# --------------------------------------------------------------------------- + + +def test__m200_concentration_from__round_trip_m200(): + """m200_concentration_from recovers the input M_200 to better than 0.01%.""" + nfw = ag.mp.NFWTruncatedSph.from_m200_concentration( + m200_solar_mass=1e9, + concentration=10.0, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + ) + m200_recovered, _ = ag.mp.NFWTruncatedSph.m200_concentration_from( + kappa_s=nfw.kappa_s, + scale_radius=nfw.scale_radius, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + ) + assert m200_recovered == pytest.approx(1e9, rel=1.0e-4) + + +def test__m200_concentration_from__round_trip_concentration(): + """m200_concentration_from recovers the input concentration to better than 0.01%.""" + nfw = ag.mp.NFWTruncatedSph.from_m200_concentration( + m200_solar_mass=1e9, + concentration=10.0, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + ) + _, c_recovered = ag.mp.NFWTruncatedSph.m200_concentration_from( + kappa_s=nfw.kappa_s, + scale_radius=nfw.scale_radius, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + ) + assert c_recovered == pytest.approx(10.0, rel=1.0e-4) + + +def test__m200_concentration_from__round_trip_high_mass(): + """Round-trip test for a high-mass halo (M_200 = 1e12 M_sun).""" + nfw = ag.mp.NFWTruncatedSph.from_m200_concentration( + m200_solar_mass=1e12, + concentration=5.0, + redshift_halo=0.3, + redshift_source=2.0, + cosmology=Planck15(), + ) + m200_recovered, c_recovered = ag.mp.NFWTruncatedSph.m200_concentration_from( + kappa_s=nfw.kappa_s, + scale_radius=nfw.scale_radius, + redshift_halo=0.3, + redshift_source=2.0, + cosmology=Planck15(), + ) + assert m200_recovered == pytest.approx(1e12, rel=1.0e-4) + assert c_recovered == pytest.approx(5.0, rel=1.0e-4) + + +def test__m200_concentration_from__default_cosmology(): + """m200_concentration_from with no cosmology argument uses Planck15.""" + m200_default, c_default = ag.mp.NFWTruncatedSph.m200_concentration_from( + kappa_s=0.005781, + scale_radius=0.279092, + redshift_halo=0.5, + redshift_source=1.0, + ) + m200_explicit, c_explicit = ag.mp.NFWTruncatedSph.m200_concentration_from( + kappa_s=0.005781, + scale_radius=0.279092, + redshift_halo=0.5, + redshift_source=1.0, + cosmology=Planck15(), + ) + assert m200_default == pytest.approx(m200_explicit, rel=1.0e-10) + assert c_default == pytest.approx(c_explicit, rel=1.0e-10) + + +# --------------------------------------------------------------------------- +# Tests for mass_ratio_from_concentration_and_truncation_factor +# --------------------------------------------------------------------------- + + +def test__mass_ratio_from_concentration_and_truncation_factor__matches_los_pipes(): + """Mass ratio from source code matches the los_pipes scale_c(c) reference.""" + c = 10.0 + expected = _los_pipes_reference_mass_ratio(c, 100.0) + result = ag.mp.NFWTruncatedSph.mass_ratio_from_concentration_and_truncation_factor( + concentration=c, truncation_factor=100.0 + ) + assert result == pytest.approx(expected, rel=1.0e-6) + + +def test__mass_ratio_from_concentration_and_truncation_factor__larger_for_smaller_factor(): + """A smaller truncation factor (larger truncation radius) gives a larger mass ratio.""" + c = 10.0 + ratio_100 = ag.mp.NFWTruncatedSph.mass_ratio_from_concentration_and_truncation_factor( + c, 100.0 + ) + ratio_50 = ag.mp.NFWTruncatedSph.mass_ratio_from_concentration_and_truncation_factor( + c, 50.0 + ) + assert ratio_50 > ratio_100 + + +def test__mass_ratio_from_concentration_and_truncation_factor__various_concentrations(): + """Spot-check mass ratios at several concentrations against los_pipes reference.""" + for c in [5.0, 10.0, 20.0]: + expected = _los_pipes_reference_mass_ratio(c, 100.0) + result = ag.mp.NFWTruncatedSph.mass_ratio_from_concentration_and_truncation_factor( + c, 100.0 + ) + assert result == pytest.approx(expected, rel=1.0e-6), f"failed for c={c}" + + +def test__mass_ratio_from_concentration_and_truncation_factor__default_truncation_factor(): + """The default truncation_factor=100 is applied when not specified.""" + c = 10.0 + result_default = ( + ag.mp.NFWTruncatedSph.mass_ratio_from_concentration_and_truncation_factor(c) + ) + result_explicit = ( + ag.mp.NFWTruncatedSph.mass_ratio_from_concentration_and_truncation_factor( + c, 100.0 + ) + ) + assert result_default == pytest.approx(result_explicit, rel=1.0e-10)