Skip to content

Commit c19fc16

Browse files
authored
Merge pull request #1 from ewquon/copilot/refactor-era5-gfs-preprocessing
Implement HindcastBase to eliminate duplicated code and follow Python best practices
2 parents de46382 + 110f338 commit c19fc16

11 files changed

Lines changed: 1034 additions & 130 deletions

File tree

erftools/hindcast/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Hindcast preprocessing framework for ERA5 and GFS data."""
2+
from .config import HindcastConfig
3+
from .base import HindcastBase
4+
from .era5 import ERA5Hindcast
5+
from .gfs import GFSHindcast
6+
7+
__all__ = [
8+
"HindcastConfig",
9+
"HindcastBase",
10+
"ERA5Hindcast",
11+
"GFSHindcast",
12+
]

erftools/hindcast/base.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Abstract base class for ERA5/GFS hindcast preprocessing workflows."""
2+
from __future__ import annotations
3+
4+
import datetime as _dt
5+
import logging
6+
import os
7+
from abc import ABC, abstractmethod
8+
from typing import Any, Optional, Sequence, Union
9+
10+
from erftools.utils.projection import create_lcc_mapping
11+
12+
from .config import HindcastConfig
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class HindcastBase(ABC):
18+
"""Abstract base class for hindcast preprocessing workflows.
19+
20+
Subclasses must implement :meth:`download` and :meth:`process`.
21+
The :meth:`run` method provides a template that calls both in order.
22+
23+
The configuration can be supplied in one of two ways:
24+
25+
1. **Legacy input file** – pass *config_file* as the first positional
26+
argument (a path to a ``key: value`` text file)::
27+
28+
hindcast = ERA5Hindcast("input_for_Laura")
29+
30+
2. **Inline kwargs** – omit *config_file* and supply *datetime* and
31+
*area* as keyword arguments::
32+
33+
hindcast = ERA5Hindcast(
34+
datetime="2020-08-26 00:00",
35+
area=(50, -130, 10, -50),
36+
)
37+
38+
Parameters
39+
----------
40+
config_file:
41+
Path to the key:value input file (e.g. ``input_for_Laura``).
42+
Mutually exclusive with *datetime*/*area*.
43+
datetime:
44+
Date/time of the hindcast snapshot. Accepts a ``str`` parseable by
45+
:class:`pandas.Timestamp`, a :class:`datetime.datetime`, or a
46+
:class:`pandas.Timestamp`. Keyword-only; requires *area*.
47+
area:
48+
Domain extent as ``(lat_max, lon_min, lat_min, lon_max)``.
49+
Keyword-only; requires *datetime*.
50+
**kwargs:
51+
Additional keyword arguments forwarded to subclasses.
52+
"""
53+
54+
def __init__(
55+
self,
56+
config_file: Optional[Union[str, os.PathLike]] = None,
57+
*,
58+
datetime: Optional[Union[str, _dt.datetime]] = None,
59+
area: Optional[Sequence[float]] = None,
60+
**kwargs: Any,
61+
) -> None:
62+
if config_file is not None:
63+
self.config_file: Optional[str] = os.fspath(config_file)
64+
self.config: HindcastConfig = HindcastConfig.from_file(self.config_file)
65+
logger.info("Loaded config from %s", self.config_file)
66+
elif datetime is not None and area is not None:
67+
self.config_file = None
68+
self.config = HindcastConfig.from_datetime(datetime, area)
69+
logger.info("Built config from datetime=%r, area=%r", datetime, area)
70+
else:
71+
raise ValueError(
72+
"Provide either config_file or both datetime and area keyword arguments"
73+
)
74+
self.config.validate()
75+
self.lambert_conformal: str = create_lcc_mapping(self.config.area)
76+
77+
def _setup_output_dirs(self, *dirs: str) -> None:
78+
"""Create one or more output directories."""
79+
for d in dirs:
80+
os.makedirs(d, exist_ok=True)
81+
82+
@abstractmethod
83+
def download(self) -> Any:
84+
"""Download raw hindcast data.
85+
86+
Returns
87+
-------
88+
Any
89+
Implementation-specific download result passed to :meth:`process`.
90+
"""
91+
92+
@abstractmethod
93+
def process(self, download_result: Any) -> None:
94+
"""Process downloaded data and write ERF input files.
95+
96+
Parameters
97+
----------
98+
download_result:
99+
The value returned by :meth:`download`.
100+
"""
101+
102+
def run(self) -> None:
103+
"""Template method: download then process."""
104+
logger.info("Starting %s run", self.__class__.__name__)
105+
result = self.download()
106+
self.process(result)
107+
logger.info("Finished %s run", self.__class__.__name__)

