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,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,7 @@
1
+ """Readers for station and point-observation data."""
2
+
3
+ import importlib
4
+ import pkgutil
5
+
6
+ for module in pkgutil.iter_modules(__path__):
7
+ 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")