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.
- ClimateGraph/__init__.py +9 -0
- ClimateGraph/appkernel.py +148 -0
- ClimateGraph/cli.py +63 -0
- ClimateGraph/data/__init__.py +8 -0
- ClimateGraph/data/data.py +508 -0
- ClimateGraph/data/point_surface.py +91 -0
- ClimateGraph/data/regular_grid.py +25 -0
- ClimateGraph/data/satellite_swath.py +21 -0
- ClimateGraph/domain/__init__.py +11 -0
- ClimateGraph/domain/domain.py +181 -0
- ClimateGraph/domain/domains.py +279 -0
- ClimateGraph/plot/__init__.py +11 -0
- ClimateGraph/plot/plot.py +402 -0
- ClimateGraph/plot/plots.py +1054 -0
- ClimateGraph/plot/primitive.py +99 -0
- ClimateGraph/plot/primitives.py +160 -0
- ClimateGraph/reader/__init__.py +15 -0
- ClimateGraph/reader/reader/__init__.py +14 -0
- ClimateGraph/reader/reader/point_surface/__init__.py +7 -0
- ClimateGraph/reader/reader/point_surface/csv_base.py +135 -0
- ClimateGraph/reader/reader/point_surface/default.py +36 -0
- ClimateGraph/reader/reader/point_surface/dmc.py +10 -0
- ClimateGraph/reader/reader/point_surface/sinca.py +10 -0
- ClimateGraph/reader/reader/point_surface/single_file.py +60 -0
- ClimateGraph/reader/reader/point_surface/station_per_file.py +55 -0
- ClimateGraph/reader/reader/point_surface/variable_per_file.py +91 -0
- ClimateGraph/reader/reader/reader.py +353 -0
- ClimateGraph/reader/reader/regular_grid/__init__.py +7 -0
- ClimateGraph/reader/reader/regular_grid/chimere.py +24 -0
- ClimateGraph/reader/reader/regular_grid/default.py +51 -0
- ClimateGraph/reader/reader/regular_grid/wrf.py +53 -0
- ClimateGraph/utils/__init__.py +1 -0
- ClimateGraph/utils/control_model.py +292 -0
- ClimateGraph/utils/dataset_utils.py +287 -0
- ClimateGraph/utils/general_utils.py +259 -0
- ClimateGraph/utils/parser.py +269 -0
- ClimateGraph/utils/registry.py +81 -0
- ClimateGraph/utils/resample_engine.py +156 -0
- climategraph-0.4.0.dist-info/METADATA +177 -0
- climategraph-0.4.0.dist-info/RECORD +44 -0
- climategraph-0.4.0.dist-info/WHEEL +5 -0
- climategraph-0.4.0.dist-info/entry_points.txt +2 -0
- climategraph-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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
|