From c8fe47f35e37f4ea8a51303ece0ab5f708def460 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 28 Aug 2026 21:27:29 -0400 Subject: [PATCH] feat: make the colormap config lever loud and documented (#509) The `visualize/general.yaml -> colormap` key already reached every 2D figure, but a typo'd or unknown value reverted silently to the bundled `autoarray` colormap and the user never learned their setting was ignored. - `_default_colormap()` now separates the two failure modes: an absent config (no autonerves, no `colormap` key) still falls back quietly to `autoarray`, while a value matplotlib cannot resolve raises a `ValueError` naming the config key, the offending value, and how to fix it. The bare `except Exception` is gone, replaced by `(KeyError, ConfigException)` / `ImportError`. - `_conf_imshow_origin()` and `_conf_output_format()` get the same narrow excepts; `imshow_origin` additionally rejects anything but `upper`/`lower`. - `config/visualize/README.md` gains a "Changing the colormap" section: the global config key, the per-figure `colormap=` argument, and the figures that deliberately fix their colormap (array_overlay `Greys`, the weak-lensing cyclic/diverging maps, the cluster and GUI maps). - 11 new unit tests in `test_autoarray/plot/test_utils.py` cover the config value, the quiet fallback, the loud failure, the per-figure override, and that one config edit moves both an imaging figure and an inversion reconstruction. No public API changes; the config key and every `colormap=` argument keep their existing names and defaults. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PXGHBFryzQZMFvAskHe9sQ --- autoarray/config/visualize/README.md | 49 ++++++ autoarray/plot/utils.py | 115 +++++++++++++- test_autoarray/plot/test_utils.py | 216 +++++++++++++++++++++++++++ 3 files changed, 372 insertions(+), 8 deletions(-) diff --git a/autoarray/config/visualize/README.md b/autoarray/config/visualize/README.md index c99ad0275..8eb15e605 100644 --- a/autoarray/config/visualize/README.md +++ b/autoarray/config/visualize/README.md @@ -8,3 +8,52 @@ The `config` folder contains configuration files which customize default **PyAut - `mat_wrap.yaml`: Specify the default matplotlib settings when figures and subplots are plotted. - `mat_wrap_1d.yaml`: Specify the default matplotlib settings when 1D figures and subplots are plotted. - `mat_wrap_2d.yaml`: Specify the default matplotlib settings when 2D figures and subplots are plotted. + +# Changing the colormap + +Every 2D figure — imaging data, fits, residual maps, inversion reconstructions — +draws with the colormap named by the `colormap` key of `general.yaml`: + +```yaml +colormap: autoarray # any matplotlib colormap name, e.g. magma, viridis, inferno +``` + +`autoarray` is the colormap bundled with **PyAutoArray**; any other value is +looked up in matplotlib, so `magma`, `viridis`, `inferno`, `plasma`, `jet` and +the rest of `list(matplotlib.colormaps)` all work. Editing this one key is +enough — no plotting code needs changing. + +A name matplotlib does not recognise (a typo, say) raises a `ValueError` naming +the key and the offending value. It is **not** silently swapped back for the +default, so a colormap setting never goes quietly ignored. + +## One figure at a time + +To override the colormap for a single figure without touching config, pass +`colormap=` to any plot function: + +```python +import autoarray.plot as aplt + +aplt.plot_array(array=image, colormap="magma") +aplt.plot_inversion_reconstruction(pixel_values=values, mapper=mapper, colormap="viridis") +``` + +The same argument exists on the **PyAutoGalaxy** and **PyAutoLens** plot +functions (`subplot_fit`, `plot_tracer`, `subplot_sensitivity`, …), which pass +it straight through to **PyAutoArray**. Its "use the config value" default is +spelled `None` in **PyAutoArray** and **PyAutoLens**, and `"default"` in +**PyAutoGalaxy**; both mean the same thing. + +## Figures that deliberately ignore the setting + +A few figures fix their colormap because the colormap carries meaning that a +user preference should not override: + +- The `array_overlay` of `plot_array` uses `Greys`, so the overlaid array stays + legible on top of whatever colormap the main array is drawn in. +- The weak-lensing figures in **PyAutoLens** use `twilight` for position angles + (cyclic data needs a cyclic colormap) and `RdBu_r` for residuals (diverging + data needs a diverging colormap centred on zero). +- The cluster figures in **PyAutoLens** use `gnuplot2`, and the interactive GUI + tools use `jet`, to keep faint features visible while masks are drawn by hand. diff --git a/autoarray/plot/utils.py b/autoarray/plot/utils.py index 2f725f21f..4e0793e81 100644 --- a/autoarray/plot/utils.py +++ b/autoarray/plot/utils.py @@ -901,34 +901,133 @@ def hide_unused_axes(axes) -> None: ax.axis("off") +#: Config key holding the default colormap, quoted in error messages. +_COLORMAP_CONF_KEY = "visualize/general.yaml -> colormap" + + def _default_colormap() -> str: - """Return the colormap name from config, registering the custom one if needed.""" + """Return the default colormap name for 2D figures. + + The name is read from the ``colormap`` key of ``visualize/general.yaml``. + The two failure modes are deliberately kept apart: + + - **No config at all** (``autonerves`` not installed, or no ``colormap`` + key on the config path — e.g. a bare install with no workspace) falls + back quietly to the bundled ``"autoarray"`` colormap. Nothing is + misconfigured, so nothing is said. + - **A config value that matplotlib does not recognise** raises + ``ValueError``. A typo'd colormap name used to revert silently to + ``"autoarray"``, so the user never learned their setting was ignored. + + Returns + ------- + str + A colormap name matplotlib can resolve — either ``"autoarray"`` (the + bundled colormap, registered here on first use) or a matplotlib name. + + Raises + ------ + ValueError + If the ``colormap`` config key is set to something that is not a + registered matplotlib colormap. + """ try: from autonerves import conf - name = conf.instance["visualize"]["general"]["colormap"] - except Exception: + from autonerves.exc import ConfigException + except ImportError: name = "autoarray" + else: + try: + name = conf.instance["visualize"]["general"]["colormap"] + except (KeyError, ConfigException): + name = "autoarray" + if name == "autoarray": from autoarray.plot.segmentdata import register + register() + return name + + _validate_colormap(name) + return name +def _validate_colormap(name) -> None: + """Raise ``ValueError`` unless *name* is a colormap matplotlib knows about. + + Parameters + ---------- + name + The colormap name read from config (or passed by the user). + + Raises + ------ + ValueError + If *name* is not a string, or is not a registered matplotlib colormap. + """ + import matplotlib + + if isinstance(name, str) and name in matplotlib.colormaps: + return + + raise ValueError( + f"Unknown colormap {name!r}.\n\n" + f"The config key `{_COLORMAP_CONF_KEY}` is set to {name!r}, which is " + f"not a colormap matplotlib recognises, so no figure can be drawn " + f"with it.\n\n" + f"Use either `autoarray` (the colormap bundled with PyAutoArray) or " + f"any matplotlib colormap name, for example `magma`, `viridis`, " + f"`inferno`, `plasma` or `jet`.\n" + f"The full list is `list(matplotlib.colormaps)`." + ) + + def _conf_imshow_origin() -> str: - """Return the imshow origin from config (``"upper"`` or ``"lower"``).""" + """Return the imshow origin from config (``"upper"`` or ``"lower"``). + + An absent config falls back quietly to ``"upper"``; a value matplotlib's + ``imshow`` would reject raises ``ValueError`` rather than being silently + swapped for the default (same contract as :func:`_default_colormap`). + """ try: from autonerves import conf - return conf.instance["visualize"]["general"]["general"]["imshow_origin"] - except Exception: + from autonerves.exc import ConfigException + except ImportError: + return "upper" + + try: + origin = conf.instance["visualize"]["general"]["general"]["imshow_origin"] + except (KeyError, ConfigException): return "upper" + if origin not in ("upper", "lower"): + raise ValueError( + f"Invalid imshow origin {origin!r}.\n\n" + f"The config key `visualize/general.yaml -> general -> " + f"imshow_origin` must be either `upper` or `lower`." + ) + + return origin + def _conf_output_format() -> str: - """Return the default output_format from config (``"show"``, ``"png"``, etc.).""" + """Return the default output_format from config (``"show"``, ``"png"``, etc.). + + An absent config falls back quietly to ``"show"``. The value itself is not + validated here — an unsupported format surfaces as matplotlib's own + ``savefig`` error, which already names the offending format and lists the + supported ones. + """ try: from autonerves import conf + from autonerves.exc import ConfigException + except ImportError: + return "show" + + try: return conf.instance["visualize"]["general"]["general"]["output_format"] - except Exception: + except (KeyError, ConfigException): return "show" diff --git a/test_autoarray/plot/test_utils.py b/test_autoarray/plot/test_utils.py index 1b36351b4..30399267a 100644 --- a/test_autoarray/plot/test_utils.py +++ b/test_autoarray/plot/test_utils.py @@ -255,3 +255,219 @@ def test__plot_inversion_reconstruction( assert len(recorded) == 1 assert recorded[0].vmin == pytest.approx(self.FLOOR) + + +class TestDefaultColormap: + """The `visualize/general.yaml -> colormap` lever and its failure modes.""" + + @staticmethod + def _general(): + return conf.instance["visualize"]["general"] + + def test__config_value_is_returned(self): + general = self._general() + original = general["colormap"] + try: + general["colormap"] = "magma" + + assert plot_utils._default_colormap() == "magma" + finally: + general["colormap"] = original + + def test__absent_key_falls_back_quietly_to_autoarray(self): + import matplotlib + + general = self._general() + original = general["colormap"] + try: + del general["colormap"] + + assert plot_utils._default_colormap() == "autoarray" + finally: + general["colormap"] = original + + # The fallback also registers the bundled colormap, so it is usable. + assert "autoarray" in matplotlib.colormaps + + def test__autoarray_value_registers_the_bundled_colormap(self): + import matplotlib + + general = self._general() + original = general["colormap"] + try: + general["colormap"] = "autoarray" + + assert plot_utils._default_colormap() == "autoarray" + finally: + general["colormap"] = original + + assert "autoarray" in matplotlib.colormaps + + def test__unknown_value_raises_instead_of_reverting_silently(self): + general = self._general() + original = general["colormap"] + try: + general["colormap"] = "magmaa" + + with pytest.raises(ValueError) as exc_info: + plot_utils._default_colormap() + finally: + general["colormap"] = original + + message = str(exc_info.value) + assert "magmaa" in message + assert "colormap" in message + + def test__non_string_value_raises(self): + general = self._general() + original = general["colormap"] + try: + general["colormap"] = 3 + + with pytest.raises(ValueError): + plot_utils._default_colormap() + finally: + general["colormap"] = original + + def test__plot_array_uses_the_config_colormap(self, plot_path, monkeypatch): + """One config edit moves the imaging figure's colormap.""" + from autoarray.structures.arrays.uniform_2d import Array2D + + recorded = {} + + import matplotlib.pyplot as plt + + original_imshow = plt.Axes.imshow + + def spy(self, *args, **kwargs): + recorded.setdefault("cmap", kwargs.get("cmap")) + return original_imshow(self, *args, **kwargs) + + monkeypatch.setattr(plt.Axes, "imshow", spy) + + general = self._general() + original = general["colormap"] + try: + general["colormap"] = "magma" + + aplt.plot_array( + array=Array2D.no_mask( + values=np.ones((7, 7)), + pixel_scales=1.0, + ), + output_path=plot_path, + output_filename="config_colormap", + output_format="png", + ) + finally: + general["colormap"] = original + + assert recorded["cmap"] == "magma" + + def test__per_figure_colormap_overrides_the_config(self, plot_path, monkeypatch): + """`colormap=` on a plot function beats the config for that figure only.""" + from autoarray.structures.arrays.uniform_2d import Array2D + + recorded = {} + + import matplotlib.pyplot as plt + + original_imshow = plt.Axes.imshow + + def spy(self, *args, **kwargs): + recorded.setdefault("cmap", kwargs.get("cmap")) + return original_imshow(self, *args, **kwargs) + + monkeypatch.setattr(plt.Axes, "imshow", spy) + + general = self._general() + original = general["colormap"] + try: + general["colormap"] = "magma" + + aplt.plot_array( + array=Array2D.no_mask( + values=np.ones((7, 7)), + pixel_scales=1.0, + ), + colormap="viridis", + output_path=plot_path, + output_filename="override_colormap", + output_format="png", + ) + finally: + general["colormap"] = original + + assert recorded["cmap"] == "viridis" + + def test__inversion_reconstruction_uses_the_config_colormap( + self, rectangular_mapper_7x7_3x3, plot_path, monkeypatch + ): + """The same config edit moves the inversion reconstruction figure.""" + recorded = {} + + import matplotlib.pyplot as plt + + original_imshow = plt.Axes.imshow + + def spy(self, *args, **kwargs): + recorded.setdefault("cmap", kwargs.get("cmap")) + return original_imshow(self, *args, **kwargs) + + monkeypatch.setattr(plt.Axes, "imshow", spy) + + general = self._general() + original = general["colormap"] + try: + general["colormap"] = "magma" + + aplt.plot_inversion_reconstruction( + pixel_values=np.ones(9), + mapper=rectangular_mapper_7x7_3x3, + output_path=plot_path, + output_filename="inversion_config_colormap", + output_format="png", + ) + finally: + general["colormap"] = original + + assert recorded["cmap"] == "magma" + + +class TestConfImshowOrigin: + @staticmethod + def _general(): + return conf.instance["visualize"]["general"]["general"] + + def test__config_value_is_returned(self): + general = self._general() + original = general["imshow_origin"] + try: + general["imshow_origin"] = "lower" + + assert plot_utils._conf_imshow_origin() == "lower" + finally: + general["imshow_origin"] = original + + def test__absent_key_falls_back_quietly(self): + general = self._general() + original = general["imshow_origin"] + try: + del general["imshow_origin"] + + assert plot_utils._conf_imshow_origin() == "upper" + finally: + general["imshow_origin"] = original + + def test__invalid_value_raises(self): + general = self._general() + original = general["imshow_origin"] + try: + general["imshow_origin"] = "sideways" + + with pytest.raises(ValueError) as exc_info: + plot_utils._conf_imshow_origin() + finally: + general["imshow_origin"] = original + + assert "sideways" in str(exc_info.value)