ClimateGraph 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. ClimateGraph/__init__.py +9 -0
  2. ClimateGraph/appkernel.py +148 -0
  3. ClimateGraph/cli.py +63 -0
  4. ClimateGraph/data/__init__.py +8 -0
  5. ClimateGraph/data/data.py +508 -0
  6. ClimateGraph/data/point_surface.py +91 -0
  7. ClimateGraph/data/regular_grid.py +25 -0
  8. ClimateGraph/data/satellite_swath.py +21 -0
  9. ClimateGraph/domain/__init__.py +11 -0
  10. ClimateGraph/domain/domain.py +181 -0
  11. ClimateGraph/domain/domains.py +279 -0
  12. ClimateGraph/plot/__init__.py +11 -0
  13. ClimateGraph/plot/plot.py +402 -0
  14. ClimateGraph/plot/plots.py +1054 -0
  15. ClimateGraph/plot/primitive.py +99 -0
  16. ClimateGraph/plot/primitives.py +160 -0
  17. ClimateGraph/reader/__init__.py +15 -0
  18. ClimateGraph/reader/reader/__init__.py +14 -0
  19. ClimateGraph/reader/reader/point_surface/__init__.py +7 -0
  20. ClimateGraph/reader/reader/point_surface/csv_base.py +135 -0
  21. ClimateGraph/reader/reader/point_surface/default.py +36 -0
  22. ClimateGraph/reader/reader/point_surface/dmc.py +10 -0
  23. ClimateGraph/reader/reader/point_surface/sinca.py +10 -0
  24. ClimateGraph/reader/reader/point_surface/single_file.py +60 -0
  25. ClimateGraph/reader/reader/point_surface/station_per_file.py +55 -0
  26. ClimateGraph/reader/reader/point_surface/variable_per_file.py +91 -0
  27. ClimateGraph/reader/reader/reader.py +353 -0
  28. ClimateGraph/reader/reader/regular_grid/__init__.py +7 -0
  29. ClimateGraph/reader/reader/regular_grid/chimere.py +24 -0
  30. ClimateGraph/reader/reader/regular_grid/default.py +51 -0
  31. ClimateGraph/reader/reader/regular_grid/wrf.py +53 -0
  32. ClimateGraph/utils/__init__.py +1 -0
  33. ClimateGraph/utils/control_model.py +292 -0
  34. ClimateGraph/utils/dataset_utils.py +287 -0
  35. ClimateGraph/utils/general_utils.py +259 -0
  36. ClimateGraph/utils/parser.py +269 -0
  37. ClimateGraph/utils/registry.py +81 -0
  38. ClimateGraph/utils/resample_engine.py +156 -0
  39. climategraph-0.4.0.dist-info/METADATA +177 -0
  40. climategraph-0.4.0.dist-info/RECORD +44 -0
  41. climategraph-0.4.0.dist-info/WHEEL +5 -0
  42. climategraph-0.4.0.dist-info/entry_points.txt +2 -0
  43. climategraph-0.4.0.dist-info/licenses/LICENSE +21 -0
  44. climategraph-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,259 @@
