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,99 @@
|
|
|
1
|
+
"""Drawable units for composed figures.
|
|
2
|
+
|
|
3
|
+
A primitive draws one layer onto shared axes, which is what a ``custom`` plot
|
|
4
|
+
composes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from ClimateGraph.utils.registry import RegistryMixin
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import matplotlib as mpl
|
|
16
|
+
import xarray as xr
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Primitive(RegistryMixin, ABC):
|
|
20
|
+
"""The Primitive abstract class.
|
|
21
|
+
|
|
22
|
+
A minimal rendering unit — the atomic building block of a ``type: custom``
|
|
23
|
+
plot. A primitive does no data processing of its own: the plot orchestrator
|
|
24
|
+
resolves the dataset/var, applies the domain, filters time, reduces dims and
|
|
25
|
+
converts units, then hands the prepared ``xr.DataArray`` to ``render`` along
|
|
26
|
+
with the coordinate names bound to each axis. The primitive only draws and
|
|
27
|
+
returns the matplotlib artist, so legends and colorbars stay a figure-level
|
|
28
|
+
concern of the orchestrator.
|
|
29
|
+
|
|
30
|
+
Mirrors the ``Plot`` / ``Domain`` ABC + registry shape via ``RegistryMixin``:
|
|
31
|
+
concrete primitives self-register by class name and ``aliases``, and their
|
|
32
|
+
``config`` classes are gathered into a discriminated union by
|
|
33
|
+
``build_config_union``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
registry: ClassVar[dict[str, type["Primitive"]]] = {}
|
|
37
|
+
aliases: ClassVar[list[str]] = []
|
|
38
|
+
config: ClassVar[type[BaseModel] | None] = None
|
|
39
|
+
|
|
40
|
+
required_coords: ClassVar[frozenset[str]] = frozenset()
|
|
41
|
+
wants_colorbar: ClassVar[bool] = False
|
|
42
|
+
wants_legend: ClassVar[bool] = False
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: str, subplot_config: BaseModel, **kwargs):
|
|
45
|
+
"""Primitive initialization.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
name : str
|
|
50
|
+
Name of the owning plot, used for reference / error messages.
|
|
51
|
+
subplot_config : BaseModel
|
|
52
|
+
The concrete subplot config (a ``SubplotConfig`` subclass) driving
|
|
53
|
+
this primitive.
|
|
54
|
+
"""
|
|
55
|
+
self.name = name
|
|
56
|
+
self.subplot_config = subplot_config
|
|
57
|
+
self.style = dict(getattr(subplot_config, "model_extra", None) or {})
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def get_primitive_class(cls, name: str):
|
|
61
|
+
"""Look up a registered primitive subclass by name."""
|
|
62
|
+
return cls.get_class(name)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def check_primitive_class(cls, name: str) -> bool:
|
|
66
|
+
"""Return True if ``type`` names a registered primitive."""
|
|
67
|
+
return cls.check_class(name)
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def render(
|
|
71
|
+
self,
|
|
72
|
+
ax: "mpl.axes.Axes",
|
|
73
|
+
data: "xr.DataArray",
|
|
74
|
+
*,
|
|
75
|
+
x: str | None,
|
|
76
|
+
y: str | None,
|
|
77
|
+
label: str | None,
|
|
78
|
+
) -> "mpl.artist.Artist":
|
|
79
|
+
"""Draw the prepared data onto ``ax`` and return the artist.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
ax : matplotlib Axes
|
|
84
|
+
The shared axes to draw on.
|
|
85
|
+
data : xr.DataArray
|
|
86
|
+
Fully prepared data (domain/time/dim-reduce/unit already applied).
|
|
87
|
+
x : str | None
|
|
88
|
+
Coordinate name bound to the x axis.
|
|
89
|
+
y : str | None
|
|
90
|
+
Coordinate name bound to the y axis (None for 1-D primitives).
|
|
91
|
+
label : str | None
|
|
92
|
+
Legend / colorbar label for this artist.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
matplotlib Artist
|
|
97
|
+
The artist created, so the orchestrator can build legends/colorbars.
|
|
98
|
+
"""
|
|
99
|
+
pass
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The concrete primitives and their subplot config models."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar, Literal
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
7
|
+
|
|
8
|
+
from ClimateGraph.utils.general_utils import ReductionMethodEnum
|
|
9
|
+
|
|
10
|
+
from .primitive import Primitive
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _drop_nan_points(
|
|
14
|
+
x: np.ndarray, y: np.ndarray, vals: np.ndarray
|
|
15
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
16
|
+
"""Drop entries whose value is NaN, keeping x/y/value aligned."""
|
|
17
|
+
keep = ~np.isnan(vals)
|
|
18
|
+
return x[keep], y[keep], vals[keep]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SubplotConfig(BaseModel):
|
|
22
|
+
"""Shared config for every primitive subplot.
|
|
23
|
+
|
|
24
|
+
Flattened (not nested) so a ``subplots:`` entry reads naturally in YAML —
|
|
25
|
+
``type: contourf`` sits next to ``dataset:``/``x:``/``y:`` directly. Extra
|
|
26
|
+
keys are allowed so matplotlib styling (color, linewidth, alpha, ...) flows
|
|
27
|
+
straight through to the primitive's draw call via ``model_extra``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="allow")
|
|
31
|
+
|
|
32
|
+
# Coordinate roles the concrete primitive requires (overridden per subclass).
|
|
33
|
+
required_coords: ClassVar[frozenset[str]] = frozenset()
|
|
34
|
+
|
|
35
|
+
dataset: str
|
|
36
|
+
var: str | None = None
|
|
37
|
+
x: str | None = None
|
|
38
|
+
y: str | None = None
|
|
39
|
+
# Optional per-subplot time override. When set it wins over the plot-level
|
|
40
|
+
# `time` for this subplot only, so heterogeneous times can overlay on one
|
|
41
|
+
# figure (e.g. the same date across two years). When None the subplot follows
|
|
42
|
+
# the plot's time iteration.
|
|
43
|
+
time: str | None = None
|
|
44
|
+
dim_reduce: dict[str, str | dict] | None = None
|
|
45
|
+
reduction_method: ReductionMethodEnum = ReductionMethodEnum.mean
|
|
46
|
+
unit: str | None = None
|
|
47
|
+
label: str | None = None
|
|
48
|
+
|
|
49
|
+
@model_validator(mode="after")
|
|
50
|
+
def _check_required_coords(self):
|
|
51
|
+
"""Check the subplot declares the coordinates this primitive needs."""
|
|
52
|
+
missing = sorted(c for c in self.required_coords if getattr(self, c) is None)
|
|
53
|
+
if missing:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"{self.type!r} subplot requires coordinate role(s) {missing} "
|
|
56
|
+
f"(set them under the subplot, e.g. x:/y:)."
|
|
57
|
+
)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SeriesConfig(SubplotConfig):
|
|
62
|
+
"""A line along one dimension (``x``)."""
|
|
63
|
+
|
|
64
|
+
required_coords: ClassVar[frozenset[str]] = frozenset({"x"})
|
|
65
|
+
type: Literal["series", "line"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Series(Primitive):
|
|
69
|
+
"""Primitive: a line of the variable along the ``x`` coordinate."""
|
|
70
|
+
|
|
71
|
+
config = SeriesConfig
|
|
72
|
+
aliases = ["series", "line"]
|
|
73
|
+
required_coords = frozenset({"x"})
|
|
74
|
+
wants_legend = True
|
|
75
|
+
|
|
76
|
+
def render(self, ax, data, *, x, y, label):
|
|
77
|
+
"""Draw a line along one dimension."""
|
|
78
|
+
xvals = (
|
|
79
|
+
data[x].values
|
|
80
|
+
if x in data.coords
|
|
81
|
+
else np.arange(data.sizes.get(x, data.size))
|
|
82
|
+
)
|
|
83
|
+
(line,) = ax.plot(xvals, data.values, label=label, **self.style)
|
|
84
|
+
return line
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ContourFillConfig(SubplotConfig):
|
|
88
|
+
"""A filled contour over a 2-D (``x``, ``y``) grid."""
|
|
89
|
+
|
|
90
|
+
required_coords: ClassVar[frozenset[str]] = frozenset({"x", "y"})
|
|
91
|
+
type: Literal["contourf", "contour_fill", "contourfill"]
|
|
92
|
+
levels: int = 10
|
|
93
|
+
cmap: str = "viridis"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ContourFill(Primitive):
|
|
97
|
+
"""Primitive: a filled contour of the variable on x/y.
|
|
98
|
+
|
|
99
|
+
No CRS / projection awareness (unlike ``SpatialOverlay``) — plain axes,
|
|
100
|
+
generic by design. Projection kwargs, if ever needed, flow through
|
|
101
|
+
``model_extra``.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
config = ContourFillConfig
|
|
105
|
+
aliases = ["contourf", "contour_fill", "contourfill"]
|
|
106
|
+
required_coords = frozenset({"x", "y"})
|
|
107
|
+
wants_colorbar = True
|
|
108
|
+
|
|
109
|
+
def render(self, ax, data, *, x, y, label):
|
|
110
|
+
"""Draw filled contours over two coordinates."""
|
|
111
|
+
cfg = self.subplot_config
|
|
112
|
+
return data.plot.contourf(
|
|
113
|
+
x=x,
|
|
114
|
+
y=y,
|
|
115
|
+
ax=ax,
|
|
116
|
+
levels=cfg.levels,
|
|
117
|
+
cmap=cfg.cmap,
|
|
118
|
+
add_colorbar=False,
|
|
119
|
+
add_labels=False,
|
|
120
|
+
**self.style,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PointsConfig(SubplotConfig):
|
|
125
|
+
"""A scatter of points at (``x``, ``y``) coloured by value."""
|
|
126
|
+
|
|
127
|
+
required_coords: ClassVar[frozenset[str]] = frozenset({"x", "y"})
|
|
128
|
+
type: Literal["points", "scatter"]
|
|
129
|
+
markersize: float = 40.0
|
|
130
|
+
cmap: str = "viridis"
|
|
131
|
+
edgecolor: str | None = "k"
|
|
132
|
+
drop_nans: bool = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Points(Primitive):
|
|
136
|
+
"""Primitive: a coordinate scatter coloured by the variable value."""
|
|
137
|
+
|
|
138
|
+
config = PointsConfig
|
|
139
|
+
aliases = ["points", "scatter"]
|
|
140
|
+
required_coords = frozenset({"x", "y"})
|
|
141
|
+
wants_colorbar = True
|
|
142
|
+
|
|
143
|
+
def render(self, ax, data, *, x, y, label):
|
|
144
|
+
"""Draw coloured points at two coordinates."""
|
|
145
|
+
cfg = self.subplot_config
|
|
146
|
+
xv = np.asarray(data[x].values).ravel()
|
|
147
|
+
yv = np.asarray(data[y].values).ravel()
|
|
148
|
+
cv = np.asarray(data.values).ravel()
|
|
149
|
+
if cfg.drop_nans:
|
|
150
|
+
xv, yv, cv = _drop_nan_points(xv, yv, cv)
|
|
151
|
+
return ax.scatter(
|
|
152
|
+
xv,
|
|
153
|
+
yv,
|
|
154
|
+
c=cv,
|
|
155
|
+
s=cfg.markersize,
|
|
156
|
+
cmap=cfg.cmap,
|
|
157
|
+
edgecolor=cfg.edgecolor,
|
|
158
|
+
label=label,
|
|
159
|
+
**self.style,
|
|
160
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Readers: turning files into datasets."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pkgutil
|
|
5
|
+
|
|
6
|
+
for module in pkgutil.iter_modules(__path__):
|
|
7
|
+
importlib.import_module(f"{__name__}.{module.name}")
|
|
8
|
+
|
|
9
|
+
from .reader import Reader
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Reader",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
# Eventually add downloaders
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The reader base class and the per-topology reader families."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pkgutil
|
|
5
|
+
|
|
6
|
+
from .reader import Reader
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Reader",
|
|
10
|
+
# "Downloader",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
for module in pkgutil.iter_modules(__path__):
|
|
14
|
+
importlib.import_module(f"{__name__}.{module.name}")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Base reader for station data stored as tables.
|
|
2
|
+
|
|
3
|
+
Drives the per-file loop itself, since tabular inputs cannot go through
|
|
4
|
+
``open_mfdataset``, and attaches station coordinates from a metadata file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import xarray as xr
|
|
13
|
+
|
|
14
|
+
from ..reader import ReadSpec
|
|
15
|
+
from .default import DefaultPointSurfaceReader
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CSVPointSurfaceReader(DefaultPointSurfaceReader):
|
|
19
|
+
"""Shared base for CSV/Excel point-surface readers.
|
|
20
|
+
|
|
21
|
+
The layout-specific readers (station-per-file, single-file,
|
|
22
|
+
variable-per-file) live in CSV/Excel rather than NetCDF and share three
|
|
23
|
+
concerns this base centralises:
|
|
24
|
+
|
|
25
|
+
1. **Per-file open is not NetCDF.** The default ``_open_many`` uses
|
|
26
|
+
``xr.open_mfdataset``; here it routes safe-mode through the exact same
|
|
27
|
+
per-file loop the unsafe path runs, so the two modes are identical for
|
|
28
|
+
CSV input (subclasses only implement ``_open_one`` / ``_to_xarray``).
|
|
29
|
+
2. **A station-metadata join.** ``_postprocess`` attaches every column of a
|
|
30
|
+
sidecar metadata table onto the ``site`` dimension as a coordinate,
|
|
31
|
+
including the ``latitude``/``longitude`` that ``PointSurface._set_geom``
|
|
32
|
+
requires.
|
|
33
|
+
3. **The canonical-name rename** is reused from ``DefaultPointSurfaceReader``
|
|
34
|
+
(variable-per-file layouts set ``restrict_rename_to_present``).
|
|
35
|
+
|
|
36
|
+
Subclasses set ``default_station_key``, ``default_time_col`` and
|
|
37
|
+
``latlon_aliases`` and implement ``_open_one`` / ``_to_xarray`` / ``_join``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Pieces hold one (variable-per-file) or a disjoint set (station-per-file)
|
|
41
|
+
# of vars; filter the rename map to what's actually present in each piece.
|
|
42
|
+
restrict_rename_to_present = True
|
|
43
|
+
|
|
44
|
+
# Subclass-supplied defaults; overridable per data block via ``spec.extras``.
|
|
45
|
+
default_station_key: str = "station_id"
|
|
46
|
+
default_time_col: str = "time"
|
|
47
|
+
# Maps the metadata file's lat/lon column names onto the canonical
|
|
48
|
+
# ``latitude``/``longitude`` the rest of the pipeline expects.
|
|
49
|
+
latlon_aliases: dict[str, str] = {}
|
|
50
|
+
|
|
51
|
+
# ----- safe == unsafe for CSV ----------------------------------------
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def _open_many(cls, paths: list[Path], spec: ReadSpec) -> xr.Dataset:
|
|
55
|
+
"""Run the per-file hooks in a plain loop (mirrors the unsafe path).
|
|
56
|
+
|
|
57
|
+
``xr.open_mfdataset`` only understands NetCDF-like engines, so the
|
|
58
|
+
default safe-mode implementation can't be used. Routing both modes
|
|
59
|
+
through the same loop also makes ``safe`` and ``unsafe`` produce
|
|
60
|
+
byte-identical datasets for CSV input.
|
|
61
|
+
"""
|
|
62
|
+
pieces = [
|
|
63
|
+
cls._preprocess(cls._to_xarray(cls._open_one(p, spec), spec), spec)
|
|
64
|
+
for p in paths
|
|
65
|
+
]
|
|
66
|
+
return cls._join(pieces, spec)
|
|
67
|
+
|
|
68
|
+
# ----- metadata join --------------------------------------------------
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _postprocess(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
72
|
+
"""Attach the station metadata once the values are assembled."""
|
|
73
|
+
ds = cls._attach_metadata(ds, spec)
|
|
74
|
+
return super()._postprocess(ds, spec)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def _attach_metadata(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
78
|
+
"""Join a station-metadata table onto the ``site`` dimension.
|
|
79
|
+
|
|
80
|
+
Every metadata column becomes a ``(site,)`` coordinate. Stations with
|
|
81
|
+
no matching metadata row get NaN-filled coordinates rather than being
|
|
82
|
+
dropped. The join keys are normalised on both sides via
|
|
83
|
+
``_norm_site_key`` so e.g. integer ids in the metadata match string
|
|
84
|
+
filename-stem ids on the data, or accented station names match their
|
|
85
|
+
ASCII-uppercased CSV column headers (see subclass overrides).
|
|
86
|
+
"""
|
|
87
|
+
meta_path = spec.extras.get("metadata")
|
|
88
|
+
if meta_path is None:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"{cls.__name__} requires a 'metadata' path in the data block."
|
|
91
|
+
)
|
|
92
|
+
meta = cls._read_metadata(Path(meta_path))
|
|
93
|
+
|
|
94
|
+
key = spec.extras.get("station_key", cls.default_station_key)
|
|
95
|
+
if key not in meta.columns:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"{cls.__name__}: station key {key!r} not in metadata columns "
|
|
98
|
+
f"{list(meta.columns)}."
|
|
99
|
+
)
|
|
100
|
+
meta = meta.set_index(key).rename(columns=cls.latlon_aliases)
|
|
101
|
+
|
|
102
|
+
# Normalise the metadata index to the canonical join key and drop any
|
|
103
|
+
# duplicate stations (keep first) so reindex can align unambiguously.
|
|
104
|
+
meta.index = [cls._norm_site_key(v) for v in meta.index]
|
|
105
|
+
meta = meta[~meta.index.duplicated(keep="first")]
|
|
106
|
+
|
|
107
|
+
site_keys = [cls._norm_site_key(v) for v in ds["site"].values]
|
|
108
|
+
meta = meta.reindex(site_keys)
|
|
109
|
+
|
|
110
|
+
for col in meta.columns:
|
|
111
|
+
ds = ds.assign_coords({col: ("site", meta[col].to_numpy())})
|
|
112
|
+
return ds
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _read_metadata(path: Path) -> pd.DataFrame:
|
|
116
|
+
"""Read the metadata sidecar — Excel by suffix, else CSV."""
|
|
117
|
+
if path.suffix.lower() in (".xlsx", ".xls"):
|
|
118
|
+
return pd.read_excel(path, engine="openpyxl")
|
|
119
|
+
return pd.read_csv(path)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _norm_site_key(value) -> str:
|
|
123
|
+
"""Canonicalise a station id for the metadata join.
|
|
124
|
+
|
|
125
|
+
Numeric ids (whether stored as ``int``, ``float`` like ``109.0``, or
|
|
126
|
+
the string ``"104"``) collapse to their integer string form so that a
|
|
127
|
+
filename stem matches an ``int`` metadata id. Everything else is
|
|
128
|
+
stringified and stripped. Subclasses with text station names (e.g.
|
|
129
|
+
variable-per-file with accented headers) override this.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
f = float(value)
|
|
133
|
+
except (ValueError, TypeError):
|
|
134
|
+
return str(value).strip()
|
|
135
|
+
return str(int(f)) if f == int(f) else str(f)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Base reader for station data stored as NetCDF."""
|
|
2
|
+
|
|
3
|
+
import xarray as xr
|
|
4
|
+
|
|
5
|
+
from ..reader import Reader, ReadSpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DefaultPointSurfaceReader(Reader):
|
|
9
|
+
type_aliases = ["DefaultPointSurface"]
|
|
10
|
+
topology = "PointSurface"
|
|
11
|
+
|
|
12
|
+
rename: dict[str, str] = {}
|
|
13
|
+
|
|
14
|
+
# When True, drop rename keys that aren't present in the piece before
|
|
15
|
+
# renaming. NetCDF readers leave this False so a missing variable still
|
|
16
|
+
# raises (a useful error). Variable-per-file readers (CSV/Excel) set it
|
|
17
|
+
# True: each piece holds only one variable, so the full rename map would
|
|
18
|
+
# otherwise reference names absent from that piece and ``rename`` would
|
|
19
|
+
# raise.
|
|
20
|
+
restrict_rename_to_present: bool = False
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def _preprocess(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
24
|
+
"""Apply the variable renaming and selection declared in the spec."""
|
|
25
|
+
rename = dict(cls.rename)
|
|
26
|
+
rename.update(spec.extras.get("rename", {}))
|
|
27
|
+
if spec.vars is not None:
|
|
28
|
+
# Skip composed vars with no file name
|
|
29
|
+
rename.update(
|
|
30
|
+
{d["name"]: name for name, d in spec.vars.items() if d.get("name")}
|
|
31
|
+
)
|
|
32
|
+
if "x" in ds.coords and "site" not in ds.coords:
|
|
33
|
+
rename["x"] = "site"
|
|
34
|
+
if cls.restrict_rename_to_present:
|
|
35
|
+
rename = {k: v for k, v in rename.items() if k in ds.variables}
|
|
36
|
+
return ds.rename(rename)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .default import DefaultPointSurfaceReader
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DMC(DefaultPointSurfaceReader):
|
|
5
|
+
"""Dirección Meteorológica de Chile point-surface reader.
|
|
6
|
+
|
|
7
|
+
Currently a thin subclass of the default — kept distinct so DMC-specific
|
|
8
|
+
download/preprocessing (e.g. fetching from the DMC API) can land here
|
|
9
|
+
without touching SINCA.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .default import DefaultPointSurfaceReader
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SINCA(DefaultPointSurfaceReader):
|
|
5
|
+
"""SINCA (Sistema de Información Nacional de Calidad del Aire) reader.
|
|
6
|
+
|
|
7
|
+
Currently a thin subclass of the default — kept distinct so SINCA-specific
|
|
8
|
+
download/preprocessing (e.g. fetching from the SINCA API) can land here
|
|
9
|
+
without touching DMC.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""CSV layout: one file containing every station."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import xarray as xr
|
|
9
|
+
|
|
10
|
+
from ..reader import ReadSpec
|
|
11
|
+
from .csv_base import CSVPointSurfaceReader
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SingleFileReader(CSVPointSurfaceReader):
|
|
15
|
+
"""Reader for the single-file CSV layout (one file holds everything).
|
|
16
|
+
|
|
17
|
+
Layout: one CSV holding every station's record, keyed by a station-code
|
|
18
|
+
column, plus a ``stations.csv`` metadata table. Originally written for São
|
|
19
|
+
Paulo (CETESB/QUALAR), whose defaults this keeps; its timestamps in
|
|
20
|
+
``local_date`` are tz-aware (``-03:00``) and stored as naive local
|
|
21
|
+
wall-clock to stay consistent with the other in-situ readers.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
type_aliases = ["single-file"]
|
|
25
|
+
default_station_key = "code"
|
|
26
|
+
default_time_col = "local_date"
|
|
27
|
+
latlon_aliases = {"lat": "latitude", "lon": "longitude"}
|
|
28
|
+
|
|
29
|
+
# Non-numeric columns that ride along as metadata (per ``code``) rather
|
|
30
|
+
# than as data variables.
|
|
31
|
+
drop_cols = ["type"]
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def _open_one(cls, path: Path, spec: ReadSpec) -> pd.DataFrame:
|
|
35
|
+
"""Read the one CSV holding every station into a frame."""
|
|
36
|
+
time_col = spec.extras.get("time_col", cls.default_time_col)
|
|
37
|
+
df = pd.read_csv(path)
|
|
38
|
+
# Parse the tz-aware timestamp, then drop the tz to keep naive local
|
|
39
|
+
# wall-clock (xarray cannot hold tz-aware datetimes anyway).
|
|
40
|
+
df[time_col] = pd.to_datetime(df[time_col]).dt.tz_localize(None)
|
|
41
|
+
return df
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def _to_xarray(cls, raw: pd.DataFrame, spec: ReadSpec) -> xr.Dataset:
|
|
45
|
+
"""Pivot the frame to a dataset indexed by time and site."""
|
|
46
|
+
time_col = spec.extras.get("time_col", cls.default_time_col)
|
|
47
|
+
code_col = spec.extras.get("station_key", cls.default_station_key)
|
|
48
|
+
|
|
49
|
+
df = raw.drop(columns=cls.drop_cols, errors="ignore")
|
|
50
|
+
# Collapse to one row per (time, code), taking the first non-null per
|
|
51
|
+
# column so the partial rows merge rather than colliding.
|
|
52
|
+
df = df.groupby([time_col, code_col]).first()
|
|
53
|
+
ds = df.to_xarray()
|
|
54
|
+
return ds.rename({time_col: "time", code_col: "site"})
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def _join(cls, pieces: list[xr.Dataset], spec: ReadSpec) -> xr.Dataset:
|
|
58
|
+
# Single file in practice; base default handles len == 1.
|
|
59
|
+
"""Concatenate the per-file datasets along time."""
|
|
60
|
+
return super()._join(pieces, spec)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""CSV layout: one file per station."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import xarray as xr
|
|
9
|
+
|
|
10
|
+
from ..reader import ReadSpec
|
|
11
|
+
from .csv_base import CSVPointSurfaceReader
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StationPerFileReader(CSVPointSurfaceReader):
|
|
15
|
+
"""Reader for the station-per-file CSV layout (one station per file).
|
|
16
|
+
|
|
17
|
+
Layout: one CSV per station (``{station_id}.csv``) holding a time column
|
|
18
|
+
plus one column per variable, accompanied by a ``stations.csv`` metadata
|
|
19
|
+
table. The station id is the file's stem. Originally written for the Chile
|
|
20
|
+
SINCA per-station export, whose defaults this keeps.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
type_aliases = ["station-per-file"]
|
|
24
|
+
default_station_key = "station_id"
|
|
25
|
+
default_time_col = "timestamp"
|
|
26
|
+
latlon_aliases = {"latitud": "latitude", "longitud": "longitude"}
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def _open_one(cls, path: Path, spec: ReadSpec) -> pd.DataFrame:
|
|
30
|
+
"""Read one station's CSV into a frame."""
|
|
31
|
+
time_col = spec.extras.get("time_col", cls.default_time_col)
|
|
32
|
+
df = pd.read_csv(path, parse_dates=[time_col])
|
|
33
|
+
# Stash the station id (filename stem) for _to_xarray, which has no
|
|
34
|
+
# access to the path.
|
|
35
|
+
df.attrs["station_id"] = path.stem
|
|
36
|
+
return df
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def _to_xarray(cls, raw: pd.DataFrame, spec: ReadSpec) -> xr.Dataset:
|
|
40
|
+
"""Convert one station's frame into a single-site dataset."""
|
|
41
|
+
time_col = spec.extras.get("time_col", cls.default_time_col)
|
|
42
|
+
df = raw.set_index(time_col)
|
|
43
|
+
df.index.name = "time"
|
|
44
|
+
ds = df.to_xarray()
|
|
45
|
+
ds = ds.expand_dims(site=[raw.attrs["station_id"]])
|
|
46
|
+
return ds.transpose("time", "site")
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _join(cls, pieces: list[xr.Dataset], spec: ReadSpec) -> xr.Dataset:
|
|
50
|
+
"""Concatenate the per-station datasets along the site dimension."""
|
|
51
|
+
if len(pieces) == 1:
|
|
52
|
+
return pieces[0]
|
|
53
|
+
# One station per file: stack along site (NOT the default time-concat),
|
|
54
|
+
# outer-joining the time axes so stations with differing coverage align.
|
|
55
|
+
return xr.concat(pieces, dim="site", join="outer")
|