erftools/hindcast/config.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Configuration dataclass for ERA5/GFS hindcast preprocessing."""
2+
from __future__ import annotations
3+
4+
import datetime as _dt
5+
import logging
6+
import os
7+
from dataclasses import dataclass
8+
from typing import List, Sequence, Union
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
@dataclass
14+
class HindcastConfig:
15+
"""Configuration parsed from a hindcast input file.
16+
17+
The expected file format is a text file with ``key: value`` pairs::
18+
19+
year: 2020
20+
month: 08
21+
day: 26
22+
time: 00:00
23+
area: 50,-130,10,-50
24+
25+
The ``area`` field is ``lat_max, lon_min, lat_min, lon_max``.
26+
"""
27+
28+
year: int
29+
month: int
30+
day: int
31+
time: str # "HH:MM"
32+
area: List[float] # [lat_max, lon_min, lat_min, lon_max]
33+
34+
@classmethod
35+
def from_file(cls, filename: Union[str, os.PathLike]) -> "HindcastConfig":
36+
"""Parse a key:value input file into a :class:`HindcastConfig`.
37+
38+
Parameters
39+
----------
40+
filename:
41+
Path to the input file. Accepts both ``str`` and
42+
:class:`os.PathLike` objects (e.g. :class:`pathlib.Path`).
43+
44+
Returns
45+
-------
46+
HindcastConfig
47+
"""
48+
filename = os.fspath(filename)
49+
if not os.path.isfile(filename):
50+
raise FileNotFoundError(f"Input file not found: {filename}")
51+
data: dict = {}
52+
with open(filename, "r") as fh:
53+
for lineno, line in enumerate(fh, start=1):
54+
stripped = line.strip()
55+
if not stripped or stripped.startswith("#"):
56+
continue
57+
if ":" not in stripped:
58+
logger.warning(
59+
"%s line %d: skipping unrecognised line: %r",
60+
filename,
61+
lineno,
62+
stripped,
63+
)
64+
continue
65+
key, _, value = stripped.partition(":")
66+
key = key.strip().lower()
67+
value = value.strip()
68+
if key == "area":
69+
data[key] = [float(x) for x in value.split(",")]
70+
elif key == "time":
71+
data[key] = value
72+
else:
73+
try:
74+
data[key] = int(value)
75+
except ValueError:
76+
data[key] = value
77+
return cls(**data)
78+
79+
@classmethod
80+
def from_datetime(
81+
cls,
82+
dt: Union[str, _dt.datetime],
83+
area: Sequence[float],
84+
) -> "HindcastConfig":
85+
"""Construct a :class:`HindcastConfig` from a datetime-like object and area.
86+
87+
Parameters
88+
----------
89+
dt:
90+
The date/time of the hindcast snapshot. Accepted types are:
91+
92+
* ``str`` – ISO-format string (e.g. ``"2020-08-26"``,
93+
``"2020-08-26 00:00"``). When :mod:`pandas` is available,
94+
any string parseable by :class:`pandas.Timestamp` is accepted;
95+
otherwise :func:`datetime.datetime.fromisoformat` is used.
96+
* :class:`datetime.datetime` or :class:`pandas.Timestamp`
97+
(which is a subclass of :class:`datetime.datetime`).
98+
99+
area:
100+
Sequence of four floats ``[lat_max, lon_min, lat_min, lon_max]``.
101+
102+
Returns
103+
-------
104+
HindcastConfig
105+
"""
106+
if isinstance(dt, str):
107+
try:
108+
import pandas as pd # preferred: handles non-ISO formats too
109+
dt = pd.Timestamp(dt)
110+
except ImportError:
111+
# Fallback for environments without pandas: accept ISO format strings
112+
dt = _dt.datetime.fromisoformat(dt)
113+
# At this point dt is a datetime.datetime (pd.Timestamp inherits from it)
114+
if not isinstance(dt, _dt.datetime):
115+
raise TypeError(
116+
f"dt must be a str, datetime.datetime, or pandas.Timestamp; got {type(dt)!r}"
117+
)
118+
time_str = f"{dt.hour:02d}:{dt.minute:02d}"
119+
return cls(
120+
year=int(dt.year),
121+
month=int(dt.month),
122+
day=int(dt.day),
123+
time=time_str,
124+
area=list(area),
125+
)
126+
127+
def validate(self) -> None:
128+
"""Validate the parsed configuration values.
129+
130+
Raises
131+
------
132+
ValueError
133+
If any required field is missing or any value is out of range.
134+
"""
135+
for field_name in ("year", "month", "day", "time", "area"):
136+
if getattr(self, field_name, None) is None:
137+
raise ValueError(f"Missing required field: {field_name}")
138+
if len(self.area) != 4:
139+
raise ValueError(
140+
"'area' must have exactly 4 values: lat_max, lon_min, lat_min, lon_max"
141+
)
142+
lat_max, lon_min, lat_min, lon_max = self.area
143+
if lat_max <= lat_min:
144+
raise ValueError(
145+
"area: lat_max (1st value) must be greater than lat_min (3rd value)"
146+
)
147+
if lon_max <= lon_min:
148+
raise ValueError(
149+
"area: lon_max (4th value) must be greater than lon_min (2nd value)"
150+
)

0 commit comments

Comments
 (0)