1
+ """Enums, path expansion and time parsing.
2
+
3
+ The leaf utilities: how the control file's string values become real objects,
4
+ and how paths and dates are interpreted.
5
+ """
6
+
7
+ import datetime
8
+ import glob
9
+ import logging
10
+ import re
11
+ from enum import Enum
12
+ from pathlib import Path
13
+
14
+ import cartopy.crs as ccrs
15
+ import numpy as np
16
+ import pandas as pd
17
+ from dateutil import parser
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ CRS_TYPES = {"platecarree": ccrs.PlateCarree}
22
+ TIME_INTERVAL_FORMAT = r"^(.+?)\s*(?:-|to)\s*(.+)$" # Accepts "date - date" or "date to date" and a date should be in dayfirst format.
23
+
24
+
25
+ class TimestepEnum(str, Enum):
26
+ """Enum used for timestep handling. Keeps consistent timestep values."""
27
+
28
+ business_day = "B"
29
+ daily = "D"
30
+ weekly = "W"
31
+ monthly = "ME"
32
+ quarterly = "Q"
33
+ yearly = "Y"
34
+ hourly = "h"
35
+ minutely = "min"
36
+ secondly = "s"
37
+ milliseconds = "ms"
38
+ microseconds = "us"
39
+ nanoseconds = "ns"
40
+
41
+
42
+ class TimeBucketEnum(str, Enum):
43
+ """Enum used for timestep handling. Keeps consistent timestep values."""
44
+
45
+ minute = "minute"
46
+ hour = "hour"
47
+ day = "day"
48
+ season = "season"
49
+ weekly = "week"
50
+ monthly = "quarter"
51
+
52
+
53
+ class ReductionMethodEnum(str, Enum):
54
+ """Enum used for Reduction Method handling. Keeps consistent Reduction methods values."""
55
+
56
+ def __new__(cls, value, func):
57
+ obj = str.__new__(cls, value)
58
+ obj._value_ = value
59
+ obj.func = func
60
+ return obj
61
+
62
+ mean = ("mean", np.nanmean)
63
+ min = ("min", np.nanmin)
64
+ max = ("max", np.nanmax)
65
+
66
+
67
+ class CRSEnum(str, Enum):
68
+ """Enum used for Coordinate Reference System handling. Keeps consistent CRS values."""
69
+
70
+ def __new__(cls, value, crs):
71
+ obj = str.__new__(cls, value)
72
+ obj._value_ = value
73
+ obj.crs = crs
74
+ return obj
75
+
76
+ platecarree = ("platecarree", ccrs.PlateCarree)
77
+
78
+
79
+ def manage_path(
80
+ paths: str | Path | list[str] | list[Path],
81
+ sort: bool = False,
82
+ ) -> list[Path]:
83
+ """Handles paths, including lists of paths and glob patterns (``*``, ``?``).
84
+
85
+ Parameters
86
+ ----------
87
+ paths : str | Path | List[str] | List[Path]
88
+ Path or list of paths that compose the data object.
89
+ sort : bool, optional
90
+ When True, return paths in lexicographic order. If the post-sort
91
+ order differs from the input order, log a warning — callers that
92
+ concat in input order (e.g. the Reader unsafe path) will get a
93
+ silently non-monotonic time axis if filenames don't embed a
94
+ sortable timestamp. Default False keeps the historical
95
+ glob/input order.
96
+
97
+ Returns
98
+ -------
99
+ List[Path]
100
+ List of existing pathlib.Path's created from the input paths.
101
+ """
102
+ if isinstance(paths, (str, Path)):
103
+ paths = [paths]
104
+
105
+ result: list[Path] = []
106
+
107
+ for raw in paths:
108
+ p = Path(raw) if isinstance(raw, str) else raw
109
+ p = p.resolve() if not p.is_absolute() else p
110
+
111
+ matches = glob.glob(str(p))
112
+ if not matches:
113
+ log.debug(f"No files match pattern: {raw}")
114
+ result.extend(Path(m).resolve() for m in matches if Path(m).exists())
115
+
116
+ if not result:
117
+ log.debug(f"No files exist for paths: {paths}")
118
+
119
+ if sort:
120
+ ordered = sorted(result)
121
+ if ordered != result:
122
+ log.warning(
123
+ "manage_path: input paths were not in lexicographic order; "
124
+ "sorted automatically. Confirm filenames embed a sortable "
125
+ "timestamp or callers that concat in input order will produce "
126
+ "a non-monotonic time axis. First few: %s",
127
+ [p.name for p in ordered[:3]],
128
+ )
129
+ result = ordered
130
+
131
+ return result
132
+
133
+
134
+ # Coarsest-to-finest. Only resolutions coarser than "hour" expand to a full bucket
135
+ _RESOLUTION_ORDER = ("year", "month", "day", "hour", "minute", "second")
136
+ COARSE_OFFSETS = {
137
+ "day": pd.Timedelta(days=1),
138
+ "month": pd.DateOffset(months=1),
139
+ "year": pd.DateOffset(years=1),
140
+ }
141
+
142
+
143
+ def _parse_with_resolution(token: str) -> tuple[pd.Timestamp, str]:
144
+ """Parse a date token and detect its resolution.
145
+
146
+ Parameters
147
+ ----------
148
+ token : str
149
+ A single date/datetime in dayfirst format. With hours or without
150
+
151
+ Returns
152
+ -------
153
+ tuple[pd.Timestamp, str]
154
+ The floored timestamp and its resolution (one of ``_RESOLUTION_ORDER``).
155
+
156
+ Raises
157
+ ------
158
+ ValueError
159
+ If the token isn't a parseable date.
160
+ """
161
+ floored = parser.parse(token, dayfirst=True, default=datetime.datetime(1999, 1, 1))
162
+ probe = parser.parse(
163
+ token, dayfirst=True, default=datetime.datetime(2002, 7, 8, 9, 10, 11)
164
+ )
165
+
166
+ resolution = "year"
167
+ for field in _RESOLUTION_ORDER:
168
+ if getattr(floored, field) == getattr(probe, field):
169
+ resolution = field
170
+ return pd.Timestamp(floored), resolution
171
+
172
+
173
+ def _bucket_end(value: pd.Timestamp, resolution: str) -> pd.Timestamp:
174
+ """End of the bucket ``value`` falls in, for its resolution.
175
+
176
+ For coarse resolutions (day/month/year) returns the last representable
177
+ instant of the bucket (start of the next bucket minus 1 ns), so an inclusive
178
+ ``slice`` covers the whole day/month/year without spilling into the next.
179
+ For hour-or-finer resolutions the value is an exact point and is returned
180
+ unchanged.
181
+ """
182
+ offset = COARSE_OFFSETS.get(resolution)
183
+ if offset is None:
184
+ return value
185
+ return value + offset - pd.Timedelta(1, "ns")
186
+
187
+
188
+ def manage_time_interval(
189
+ time_interval: str,
190
+ ) -> tuple[pd.Timestamp, pd.Timestamp]:
191
+ """Turn a time-interval string into (start, end).
192
+
193
+ Accepts either a single date or a "start - (or to) end" range. Each endpoint is treated
194
+ as an *interval covering its own resolution* when that resolution is coarser
195
+ than hourly
196
+
197
+ If the two endpoints have different resolutions, a warning is logged and each
198
+ is expanded on its own bucket (best-effort).
199
+
200
+ Parameters
201
+ ----------
202
+ time_interval : str
203
+ A single date or a ``"start [-|to] end"`` range in dayfirst format.
204
+
205
+ Returns
206
+ -------
207
+ tuple[pd.Timestamp, pd.Timestamp]
208
+ Start (bucket start of the first endpoint) and end (bucket end of the
209
+ last endpoint).
210
+
211
+ Raises
212
+ ------
213
+ ValueError
214
+ If an endpoint isn't a parseable date.
215
+ """
216
+ if time_interval is None:
217
+ return None, None
218
+ time_interval = time_interval.strip()
219
+
220
+ # A separator splits a range; otherwise the whole string is a single date
221
+ # that spans its own bucket (start_str == end_str).
222
+ if (match := re.match(TIME_INTERVAL_FORMAT, time_interval)) is not None:
223
+ start_str, end_str = match.group(1), match.group(2)
224
+ else:
225
+ start_str = end_str = time_interval
226
+
227
+ start_val, start_res = _parse_with_resolution(start_str)
228
+ end_val, end_res = _parse_with_resolution(end_str)
229
+
230
+ if start_res != end_res:
231
+ log.warning(
232
+ "time_interval %r endpoints have different temporal resolutions "
233
+ "(%s vs %s); expanding each on its own bucket.",
234
+ time_interval,
235
+ start_res,
236
+ end_res,
237
+ )
238
+
239
+ return start_val, _bucket_end(end_val, end_res)
240
+
241
+
242
+ def normalize_time(time: str | list[str] | None) -> list[str | None]:
243
+ """Coerce a plot's ``time`` field into a list of entries to
244
+ iterate over. A single date/interval/None becomes a one-element list; a
245
+ list passes through unchanged, fanning the plot out into one output per entry.
246
+
247
+ Parameters
248
+ ----------
249
+ time : str | list[str] | None
250
+ A single date/interval string, a list of them, or None.
251
+
252
+ Returns
253
+ -------
254
+ list[str | None]
255
+ Entries to iterate over, each independently valid for manage_time_interval.
256
+ """
257
+ if isinstance(time, list):
258
+ return time
259
+ return [time]
@@ -0,0 +1,269 @@
1
+ """Control file reading and object construction.
2
+
3
+ Reads a control file in any supported format, validates it against the Pydantic
4
+ schema in :mod:`ClimateGraph.utils.control_model`, and builds the three
5
+ registries the run works from: the datasets, the domains and the plots.
6
+
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+ from pydantic import ValidationError
15
+
16
+ from ClimateGraph.data import Data
17
+ from ClimateGraph.data.point_surface import PointSurface
18
+ from ClimateGraph.domain import Domain
19
+ from ClimateGraph.domain.domains import AttributeConfig, PointsConfig
20
+ from ClimateGraph.plot import Plot
21
+ from ClimateGraph.utils.control_model import ControlFile
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ FILE_READERS = {".json": json.load, ".yaml": yaml.safe_load, ".yml": yaml.safe_load}
26
+
27
+
28
+ def _expansion_entries(name: str, model) -> list[tuple[str, object]] | None:
29
+ """Compute the per-value expansions for a ``one_for_each`` domain.
30
+
31
+ Returns ``[(expanded_name, expanded_model), ...]``, or ``None`` when the model
32
+ can't be expanded (so the caller leaves it as-is). Handles the two domain types
33
+ that carry ``one_for_each``:
34
+
35
+ - ``AttributeConfig``: one domain per ``field_value`` (needs a list), named
36
+ ``{name}__{value}``.
37
+ - ``PointsConfig``: one domain per named point, named by the point; each keeps
38
+ the full point set and gets a ``select_name`` so they share one resample
39
+ target and just ``.sel`` their site.
40
+ """
41
+ if isinstance(model, PointsConfig):
42
+ return [
43
+ (
44
+ pname,
45
+ model.model_copy(update={"select_name": pname, "one_for_each": False}),
46
+ )
47
+ for pname, _lat, _lon in model.resolve_points()
48
+ ]
49
+ if isinstance(model, AttributeConfig):
50
+ if not isinstance(model.field_value, list):
51
+ return None
52
+ return [
53
+ (
54
+ f"{name}__{value}",
55
+ model.model_copy(update={"field_value": value, "one_for_each": False}),
56
+ )
57
+ for value in model.field_value
58
+ ]
59
+ return None
60
+
61
+
62
+ def _points_target(model: PointsConfig, cache: dict) -> PointSurface:
63
+ """Build (or reuse) the in-memory PointSurface target for a Points domain.
64
+
65
+ Memoized on the resolved points so all fan-out expansions of one block share a
66
+ single target object — giving them one resample-cache entry (keyed by the
67
+ target's unique name) instead of recomputing the projection per point. A new
68
+ distinct point set gets a fresh unique name so different geometries never
69
+ collide in the resample cache.
70
+ """
71
+ pts = tuple(model.resolve_points())
72
+ target = cache.get(pts)
73
+ if target is None:
74
+ names = [p[0] for p in pts]
75
+ lats = [p[1] for p in pts]
76
+ lons = [p[2] for p in pts]
77
+ target = PointSurface.from_points(
78
+ f"__points_target_{len(cache)}", names, lats, lons
79
+ )
80
+ cache[pts] = target
81
+ return target
82
+
83
+
84
+ def _expand_domains(domain_models: dict) -> tuple[dict, dict[str, list[str]]]:
85
+ """Expand domain configs flagged ``one_for_each`` into one config per value.
86
+
87
+ Returns
88
+ -------
89
+ tuple[dict, dict[str, list[str]]]
90
+ The expanded {name: domain_model} dict (un-flagged domains pass through
91
+ unchanged), and a {original_name: [expanded_names]} rewrite map used to
92
+ resolve plot ``domains:`` references that still point at the original name.
93
+ """
94
+ expanded = {}
95
+ rewrite_map = {}
96
+ for name, model in domain_models.items():
97
+ if not getattr(model, "one_for_each", False):
98
+ expanded[name] = model
99
+ continue
100
+
101
+ entries = _expansion_entries(name, model)
102
+ if not entries:
103
+ expanded[name] = model
104
+ log.debug(
105
+ f"Attempted expansion of domain {name} but there was no expansible "
106
+ "value. Left as is."
107
+ )
108
+ continue
109
+
110
+ generated_names = []
111
+ for new_name, new_model in entries:
112
+ expanded[new_name] = new_model
113
+ generated_names.append(new_name)
114
+ rewrite_map[name] = generated_names
115
+
116
+ return expanded, rewrite_map
117
+
118
+
119
+ class Parser:
120
+ """Parser class, used for managing input configuration files for ClimateGraph"""
121
+
122
+ @staticmethod
123
+ def parse_control(control_path: Path):
124
+ """Validate a control file and build the objects it describes.
125
+
126
+ Parameters
127
+ ----------
128
+ control_path : Path
129
+ Path to configuration file.
130
+
131
+ Returns
132
+ -------
133
+ AnalysisModel, Dict[str, Data], Dict[str, Plot], Dict[str,Domain]
134
+ analysis, data, plots and domains specified for ClimateGraph execution.
135
+
136
+ Raises
137
+ ------
138
+ ValueError
139
+ Configuration file doesn't meet the ControlFile pydantic model.
140
+ """
141
+ control_dict = Parser.read_control(control_path)
142
+
143
+ try:
144
+ valid = ControlFile.model_validate(control_dict)
145
+ except ValidationError as err:
146
+ error_str = ""
147
+ for e in err.errors():
148
+ error_str += f"Error {e['msg']}. Input given:\n{e['input']}\n See more info at: {e['url']}\n"
149
+ raise ValueError(
150
+ f"Configuration file {control_path} doesn't meet the input structure.\n\n{error_str}\n\nFix this errors before retrying..."
151
+ ) from err
152
+
153
+ analysis = valid.analysis.model_dump()
154
+ data = dict()
155
+ plots = dict()
156
+ domains = dict()
157
+
158
+ # Default download cache for readers that fetch remote paths.
159
+ # Per-data `cache_dir` in YAML overrides this; local-only readers
160
+ # never look at it.
161
+ default_cache_dir = analysis["output_path"] / ".cache"
162
+
163
+ for data_name, data_model in valid.data.items():
164
+ _name = data_name
165
+ _topology, _reader, _path, _vars, _crs = (
166
+ data_model.topology,
167
+ data_model.reader,
168
+ data_model.path,
169
+ data_model.vars,
170
+ data_model.crs,
171
+ )
172
+ # Only necessary for dict, but not REALLY sure how necessary it is
173
+ if isinstance(_vars, dict):
174
+ _vars = {
175
+ var: var_model.model_dump() for var, var_model in _vars.items()
176
+ }
177
+
178
+ # model_extra is reader-specific kwargs (rename overrides,
179
+ # vertical_level for Chimere, etc.). Lifecycle settings
180
+ # (load_mode, cache_dir) are declared fields — we add them
181
+ # in explicitly so Data.load_obj has the full picture
182
+ # without Data growing new parameters.
183
+ reader_kwargs = dict(data_model.model_extra or {})
184
+ reader_kwargs["load_mode"] = data_model.load_mode
185
+ reader_kwargs["cache_dir"] = data_model.cache_dir or default_cache_dir
186
+ reader_kwargs["save_to"] = data_model.save_to
187
+
188
+ data_instance = Data.create(
189
+ _name, _topology, _reader, _path, _vars, _crs, reader_kwargs
190
+ )
191
+
192
+ data[_name] = data_instance
193
+
194
+ rewrite_map = {}
195
+ if valid.domains:
196
+ expanded_domains, rewrite_map = _expand_domains(valid.domains)
197
+ # Points domains build their own in-memory target from the point set;
198
+ # memoize by the resolved points so every fan-out expansion (and the
199
+ # grouped case) shares one target -> one resample cache entry.
200
+ points_targets: dict[tuple, PointSurface] = {}
201
+ for domain_name, domain_model in expanded_domains.items():
202
+ _type = domain_model.type
203
+ # Optional resample pre-step: resolve the target geometry so the
204
+ # domain can reproject onto it.
205
+ target_data = None
206
+ if isinstance(domain_model, PointsConfig):
207
+ target_data = _points_target(domain_model, points_targets)
208
+ elif getattr(domain_model, "resample_to", None):
209
+ target_data = data.get(domain_model.resample_to)
210
+ if target_data is None:
211
+ raise ValueError(
212
+ f"Domain '{domain_name}' resample_to references unknown "
213
+ f"dataset '{domain_model.resample_to}'."
214
+ )
215
+ domain_instance = Domain.create(
216
+ domain_name, _type, domain_model, target_data=target_data
217
+ )
218
+ domains[domain_name] = domain_instance
219
+ if valid.plots:
220
+ for plot_name, plot_model in valid.plots.items():
221
+ if rewrite_map and getattr(plot_model, "domains", None):
222
+ resolved = []
223
+ for ref in plot_model.domains:
224
+ resolved.extend(rewrite_map.get(ref, [ref]))
225
+ plot_model.domains = resolved
226
+
227
+ _type = plot_model.type
228
+ plot_instance = Plot.create(
229
+ plot_name,
230
+ _type,
231
+ plot_model,
232
+ data,
233
+ domains,
234
+ output_path=analysis["output_path"],
235
+ )
236
+ plots[plot_name] = plot_instance
237
+
238
+ return analysis, data, plots, domains
239
+
240
+ @staticmethod
241
+ def read_control(control_path: Path):
242
+ """Read a control file into a python dictionary. Supports ``.json``, ``.yml`` and ``.yaml``.
243
+
244
+ Parameters
245
+ ----------
246
+ control_path : Path
247
+ Path to configuration file.
248
+
249
+ Returns
250
+ -------
251
+ Dict
252
+ Mapping with configuration arguments.
253
+
254
+ Raises
255
+ ------
256
+ FileNotFoundError
257
+ Path doesn't point to any file.
258
+ ValueError
259
+ File type not supported.
260
+ """
261
+ control_path = control_path.resolve()
262
+ if not (control_path.exists() and control_path.is_file()):
263
+ raise FileNotFoundError(f"File {control_path} not found.")
264
+ if (reader := FILE_READERS.get(control_path.suffix)) is None:
265
+ raise ValueError(f"File type {control_path.suffix} not supported.")
266
+
267
+ with open(control_path) as fp:
268
+ control_dict = reader(fp)
269
+ return control_dict
@@ -0,0 +1,81 @@
1
+ """Self-registering class registries.
2
+
3
+ Lets an abstract base class keep a registry of its subclasses, so defining a
4
+ subclass is enough to make it usable from a control file.
5
+ """
6
+
7
+ from typing import Annotated, ClassVar, Union
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class RegistryMixin:
13
+ """Mixin that provides a self-registering class registry.
14
+
15
+ Each root ABC that inherits this mixin must declare its own:
16
+ registry: ClassVar[dict[str, type]] = {}
17
+
18
+ Concrete subclasses are registered automatically via __init_subclass__
19
+ under their lowercased class name and any names listed in their ``aliases``
20
+ class variable. Only aliases declared directly on the subclass are
21
+ registered (not inherited ones), matching the Reader convention.
22
+
23
+ The mixin intentionally does NOT define ``registry`` itself — each root ABC
24
+ owns a separate dict so their namespaces never collide.
25
+ """
26
+
27
+ aliases: ClassVar[list[str]] = []
28
+ config: ClassVar[type[BaseModel] | None] = None
29
+
30
+ def __init_subclass__(cls, **kwargs: object) -> None:
31
+ super().__init_subclass__(**kwargs)
32
+
33
+ # Walk the MRO (skipping cls itself and RegistryMixin) to find the first
34
+ # ancestor that declares its own ``registry`` dict. That ancestor is the
35
+ # root ABC for this family and owns the shared registry.
36
+ owner = None
37
+ for ancestor in cls.__mro__[1:]:
38
+ if ancestor is RegistryMixin:
39
+ continue
40
+ if "registry" in ancestor.__dict__:
41
+ owner = ancestor
42
+ break
43
+
44
+ if owner is None:
45
+ return
46
+
47
+ owner.registry[cls.__name__.lower()] = cls
48
+ for alias in cls.__dict__.get("aliases", []):
49
+ owner.registry[alias.lower()] = cls
50
+
51
+ @classmethod
52
+ def get_class(cls, name: str) -> type:
53
+ """Look up a registered subclass by name (case-insensitive)."""
54
+ try:
55
+ return cls.registry[name.lower()]
56
+ except KeyError as err:
57
+ raise ValueError(
58
+ f"No {cls.__name__} named '{name}'. "
59
+ f"Options: {list(cls.registry)} (case-insensitive)."
60
+ ) from err
61
+
62
+ @classmethod
63
+ def check_class(cls, name: str) -> bool:
64
+ """Return True if *name* maps to a registered subclass."""
65
+ return name.lower() in cls.registry
66
+
67
+ @classmethod
68
+ def build_config_union(cls) -> Annotated:
69
+ """Build a Pydantic discriminated-union over all registered configs.
70
+
71
+ Only meaningful for ABCs whose subclasses carry a ``config`` class
72
+ attribute (Plot, Domain). ``Data`` never calls this.
73
+ """
74
+ configs = [
75
+ c.config
76
+ for c in cls.registry.values()
77
+ if getattr(c, "config", None) is not None
78
+ ]
79
+ # Union[tuple(configs)] unpacks at runtime — do not let Ruff rewrite this;
80
+ # the UP007 rewrite strips Union and breaks Pydantic's discriminator.
81
+ return Annotated[Union[tuple(configs)], Field(discriminator="type")] # noqa: UP007