diff --git a/autogalaxy/profiles/basis.py b/autogalaxy/profiles/basis.py index 3cff0f8dc..39ca46cd8 100644 --- a/autogalaxy/profiles/basis.py +++ b/autogalaxy/profiles/basis.py @@ -8,6 +8,10 @@ When linear light profiles are used in a basis, their individual intensities are solved simultaneously via a linear inversion (a single matrix solve), making the inference highly efficient regardless of how many basis components are included. + +A `Basis` declares no solved amplitude of its own (it has no `__solved_parameters__`): +the solved intensities belong to its linear member profiles, one per member, so a model +figure draws a `solved` `intensity` inside each member and none on the `Basis` itself. """ import numpy as np diff --git a/autogalaxy/profiles/light/linear/abstract.py b/autogalaxy/profiles/light/linear/abstract.py index d1e4088ed..b2941c5d8 100644 --- a/autogalaxy/profiles/light/linear/abstract.py +++ b/autogalaxy/profiles/light/linear/abstract.py @@ -70,6 +70,13 @@ class LightProfileLinear(LightProfile): _pytree_token_counter = itertools.count() __exclude_identifier_fields__ = ("pytree_token",) + # The `intensity` of a linear light profile is solved for by the inversion, not + # sampled by the non-linear search, so it has no prior and never appears in + # `model.info`. PyAutoFit's `graph_spec` reads this class attribute (the + # `__solved_parameters__` protocol) to draw `intensity` as a `solved` row in + # model figures. Subclasses (e.g. `lp_linear.Sersic`) inherit it. + __solved_parameters__ = ("intensity",) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.pytree_token = next(LightProfileLinear._pytree_token_counter) @@ -259,12 +266,14 @@ def __init__( """ for light_profile in light_profile_list: if not isinstance(light_profile, LightProfileLinear): - raise exc.ProfileException(""" + raise exc.ProfileException( + """ A light profile that is not a LightProfileLinear object has been input into the LightProfileLinearObjFuncList object. Only children of the LightProfileLinear class can be used in a linear inversion. - """) + """ + ) super().__init__( grid=grid, regularization=regularization, settings=settings, xp=xp diff --git a/autogalaxy/profiles/point_sources.py b/autogalaxy/profiles/point_sources.py index db178f6ae..8b9a36d63 100644 --- a/autogalaxy/profiles/point_sources.py +++ b/autogalaxy/profiles/point_sources.py @@ -32,4 +32,8 @@ class PointSolved: and `autolens.point.fit.fluxes`). """ - pass + # The `centre` is solved for analytically by the `*Solved` fit classes, not sampled + # by the non-linear search, so it has no prior and never appears in `model.info`. + # PyAutoFit's `graph_spec` reads this class attribute (the `__solved_parameters__` + # protocol) to draw `centre` as a `solved` row in model figures. + __solved_parameters__ = ("centre",) diff --git a/docs/general/images/model_cookbook/complex.png b/docs/general/images/model_cookbook/complex.png new file mode 100644 index 000000000..4ca45e344 Binary files /dev/null and b/docs/general/images/model_cookbook/complex.png differ diff --git a/docs/general/images/model_cookbook/concise.png b/docs/general/images/model_cookbook/concise.png new file mode 100644 index 000000000..4ca45e344 Binary files /dev/null and b/docs/general/images/model_cookbook/concise.png differ diff --git a/docs/general/images/model_cookbook/make_figures.py b/docs/general/images/model_cookbook/make_figures.py new file mode 100644 index 000000000..fec37bcdd --- /dev/null +++ b/docs/general/images/model_cookbook/make_figures.py @@ -0,0 +1,191 @@ +""" +Regenerate the committed model-figure renders for the PyAutoGalaxy docs. + +Run from the PyAutoGalaxy repo root, with the worktree environment active:: + + python docs/general/images/model_cookbook/make_figures.py + +Every PNG is written at ``width=14.0`` inches and DPI 100, so none exceeds the +1400 px width budget of the ``model-figures`` epic. + +One figure is written per ``print(model.info)`` stage of +``autogalaxy_workspace/scripts/guides/modeling/cookbook.py`` -- the models below +are that script's compositions -- and +``docs/overview/images/overview_3/mge_model.png`` is the multi-Gaussian +expansion model of ``scripts/imaging/features/multi_gaussian_expansion/modeling.py``. +""" + +import sys +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") + +REPO_ROOT = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(REPO_ROOT)) + +import numpy as np + +import autofit as af +import autogalaxy as ag + +OUTPUT_PATH = Path(__file__).resolve().parent +OVERVIEW_PATH = REPO_ROOT / "docs" / "overview" / "images" / "overview_3" +WIDTH = 14.0 + + +def write(model, name: str, path: Path = OUTPUT_PATH, **kwargs): + """Render one model to ``.png`` in ``path``.""" + path.mkdir(parents=True, exist_ok=True) + + af.ModelPlotter(model).figure( + path=str(path), + filename=name, + format="png", + width=WIDTH, + **kwargs, + ) + print(f"wrote {path.name}/{name}.png") + + +# --------------------------------------------------------------------------- +# the cookbook stages -- `scripts/guides/modeling/cookbook.py` +# --------------------------------------------------------------------------- + + +def simple(): + """``__Simple Model__`` -- cookbook.py:44-48.""" + bulge = af.Model(ag.lp_linear.Sersic) + + galaxy = af.Model(ag.Galaxy, redshift=0.5, bulge=bulge) + + return af.Collection(galaxies=af.Collection(galaxy=galaxy)) + + +def complex_model(): + """``__More Complex Models__`` -- cookbook.py:68-76.""" + bulge = af.Model(ag.lp_linear.Sersic) + disk = af.Model(ag.lp_linear.Exponential) + bar = af.Model(ag.lp_linear.Sersic) + + galaxy = af.Model(ag.Galaxy, redshift=0.5, bulge=bulge, disk=disk, bar=bar) + + return af.Collection(galaxies=af.Collection(galaxy=galaxy)) + + +def two_galaxies(): + """``__More Complex Models__``, two galaxies -- cookbook.py:83-107.""" + galaxy_0 = af.Model(ag.Galaxy, redshift=0.5, bulge=af.Model(ag.lp_linear.Sersic)) + galaxy_1 = af.Model(ag.Galaxy, redshift=0.5, bulge=af.Model(ag.lp_linear.Sersic)) + + return af.Collection(galaxies=af.Collection(galaxy_0=galaxy_0, galaxy_1=galaxy_1)) + + +def concise(): + """``__Concise API__`` -- cookbook.py:118-127.""" + galaxy = af.Model( + ag.Galaxy, + redshift=0.5, + bulge=ag.lp_linear.Sersic, + disk=ag.lp_linear.Exponential, + bar=ag.lp_linear.Sersic, + ) + + return af.Collection(galaxies=af.Collection(galaxy=galaxy)) + + +def prior_custom(): + """``__Prior Customization__`` -- cookbook.py:134-149.""" + bulge = af.Model(ag.lp_linear.Sersic) + bulge.centre.centre_0 = af.UniformPrior(lower_limit=-0.1, upper_limit=0.1) + bulge.centre.centre_1 = af.UniformPrior(lower_limit=-0.1, upper_limit=0.1) + bulge.sersic_index = af.TruncatedGaussianPrior( + mean=4.0, sigma=1.0, lower_limit=1.0, upper_limit=8.0 + ) + + galaxy = af.Model(ag.Galaxy, redshift=0.5, bulge=bulge) + + return af.Collection(galaxies=af.Collection(galaxy=galaxy)) + + +def model_custom(): + """``__Model Customization__`` -- cookbook.py:156-194.""" + bulge = af.Model(ag.lp_linear.Sersic) + disk = af.Model(ag.lp_linear.Exponential) + + bulge.centre = disk.centre + bulge.sersic_index = 4.0 + bulge.effective_radius = disk.effective_radius + 0.1 + + galaxy = af.Model(ag.Galaxy, redshift=0.5, bulge=bulge, disk=disk) + + model = af.Collection(galaxies=af.Collection(galaxy=galaxy)) + + model.add_assertion( + model.galaxies.galaxy.bulge.effective_radius + > model.galaxies.galaxy.disk.effective_radius + ) + model.add_assertion(model.galaxies.galaxy.bulge.effective_radius < 3.0) + + return model + + +# --------------------------------------------------------------------------- +# the overview_3 MGE figure +# --------------------------------------------------------------------------- + + +def mge_overview(total_gaussians: int = 30, gaussian_per_basis: int = 2): + """ + The MGE model of ``features/multi_gaussian_expansion/modeling.py``. + + ``gaussian_per_basis`` bases of ``total_gaussians`` linear Gaussians, all + sharing one ``centre``, each basis sharing its own ``ell_comps`` and every + ``sigma`` fixed to its own log-spaced value. + """ + mask_radius = 3.0 + pixel_scale = 0.1 + + log10_sigma_list = np.linspace( + np.log10(pixel_scale / 10.0), np.log10(mask_radius), total_gaussians + ) + + centre_0 = af.UniformPrior(lower_limit=-0.1, upper_limit=0.1) + centre_1 = af.UniformPrior(lower_limit=-0.1, upper_limit=0.1) + + bulge_gaussian_list = [] + + for _ in range(gaussian_per_basis): + gaussian_list = af.Collection( + af.Model(ag.lp_linear.Gaussian) for _ in range(total_gaussians) + ) + + for i, gaussian in enumerate(gaussian_list): + gaussian.centre.centre_0 = centre_0 + gaussian.centre.centre_1 = centre_1 + gaussian.ell_comps = gaussian_list[0].ell_comps + gaussian.sigma = 10 ** log10_sigma_list[i] + + bulge_gaussian_list += gaussian_list + + bulge = af.Model(ag.lp_basis.Basis, profile_list=bulge_gaussian_list) + + galaxy = af.Model(ag.Galaxy, redshift=0.5, bulge=bulge) + + return af.Collection(galaxies=af.Collection(galaxy=galaxy)) + + +def main(): + write(simple(), "simple") + write(complex_model(), "complex") + write(two_galaxies(), "two_galaxies") + write(concise(), "concise") + write(prior_custom(), "prior_custom") + write(model_custom(), "model_custom") + + write(mge_overview(), "mge_model", path=OVERVIEW_PATH) + + +if __name__ == "__main__": + main() diff --git a/docs/general/images/model_cookbook/model_custom.png b/docs/general/images/model_cookbook/model_custom.png new file mode 100644 index 000000000..4ad07cbfe Binary files /dev/null and b/docs/general/images/model_cookbook/model_custom.png differ diff --git a/docs/general/images/model_cookbook/prior_custom.png b/docs/general/images/model_cookbook/prior_custom.png new file mode 100644 index 000000000..d53caeed2 Binary files /dev/null and b/docs/general/images/model_cookbook/prior_custom.png differ diff --git a/docs/general/images/model_cookbook/simple.png b/docs/general/images/model_cookbook/simple.png new file mode 100644 index 000000000..d53caeed2 Binary files /dev/null and b/docs/general/images/model_cookbook/simple.png differ diff --git a/docs/general/images/model_cookbook/two_galaxies.png b/docs/general/images/model_cookbook/two_galaxies.png new file mode 100644 index 000000000..ea175db86 Binary files /dev/null and b/docs/general/images/model_cookbook/two_galaxies.png differ diff --git a/docs/general/model_cookbook.md b/docs/general/model_cookbook.md index fc9621506..8ccb1155a 100644 --- a/docs/general/model_cookbook.md +++ b/docs/general/model_cookbook.md @@ -10,10 +10,10 @@ readable code for different use-cases. ## Simple Model -A simple model we can compose has a galaxy with a Sersic light profile: +A simple model we can compose has a galaxy with a linear Sersic light profile: ```python -bulge = af.Model(ag.lp.Sersic) +bulge = af.Model(ag.lp_linear.Sersic) galaxy = af.Model(ag.Galaxy, redshift=0.5, bulge=bulge) @@ -21,7 +21,8 @@ model = af.Collection(galaxies=af.Collection(galaxy=galaxy)) ``` The model `total_free_parameters` tells us the total number of free parameters (which are fitted for via a -non-linear search), which in this case is 7. +non-linear search), which in this case is 6. The `intensity` of a linear light profile is not one of them: it is +solved for by a linear inversion during every likelihood evaluation. ```python print(f"Model Total Free Parameters = {model.total_free_parameters}") @@ -36,29 +37,49 @@ print(model.info) This gives the following output: ```bash +Total Free Parameters = 6 + +model Collection (N=6) + galaxies Collection (N=6) + galaxy Galaxy (N=6) + bulge Sersic (N=6) + galaxies galaxy - redshift 0.5 + redshift 0.5 bulge centre - centre_0 GaussianPrior, mean = 0.0, sigma = 0.3 - centre_1 GaussianPrior, mean = 0.0, sigma = 0.3 + centre_0 GaussianPrior [0], mean = 0.0, sigma = 0.3 + centre_1 GaussianPrior [1], mean = 0.0, sigma = 0.3 ell_comps - ell_comps_0 GaussianPrior, mean = 0.0, sigma = 0.5 - ell_comps_1 GaussianPrior, mean = 0.0, sigma = 0.5 - intensity LogUniformPrior, lower_limit = 1e-06, upper_limit = 1000000.0 - effective_radius UniformPrior, lower_limit = 0.0, upper_limit = 30.0 - sersic_index UniformPrior, lower_limit = 0.8, upper_limit = 5.0 + ell_comps_0 TruncatedGaussianPrior [2], mean = 0.0, sigma = 0.3, lower_limit = -1.0, upper_limit = 1.0 + ell_comps_1 TruncatedGaussianPrior [3], mean = 0.0, sigma = 0.3, lower_limit = -1.0, upper_limit = 1.0 + effective_radius UniformPrior [4], lower_limit = 0.0, upper_limit = 30.0 + sersic_index UniformPrior [5], lower_limit = 0.8, upper_limit = 5.0 ``` +The same model can be drawn as a figure, which shows its structure at a glance: + +```python +af.ModelPlotter(model).figure() +``` + +```{image} https://raw.githubusercontent.com/PyAutoLabs/PyAutoGalaxy/main/docs/general/images/model_cookbook/simple.png +:alt: A galaxy with a single linear Sersic bulge, drawn as nested component cards. +:width: 600 +``` + +The dashed `intensity · solved` pill is the parameter that `model.info` above does not print, because it is not part +of the model: it is solved for during the fit. See `Reading the figure` at the end of this cookbook. + ## More Complex Models The API above can be easily extended to compose models where each galaxy has multiple light or mass profiles: ```python -bulge = af.Model(ag.lp.Sersic) -disk = af.Model(ag.lp.Exponential) -bar = af.Model(ag.lp.Sersic) +bulge = af.Model(ag.lp_linear.Sersic) +disk = af.Model(ag.lp_linear.Exponential) +bar = af.Model(ag.lp_linear.Sersic) galaxy = af.Model( ag.Galaxy, @@ -69,6 +90,13 @@ galaxy = af.Model( ) model = af.Collection(galaxies=af.Collection(galaxy=galaxy)) + +af.ModelPlotter(model).figure() +``` + +```{image} https://raw.githubusercontent.com/PyAutoLabs/PyAutoGalaxy/main/docs/general/images/model_cookbook/complex.png +:alt: A galaxy with a bulge, a disk and a bar, each a linear light profile. +:width: 600 ``` The use of the words `bulge`, `disk` and `bar` above are arbitrary. They can be replaced with any name you @@ -77,7 +105,7 @@ like, e.g. `bulge_0`, `bulge_1`, `star_clump`, and the model will still behave i The API can also be extended to compose models where there are multiple galaxies: ```python -bulge = af.Model(ag.lp.Sersic) +bulge = af.Model(ag.lp_linear.Sersic) galaxy_0 = af.Model( ag.Galaxy, @@ -85,7 +113,7 @@ galaxy_0 = af.Model( bulge=bulge, ) -bulge = af.Model(ag.lp.Sersic) +bulge = af.Model(ag.lp_linear.Sersic) galaxy_1 = af.Model( ag.Galaxy, @@ -99,8 +127,19 @@ model = af.Collection( galaxy_1=galaxy_1, ) ) + +af.ModelPlotter(model).figure() ``` +```{image} https://raw.githubusercontent.com/PyAutoLabs/PyAutoGalaxy/main/docs/general/images/model_cookbook/two_galaxies.png +:alt: A model with two galaxies, each with its own linear Sersic bulge. +:width: 600 +``` + +The two galaxies are identical in structure, so the figure draws them once inside a dashed plate badged +`2 components` rather than twice. Their parameters are badged `independent`: two separate priors with the same +configuration, which is not the same thing as one shared prior. + ## Concise API If a light profile is passed directly to the `af.Model` of a galaxy, it is automatically assigned to be a `af.Model` @@ -112,24 +151,35 @@ This means we can write the model above comprising multiple light profiles more galaxy = af.Model( ag.Galaxy, redshift=0.5, - bulge=ag.lp.Sersic, - disk=ag.lp.Exponential, - bar=ag.lp.Sersic + bulge=ag.lp_linear.Sersic, + disk=ag.lp_linear.Exponential, + bar=ag.lp_linear.Sersic ) model = af.Collection(galaxies=af.Collection(galaxy=galaxy)) + +af.ModelPlotter(model).figure() ``` +```{image} https://raw.githubusercontent.com/PyAutoLabs/PyAutoGalaxy/main/docs/general/images/model_cookbook/concise.png +:alt: The bulge, disk and bar model composed with the concise API. +:width: 600 +``` + +The figure is identical to the one composed the long way round, which is the point: the concise API is a shorthand for +writing the model, not a different model. + ## Prior Customization We can customize the priors of the model component individual parameters as follows: ```python -bulge = af.Model(ag.lp.Sersic) +bulge = af.Model(ag.lp_linear.Sersic) bulge.centre.centre_0 = af.UniformPrior(lower_limit=-0.1, upper_limit=0.1) bulge.centre.centre_1 = af.UniformPrior(lower_limit=-0.1, upper_limit=0.1) -bulge.intensity = af.LogUniformPrior(lower_limit=1e-4, upper_limit=1e4) -bulge.sersic_index = af.GaussianPrior(mean=4.0, sigma=1.0, lower_limit=1.0, upper_limit=8.0) +bulge.sersic_index = af.TruncatedGaussianPrior( + mean=4.0, sigma=1.0, lower_limit=1.0, upper_limit=8.0 +) galaxy = af.Model( ag.Galaxy, @@ -138,15 +188,26 @@ galaxy = af.Model( ) model = af.Collection(galaxies=af.Collection(galaxy=galaxy)) + +af.ModelPlotter(model).figure() ``` +```{image} https://raw.githubusercontent.com/PyAutoLabs/PyAutoGalaxy/main/docs/general/images/model_cookbook/prior_custom.png +:alt: A galaxy model whose centre and sersic index priors have been customized. +:width: 600 +``` + +Customizing a prior does not change a parameter's state: every parameter above is still sampled, so the figure is +unchanged by the customization. Print `model.info`, or call `af.ModelPlotter(model).figure(detail="priors")`, to see +the prior on each parameter. + ## Model Customization We can customize the model parameters in a number of different ways, as shown below: ```python -bulge = af.Model(ag.lp.Sersic) -disk = af.Model(ag.lp.Exponential) +bulge = af.Model(ag.lp_linear.Sersic) +disk = af.Model(ag.lp_linear.Exponential) # Parameter Pairing: Pair the centre of the bulge and disk together, reducing # the complexity of non-linear parameter space by N = 2 @@ -158,10 +219,10 @@ bulge.centre = disk.centre bulge.sersic_index = 4.0 -# Parameter Offsets: Make the bulge intensity parameters the same value as +# Parameter Offsets: Make the bulge effective_radius the same value as # the disk but with an offset. -bulge.intensity = disk.intensity + 0.1 +bulge.effective_radius = disk.effective_radius + 0.1 galaxy = af.Model( ag.Galaxy, @@ -174,13 +235,45 @@ model = af.Collection(galaxies=af.Collection(galaxy=galaxy)) # Assert that the effective radius of the bulge is larger than that of the disk. # (Assertions can only be added at the end of model composition, after all components -# have been bright together in a `Collection`. +# have been brought together in a `Collection`. model.add_assertion(model.galaxies.galaxy.bulge.effective_radius > model.galaxies.galaxy.disk.effective_radius) -# Assert that the bulge effetive radius is below 3.0": +# Assert that the bulge effective radius is below 3.0": model.add_assertion(model.galaxies.galaxy.bulge.effective_radius < 3.0) + +af.ModelPlotter(model).figure() ``` +```{image} https://raw.githubusercontent.com/PyAutoLabs/PyAutoGalaxy/main/docs/general/images/model_cookbook/model_custom.png +:alt: A customized galaxy model showing a paired centre, a fixed sersic index, an offset relation and two assertions. +:width: 600 +``` + +This is the stage where the figure earns its keep: the paired `centre` is drawn once on its owner with a badge and a +link from the component that reuses it, the fixed `sersic_index` is a grey pill, the offset `effective_radius` carries +its defining expression, and each assertion is a compact label naming both of its operands. + +## Reading the figure + +The figure is the **map**; `model.info` is the **legend**. The map shows the shape of the model — which components +contain which, which parameters are shared, fixed, related or solved — and the legend gives the numbers: the prior on +every parameter, and the exact value of every fixed one. + +The contract between them is explicit: every displayed model element resolves to its corresponding path or grouped +paths in `model.info`, and every omission and every added annotation (`solved`, `missing`) is called out on the figure +itself. It is deliberately not a line-for-line correspondence — the figure partitions by *component*, while +`model.info` groups per *parameter*, so one plate of thirty components can correspond to a single `0 - 29` block in the +text. + +A `solved` pill has no counterpart in `model.info` at all: it is additional information. The `intensity` of a linear +light profile (`ag.lp_linear.*`, and every member of an `ag.lp_basis.Basis` built from them) is solved for by the +inversion at every likelihood evaluation, so it has no prior and is absent from the text. A `Basis` declares no solved +amplitude of its own — the solved intensities belong to its member profiles, one per member. + +The background tints follow **nesting depth only**. They are decorative: they help you see which card sits inside +which, and they carry no information about the class family of a component. A light profile and a mass profile at the +same depth are tinted identically. + ## Available Model Components The light profiles, mass profiles and other components that can be used for galaxy modeling are given at the following diff --git a/docs/overview/images/overview_3/mge_model.png b/docs/overview/images/overview_3/mge_model.png new file mode 100644 index 000000000..63adcd4da Binary files /dev/null and b/docs/overview/images/overview_3/mge_model.png differ diff --git a/docs/overview/overview_3_features.md b/docs/overview/overview_3_features.md index 5bc571985..4826e8f15 100644 --- a/docs/overview/overview_3_features.md +++ b/docs/overview/overview_3_features.md @@ -92,6 +92,14 @@ An MGE decomposes the light of a galaxy into tens or hundreds of two dimensional In the image above, 30 Gaussians are shown, where their sizes go from below the pixel scale (in order to resolve point emission) to beyond the size of the galaxy (to capture its extended emission). +The model this composes is drawn below: the 60 Gaussians of its two bases collapse into two plates badged +`30 components`, whose `intensity` parameters are solved for by the inversion rather than sampled. + +```{image} https://raw.githubusercontent.com/PyAutoLabs/PyAutoGalaxy/main/docs/overview/images/overview_3/mge_model.png +:alt: The MGE galaxy model, whose sixty Gaussians are drawn as two plates of thirty components. +:width: 600 +``` + Scientific Applications include capturing departures from elliptical symmetry in the light of galaxies, providing a flexible model to deblend the emission of point sources (e.g. quasars) from the emission of their host galaxy and deprojecting the light of a galaxy from 2D to 3D. diff --git a/test_autogalaxy/profiles/test_model_figure_semantics.py b/test_autogalaxy/profiles/test_model_figure_semantics.py new file mode 100644 index 000000000..895f60384 --- /dev/null +++ b/test_autogalaxy/profiles/test_model_figure_semantics.py @@ -0,0 +1,298 @@ +""" +The domain semantics a model figure draws, asserted over the **real** classes. + +PyAutoFit cannot know which parameters a fit solves rather than samples; phase 3 +of the ``model-figures`` epic supplies that from here, as a class attribute +(``__solved_parameters__``) that PyAutoFit's ``graph_spec`` reads. These tests +pin the semantics end to end: the class declares, ``GraphSpec`` extracts, and +``ModelPlotter(...).presentation()`` says the right thing on the pill. + +Every assertion is over a real PyAutoGalaxy class -- the structural doubles in +``test_autofit`` cannot catch a declaration that was never made. +""" + +import matplotlib + +matplotlib.use("Agg") + +import autofit as af +import autogalaxy as ag + + +# ---------------------------------------------------------------------------- +# helpers +# ---------------------------------------------------------------------------- + + +def rows_of(spec): + """Every ``ParamRow`` in a spec, keyed by its dotted path.""" + rows = {} + + def walk(node): + for row in node.rows: + rows[".".join(row.path)] = row + for child in node.children: + walk(child) + + walk(spec.root) + return rows + + +def named(spec, name): + """Every row with this exact attribute name, in walk order.""" + return [row for path, row in rows_of(spec).items() if row.name == name] + + +def pill_texts(presentation): + return [pill.text for pill in presentation.pills()] + + +def card_of(presentation, title_fragment): + for card in presentation.walk(): + if title_fragment in card.title: + return card + raise AssertionError( + f"no card whose title contains {title_fragment!r}: " + f"{[card.title for card in presentation.walk()]}" + ) + + +# ---------------------------------------------------------------------------- +# linear light profiles -- `intensity` is solved by the inversion +# ---------------------------------------------------------------------------- + + +def test__linear_light_profile_intensity_is_solved(): + spec = af.GraphSpec.from_model(af.Model(ag.lp_linear.Sersic)) + + row = rows_of(spec)["intensity"] + + assert row.sampling == "solved" + assert row.in_model_info is False + assert row.provenance.kind == "solved-by-fit" + + presentation = af.ModelPlotter(af.Model(ag.lp_linear.Sersic)).presentation() + + assert "intensity · solved" in pill_texts(presentation) + + +def test__standard_light_profile_intensity_is_free(): + spec = af.GraphSpec.from_model(af.Model(ag.lp.Sersic)) + + row = rows_of(spec)["intensity"] + + assert row.sampling == "free" + assert row.in_model_info is True + + assert "intensity" in pill_texts( + af.ModelPlotter(af.Model(ag.lp.Sersic)).presentation() + ) + + +def test__solved_declaration_is_inherited_by_every_linear_subclass(): + for cls in ( + ag.lp_linear.Gaussian, + ag.lp_linear.Exponential, + ag.lp_linear.SersicCore, + ): + spec = af.GraphSpec.from_model(af.Model(cls)) + + assert rows_of(spec)["intensity"].sampling == "solved", cls.__name__ + + +# ---------------------------------------------------------------------------- +# `Basis` -- the solved intensities belong to the members, not the basis +# ---------------------------------------------------------------------------- + + +def test__basis_has_no_solved_intensity_of_its_own__members_do(): + model = af.Model( + ag.lp_basis.Basis, + profile_list=[af.Model(ag.lp_linear.Gaussian) for _ in range(3)], + ) + + spec = af.GraphSpec.from_model(model, collapse=False) + + assert [row.name for row in spec.root.rows if row.name == "intensity"] == [] + + intensities = named(spec, "intensity") + + assert len(intensities) == 3 + assert {row.sampling for row in intensities} == {"solved"} + + +def test__basis_members_collapse_into_one_plate_whose_repeats_name_the_solved_intensity(): + model = af.Model( + ag.lp_basis.Basis, + profile_list=[af.Model(ag.lp_linear.Gaussian) for _ in range(3)], + ) + + spec = af.GraphSpec.from_model(model) + + plates = [ + node + for node in _walk_nodes(spec.root) + if node.plate is not None and node.plate.count == 3 + ] + + assert len(plates) == 1 + assert any("intensity solved" in line for line in plates[0].plate.repeats) + + presentation = af.ModelPlotter(model).presentation() + + assert "intensity · solved" in pill_texts(presentation) + + +def _walk_nodes(node): + yield node + for child in node.children: + yield from _walk_nodes(child) + + +# ---------------------------------------------------------------------------- +# point sources -- `PointSolved` solves its centre, `Point` samples it +# ---------------------------------------------------------------------------- + + +def test__point_solved_has_exactly_one_solved_centre_row(): + spec = af.GraphSpec.from_model(af.Model(ag.ps.PointSolved)) + + assert [row.name for row in spec.root.rows] == ["centre"] + + row = spec.root.rows[0] + + assert row.sampling == "solved" + assert row.in_model_info is False + + presentation = af.ModelPlotter(af.Model(ag.ps.PointSolved)).presentation() + + assert "centre · solved" in pill_texts(presentation) + + +def test__point_centre_is_a_free_tuple(): + spec = af.GraphSpec.from_model(af.Model(ag.ps.Point)) + + row = rows_of(spec)["centre"] + + assert row.sampling == "free" + assert row.dimensionality == "tuple" + assert row.in_model_info is True + + +# ---------------------------------------------------------------------------- +# the `missing` state -- unset configuration on a `Delaunay` mesh +# ---------------------------------------------------------------------------- + + +def test__delaunay_areas_factor_has_no_prior_configured_and_is_missing(): + spec = af.GraphSpec.from_model(af.Model(ag.mesh.Delaunay)) + + row = rows_of(spec)["areas_factor"] + + assert row.sampling == "missing" + assert row.prior_cls_name == "ConfigException" + assert spec.counts["missing"] == 1 + + presentation = af.ModelPlotter(af.Model(ag.mesh.Delaunay)).presentation() + + assert "areas_factor · missing" in pill_texts(presentation) + + +def test__delaunay_pixels_and_zeroed_pixels_are_fixed_not_missing(): + """ + Measured, not assumed. + + The epic's brief expected ``pixels``, ``zeroed_pixels`` and ``areas_factor`` + to be three ``missing`` rows. They are not: ``pixels: int`` has no default, + so ``af.Model`` fills the slot with ``af.Model(int)`` -- a *fixed* row by + rule R7, never a ``ConfigException`` -- and ``zeroed_pixels: Optional[int] = + 0`` simply takes its default. Only ``areas_factor`` is genuinely unset + configuration. This test pins that distinction so the figure cannot start + calling an unset int "missing" without someone noticing. + """ + spec = af.GraphSpec.from_model(af.Model(ag.mesh.Delaunay)) + + rows = rows_of(spec) + + assert rows["pixels"].sampling == "fixed" + assert [row.name for row in spec.root.rows] == ["pixels", "areas_factor"] + + set_spec = af.GraphSpec.from_model( + af.Model(ag.mesh.Delaunay, pixels=500, zeroed_pixels=0) + ) + set_rows = set_spec.root.rows + + assert [row.name for row in set_rows] == ["pixels", "zeroed_pixels", "areas_factor"] + assert [row.sampling for row in set_rows] == ["fixed", "fixed", "missing"] + assert set_spec.counts["missing"] == 1 + + +# ---------------------------------------------------------------------------- +# pixelization -- the source reconstruction is solved by the inversion +# ---------------------------------------------------------------------------- + + +def pixelization_model(): + return af.Model( + ag.Pixelization, + mesh=af.Model(ag.mesh.Delaunay, pixels=500, zeroed_pixels=0), + regularization=af.Model(ag.reg.ConstantSplit), + ) + + +def test__pixelization_reconstruction_is_solved_and_regularization_stays_free(): + model = pixelization_model() + + spec = af.GraphSpec.from_model(model) + rows = rows_of(spec) + + reconstruction = rows["reconstruction"] + + assert reconstruction.sampling == "solved" + assert reconstruction.in_model_info is False + assert reconstruction.provenance.kind == "solved-by-fit" + + assert rows["regularization.coefficient"].sampling == "free" + assert spec.counts["unique_sampled_scalars"] == model.prior_count + + presentation = af.ModelPlotter(model).presentation() + + assert "reconstruction · solved" in [ + pill.text for pill in card_of(presentation, "Pixelization").pills + ] + + +# ---------------------------------------------------------------------------- +# redshift -- free is an ordinary pill, fixed is a subtitle +# ---------------------------------------------------------------------------- + + +def free_redshift_galaxy(): + redshift = af.Model(ag.Redshift) + redshift.redshift = af.UniformPrior(lower_limit=0.0, upper_limit=2.0) + + return af.Model(ag.Galaxy, redshift=redshift, bulge=af.Model(ag.lp.Sersic)) + + +def test__free_redshift_is_a_pill_on_the_galaxy_card_not_a_child_card(): + model = free_redshift_galaxy() + + presentation = af.ModelPlotter(model).presentation() + galaxy = card_of(presentation, "Galaxy") + + assert "redshift" in [pill.text for pill in galaxy.pills] + assert "Redshift" not in [child.title for child in galaxy.children] + + spec = af.GraphSpec.from_model(model) + + assert rows_of(spec)["redshift"].sampling == "free" + + +def test__fixed_redshift_is_the_card_subtitle(): + model = af.Model(ag.Galaxy, redshift=0.5, bulge=af.Model(ag.lp.Sersic)) + + presentation = af.ModelPlotter(model).presentation() + galaxy = card_of(presentation, "Galaxy") + + assert galaxy.subtitle == "redshift = 0.5" + assert "redshift" not in [pill.text for pill in galaxy.pills]