metdatapy 1.0.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.
- metdatapy/__init__.py +23 -0
- metdatapy/cli.py +314 -0
- metdatapy/core.py +220 -0
- metdatapy/derive.py +393 -0
- metdatapy/io.py +350 -0
- metdatapy/manifest.py +345 -0
- metdatapy/mapper.py +214 -0
- metdatapy/mlprep.py +306 -0
- metdatapy/qc.py +318 -0
- metdatapy/units.py +53 -0
- metdatapy/utils.py +61 -0
- metdatapy-1.0.0.dist-info/METADATA +285 -0
- metdatapy-1.0.0.dist-info/RECORD +17 -0
- metdatapy-1.0.0.dist-info/WHEEL +5 -0
- metdatapy-1.0.0.dist-info/entry_points.txt +2 -0
- metdatapy-1.0.0.dist-info/licenses/LICENSE +23 -0
- metdatapy-1.0.0.dist-info/top_level.txt +1 -0
metdatapy/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""MetDataPy package init."""
|
|
2
|
+
|
|
3
|
+
from .core import WeatherSet
|
|
4
|
+
from .mapper import Mapper, Detector
|
|
5
|
+
from .mlprep import make_supervised, time_split, fit_scaler, apply_scaler
|
|
6
|
+
from .qc import qc_range, qc_spike, qc_flatline, qc_consistency
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"WeatherSet",
|
|
10
|
+
"Mapper",
|
|
11
|
+
"Detector",
|
|
12
|
+
"make_supervised",
|
|
13
|
+
"time_split",
|
|
14
|
+
"fit_scaler",
|
|
15
|
+
"apply_scaler",
|
|
16
|
+
"qc_range",
|
|
17
|
+
"qc_spike",
|
|
18
|
+
"qc_flatline",
|
|
19
|
+
"qc_consistency",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
|
metdatapy/cli.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from .mapper import Detector, Mapper
|
|
9
|
+
from .core import WeatherSet
|
|
10
|
+
from .io import to_parquet
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def main():
|
|
15
|
+
"""MetDataPy command-line interface."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@main.group()
|
|
20
|
+
def ingest():
|
|
21
|
+
"""Ingestion helpers."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@ingest.command("detect")
|
|
26
|
+
@click.option("--csv", "csv_path", required=True, type=click.Path(exists=True, dir_okay=False))
|
|
27
|
+
@click.option("--save", "save_path", required=False, type=click.Path(dir_okay=False))
|
|
28
|
+
@click.option("--yes", is_flag=True, help="Accept detected mapping without interactive editing")
|
|
29
|
+
def ingest_detect(csv_path: str, save_path: Optional[str], yes: bool):
|
|
30
|
+
det = Detector()
|
|
31
|
+
# Read a sample for column choices
|
|
32
|
+
df_head = pd.read_csv(csv_path, nrows=200)
|
|
33
|
+
mapping = det.detect(df_head)
|
|
34
|
+
|
|
35
|
+
if not yes:
|
|
36
|
+
mapping = _interactive_mapping_wizard(mapping, list(df_head.columns))
|
|
37
|
+
|
|
38
|
+
click.echo(json.dumps(mapping, indent=2))
|
|
39
|
+
if save_path:
|
|
40
|
+
Mapper.save(mapping, save_path)
|
|
41
|
+
click.echo(f"Saved mapping to {save_path}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _interactive_mapping_wizard(mapping: dict, columns: List[str]) -> dict:
|
|
45
|
+
"""Interactive confirm/edit flow for detected mapping."""
|
|
46
|
+
from .mapper import CANONICAL_FIELDS
|
|
47
|
+
|
|
48
|
+
click.echo("Interactive mapping wizard (press Enter to accept defaults). Type 'none' to unset.")
|
|
49
|
+
|
|
50
|
+
# Timestamp column
|
|
51
|
+
ts_current = (mapping.get("ts") or {}).get("col")
|
|
52
|
+
col_choices = [str(c) for c in columns]
|
|
53
|
+
if ts_current is None:
|
|
54
|
+
ts_current = col_choices[0] if col_choices else None
|
|
55
|
+
ts_selected = click.prompt(
|
|
56
|
+
"Timestamp column",
|
|
57
|
+
default=ts_current or "",
|
|
58
|
+
show_default=True,
|
|
59
|
+
).strip()
|
|
60
|
+
if ts_selected.lower() == "none" or ts_selected == "":
|
|
61
|
+
mapping["ts"] = {"col": None}
|
|
62
|
+
else:
|
|
63
|
+
mapping["ts"] = {"col": ts_selected}
|
|
64
|
+
|
|
65
|
+
# Ensure fields dict exists
|
|
66
|
+
if "fields" not in mapping or mapping["fields"] is None:
|
|
67
|
+
mapping["fields"] = {}
|
|
68
|
+
|
|
69
|
+
# Loop over canonical fields (union with detected keys)
|
|
70
|
+
canonical_all = list({*CANONICAL_FIELDS, *mapping["fields"].keys()})
|
|
71
|
+
for canon in canonical_all:
|
|
72
|
+
current = mapping["fields"].get(canon, {})
|
|
73
|
+
cur_col = current.get("col") or ""
|
|
74
|
+
cur_unit = (current.get("unit") or "")
|
|
75
|
+
conf = current.get("confidence")
|
|
76
|
+
if conf is not None:
|
|
77
|
+
click.echo(f"\n{canon}: (confidence={conf})")
|
|
78
|
+
else:
|
|
79
|
+
click.echo(f"\n{canon}:")
|
|
80
|
+
new_col = click.prompt(
|
|
81
|
+
f" Source column for {canon}",
|
|
82
|
+
default=cur_col,
|
|
83
|
+
show_default=True,
|
|
84
|
+
).strip()
|
|
85
|
+
if new_col.lower() == "none":
|
|
86
|
+
if canon in mapping["fields"]:
|
|
87
|
+
del mapping["fields"][canon]
|
|
88
|
+
continue
|
|
89
|
+
if new_col:
|
|
90
|
+
# Ask for unit if applicable
|
|
91
|
+
new_unit = click.prompt(
|
|
92
|
+
f" Unit for {canon} (e.g., C, F, m/s, km/h, hpa, mm)",
|
|
93
|
+
default=cur_unit,
|
|
94
|
+
show_default=True,
|
|
95
|
+
).strip()
|
|
96
|
+
entry = {"col": new_col}
|
|
97
|
+
if new_unit:
|
|
98
|
+
entry["unit"] = new_unit
|
|
99
|
+
# Preserve confidence if present
|
|
100
|
+
if conf is not None:
|
|
101
|
+
entry["confidence"] = conf
|
|
102
|
+
mapping["fields"][canon] = entry
|
|
103
|
+
|
|
104
|
+
return mapping
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@ingest.command("apply")
|
|
108
|
+
@click.option("--csv", "csv_path", required=True, type=click.Path(exists=True, dir_okay=False))
|
|
109
|
+
@click.option("--map", "map_path", required=True, type=click.Path(exists=True, dir_okay=False))
|
|
110
|
+
@click.option("--out", "out_path", required=True, type=click.Path(dir_okay=False))
|
|
111
|
+
def ingest_apply(csv_path: str, map_path: str, out_path: str):
|
|
112
|
+
mapping = Mapper.load(map_path)
|
|
113
|
+
df = pd.read_csv(csv_path)
|
|
114
|
+
ws = WeatherSet.from_mapping(df, mapping).to_utc().normalize_units(mapping)
|
|
115
|
+
to_parquet(ws.to_dataframe(), out_path)
|
|
116
|
+
click.echo(f"Wrote {out_path}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@main.group()
|
|
120
|
+
def qc():
|
|
121
|
+
"""Quality control commands."""
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@qc.command("run")
|
|
126
|
+
@click.option("--in", "in_path", required=True, type=click.Path(exists=True, dir_okay=False))
|
|
127
|
+
@click.option("--out", "out_path", required=True, type=click.Path(dir_okay=False))
|
|
128
|
+
@click.option("--report", "report_path", required=False, type=click.Path(dir_okay=False))
|
|
129
|
+
@click.option("--config", "config_path", required=False, type=click.Path(exists=True, dir_okay=False), help="YAML/JSON thresholds for QC")
|
|
130
|
+
def qc_run(in_path: str, out_path: str, report_path: Optional[str], config_path: Optional[str]):
|
|
131
|
+
df = pd.read_parquet(in_path)
|
|
132
|
+
ws = WeatherSet(df)
|
|
133
|
+
cfg = None
|
|
134
|
+
if config_path:
|
|
135
|
+
text = Path(config_path).read_text(encoding="utf-8")
|
|
136
|
+
try:
|
|
137
|
+
import yaml as _yaml
|
|
138
|
+
cfg = _yaml.safe_load(text)
|
|
139
|
+
except Exception:
|
|
140
|
+
try:
|
|
141
|
+
cfg = json.loads(text)
|
|
142
|
+
except Exception:
|
|
143
|
+
cfg = None
|
|
144
|
+
ws = ws.qc_range()
|
|
145
|
+
from .qc import qc_spike as _sp, qc_flatline as _fl
|
|
146
|
+
sp = cfg.get("spike", {}) if isinstance(cfg, dict) else {}
|
|
147
|
+
fl = cfg.get("flatline", {}) if isinstance(cfg, dict) else {}
|
|
148
|
+
ws.df = _sp(ws.df, window=int(sp.get("window", 9)), thresh=float(sp.get("thresh", 6.0)))
|
|
149
|
+
ws.df = _fl(ws.df, window=int(fl.get("window", 5)), tol=float(fl.get("tol", 0.0)))
|
|
150
|
+
ws = ws.qc_consistency()
|
|
151
|
+
out_df = ws.to_dataframe()
|
|
152
|
+
out_df.to_parquet(out_path)
|
|
153
|
+
click.echo(f"Wrote {out_path}")
|
|
154
|
+
if report_path:
|
|
155
|
+
report = {}
|
|
156
|
+
for col in out_df.columns:
|
|
157
|
+
if col.startswith("qc_"):
|
|
158
|
+
report[col] = int(out_df[col].fillna(False).sum())
|
|
159
|
+
Path(report_path).write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
160
|
+
click.echo(f"Saved report to {report_path}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@ingest.command("template")
|
|
164
|
+
@click.option("--out", "out_path", required=False, type=click.Path(dir_okay=False))
|
|
165
|
+
@click.option("--minimal", is_flag=True, help="Exclude optional fields from template")
|
|
166
|
+
def ingest_template(out_path: Optional[str], minimal: bool):
|
|
167
|
+
from .mapper import Mapper
|
|
168
|
+
tpl = Mapper.template(include_optional=not minimal)
|
|
169
|
+
s = json.dumps(tpl, indent=2)
|
|
170
|
+
if out_path:
|
|
171
|
+
Path(out_path).write_text(s, encoding="utf-8")
|
|
172
|
+
click.echo(f"Wrote mapping template to {out_path}")
|
|
173
|
+
else:
|
|
174
|
+
click.echo(s)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@main.group()
|
|
178
|
+
def manifest():
|
|
179
|
+
"""Manifest and reproducibility commands."""
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@manifest.command("validate")
|
|
184
|
+
@click.argument("manifest_path", type=click.Path(exists=True, dir_okay=False))
|
|
185
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed validation results")
|
|
186
|
+
def manifest_validate(manifest_path: str, verbose: bool):
|
|
187
|
+
"""Validate a manifest.json file."""
|
|
188
|
+
from .manifest import validate_manifest
|
|
189
|
+
|
|
190
|
+
click.echo(f"Validating manifest: {manifest_path}")
|
|
191
|
+
results = validate_manifest(manifest_path)
|
|
192
|
+
|
|
193
|
+
if results["valid"]:
|
|
194
|
+
click.secho("✓ Manifest is valid", fg="green", bold=True)
|
|
195
|
+
|
|
196
|
+
if verbose:
|
|
197
|
+
click.echo(f"\nManifest Details:")
|
|
198
|
+
click.echo(f" Version: {results['version']}")
|
|
199
|
+
click.echo(f" MetDataPy Version: {results['metdatapy_version']}")
|
|
200
|
+
click.echo(f" Pipeline Steps: {results['pipeline_steps']}")
|
|
201
|
+
click.echo(f" Pipeline Hash: {results['pipeline_hash']}")
|
|
202
|
+
click.echo(f" Has QC Report: {results['has_qc_report']}")
|
|
203
|
+
click.echo(f" Has Scaler: {results['has_scaler']}")
|
|
204
|
+
click.echo(f" Has Split: {results['has_split']}")
|
|
205
|
+
|
|
206
|
+
if results.get("warnings"):
|
|
207
|
+
click.echo(f"\nWarnings:")
|
|
208
|
+
for warning in results["warnings"]:
|
|
209
|
+
click.secho(f" ⚠ {warning}", fg="yellow")
|
|
210
|
+
else:
|
|
211
|
+
click.secho("✗ Manifest is invalid", fg="red", bold=True)
|
|
212
|
+
if results.get("errors"):
|
|
213
|
+
click.echo(f"\nErrors:")
|
|
214
|
+
for error in results["errors"]:
|
|
215
|
+
click.secho(f" ✗ {error}", fg="red")
|
|
216
|
+
raise click.Abort()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@manifest.command("show")
|
|
220
|
+
@click.argument("manifest_path", type=click.Path(exists=True, dir_okay=False))
|
|
221
|
+
@click.option("--format", "output_format", type=click.Choice(["json", "yaml", "summary"]), default="summary")
|
|
222
|
+
def manifest_show(manifest_path: str, output_format: str):
|
|
223
|
+
"""Display manifest contents."""
|
|
224
|
+
from .manifest import Manifest
|
|
225
|
+
|
|
226
|
+
m = Manifest.from_json(manifest_path)
|
|
227
|
+
|
|
228
|
+
if output_format == "json":
|
|
229
|
+
click.echo(json.dumps(m.model_dump(), indent=2))
|
|
230
|
+
elif output_format == "yaml":
|
|
231
|
+
import yaml
|
|
232
|
+
click.echo(yaml.dump(m.model_dump(), sort_keys=False))
|
|
233
|
+
else: # summary
|
|
234
|
+
click.echo(f"Manifest Summary")
|
|
235
|
+
click.echo(f"{'=' * 60}")
|
|
236
|
+
click.echo(f"Created: {m.created_at}")
|
|
237
|
+
click.echo(f"MetDataPy Version: {m.metdatapy_version}")
|
|
238
|
+
click.echo(f"Pipeline Hash: {m.pipeline_hash}")
|
|
239
|
+
|
|
240
|
+
click.echo(f"\nDataset:")
|
|
241
|
+
click.echo(f" Source: {m.dataset.source}")
|
|
242
|
+
click.echo(f" Rows: {m.dataset.rows:,}")
|
|
243
|
+
click.echo(f" Columns: {len(m.dataset.columns)}")
|
|
244
|
+
click.echo(f" Time Range: {m.dataset.start_time} to {m.dataset.end_time}")
|
|
245
|
+
if m.dataset.frequency:
|
|
246
|
+
click.echo(f" Frequency: {m.dataset.frequency}")
|
|
247
|
+
|
|
248
|
+
click.echo(f"\nPipeline Steps ({len(m.pipeline_steps)}):")
|
|
249
|
+
for i, step in enumerate(m.pipeline_steps, 1):
|
|
250
|
+
duration = f" ({step.duration_seconds:.2f}s)" if step.duration_seconds else ""
|
|
251
|
+
click.echo(f" {i}. {step.function}{duration}")
|
|
252
|
+
|
|
253
|
+
click.echo(f"\nFeatures:")
|
|
254
|
+
click.echo(f" Original: {len(m.features.original_features)}")
|
|
255
|
+
click.echo(f" Derived: {len(m.features.derived_features)}")
|
|
256
|
+
click.echo(f" Lag: {len(m.features.lag_features)}")
|
|
257
|
+
click.echo(f" Calendar: {len(m.features.calendar_features)}")
|
|
258
|
+
click.echo(f" Target: {len(m.features.target_features)}")
|
|
259
|
+
|
|
260
|
+
if m.qc_report:
|
|
261
|
+
click.echo(f"\nQuality Control:")
|
|
262
|
+
click.echo(f" Total Flags: {m.qc_report.total_flags:,}")
|
|
263
|
+
click.echo(f" Flagged: {m.qc_report.flagged_percentage:.2f}%")
|
|
264
|
+
if m.qc_report.flags_by_type:
|
|
265
|
+
click.echo(f" By Type:")
|
|
266
|
+
for flag_type, count in sorted(m.qc_report.flags_by_type.items()):
|
|
267
|
+
click.echo(f" {flag_type}: {count:,}")
|
|
268
|
+
|
|
269
|
+
if m.scaler:
|
|
270
|
+
click.echo(f"\nScaler:")
|
|
271
|
+
click.echo(f" Method: {m.scaler.method}")
|
|
272
|
+
click.echo(f" Columns: {len(m.scaler.columns)}")
|
|
273
|
+
|
|
274
|
+
if m.split:
|
|
275
|
+
click.echo(f"\nSplit Boundaries:")
|
|
276
|
+
click.echo(f" Train: {m.split.train_start} to {m.split.train_end}")
|
|
277
|
+
if m.split.val_start:
|
|
278
|
+
click.echo(f" Val: {m.split.val_start} to {m.split.val_end}")
|
|
279
|
+
if m.split.test_start:
|
|
280
|
+
click.echo(f" Test: {m.split.test_start} to {m.split.test_end}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@manifest.command("compare")
|
|
284
|
+
@click.argument("manifest1", type=click.Path(exists=True, dir_okay=False))
|
|
285
|
+
@click.argument("manifest2", type=click.Path(exists=True, dir_okay=False))
|
|
286
|
+
def manifest_compare(manifest1: str, manifest2: str):
|
|
287
|
+
"""Compare two manifests for reproducibility."""
|
|
288
|
+
from .manifest import Manifest
|
|
289
|
+
|
|
290
|
+
m1 = Manifest.from_json(manifest1)
|
|
291
|
+
m2 = Manifest.from_json(manifest2)
|
|
292
|
+
|
|
293
|
+
results = m1.validate_reproducibility(m2)
|
|
294
|
+
|
|
295
|
+
click.echo(f"Comparing Manifests")
|
|
296
|
+
click.echo(f"{'=' * 60}")
|
|
297
|
+
click.echo(f"Manifest 1: {manifest1}")
|
|
298
|
+
click.echo(f"Manifest 2: {manifest2}")
|
|
299
|
+
click.echo()
|
|
300
|
+
|
|
301
|
+
all_match = all(results.values())
|
|
302
|
+
|
|
303
|
+
for check, passed in results.items():
|
|
304
|
+
status = "✓" if passed else "✗"
|
|
305
|
+
color = "green" if passed else "red"
|
|
306
|
+
click.secho(f"{status} {check.replace('_', ' ').title()}", fg=color)
|
|
307
|
+
|
|
308
|
+
click.echo()
|
|
309
|
+
if all_match:
|
|
310
|
+
click.secho("✓ Manifests are compatible for reproducibility", fg="green", bold=True)
|
|
311
|
+
else:
|
|
312
|
+
click.secho("✗ Manifests differ - results may not be reproducible", fg="yellow", bold=True)
|
|
313
|
+
|
|
314
|
+
|
metdatapy/core.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from .utils import CANONICAL_INDEX, CANONICAL_VARS, ensure_datetime_utc
|
|
8
|
+
from .units import (
|
|
9
|
+
fahrenheit_to_c,
|
|
10
|
+
identity,
|
|
11
|
+
mph_to_ms,
|
|
12
|
+
kmh_to_ms,
|
|
13
|
+
mbar_to_hpa,
|
|
14
|
+
pa_to_hpa,
|
|
15
|
+
)
|
|
16
|
+
from .qc import qc_range
|
|
17
|
+
from .derive import dew_point_c, vpd_kpa
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
UNIT_CONVERTERS = {
|
|
21
|
+
"temp_c": {"F": fahrenheit_to_c, "C": identity},
|
|
22
|
+
"wspd_ms": {"mph": mph_to_ms, "km/h": kmh_to_ms, "m/s": identity},
|
|
23
|
+
"gust_ms": {"mph": mph_to_ms, "km/h": kmh_to_ms, "m/s": identity},
|
|
24
|
+
"pres_hpa": {"mbar": mbar_to_hpa, "hpa": identity, "pa": pa_to_hpa},
|
|
25
|
+
"rain_mm": {"mm": identity, "inch": lambda x: x * 25.4},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WeatherSet:
|
|
30
|
+
def __init__(self, df: pd.DataFrame):
|
|
31
|
+
self.df = df
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_csv(cls, path: str, mapping: Dict) -> "WeatherSet":
|
|
35
|
+
df = pd.read_csv(path)
|
|
36
|
+
return cls.from_mapping(df, mapping)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_mapping(cls, df: pd.DataFrame, mapping: Dict) -> "WeatherSet":
|
|
40
|
+
ts_col = mapping.get("ts", {}).get("col")
|
|
41
|
+
if ts_col is None or ts_col not in df.columns:
|
|
42
|
+
raise ValueError("Timestamp column not found in mapping or data")
|
|
43
|
+
idx = ensure_datetime_utc(df[ts_col])
|
|
44
|
+
df = df.copy()
|
|
45
|
+
df.index = idx
|
|
46
|
+
df.index.name = CANONICAL_INDEX
|
|
47
|
+
|
|
48
|
+
out = pd.DataFrame(index=df.index)
|
|
49
|
+
fields = mapping.get("fields", {})
|
|
50
|
+
for canon, cfg in fields.items():
|
|
51
|
+
if canon not in CANONICAL_VARS:
|
|
52
|
+
continue
|
|
53
|
+
src = cfg.get("col")
|
|
54
|
+
if src not in df.columns:
|
|
55
|
+
continue
|
|
56
|
+
out[canon] = df[src]
|
|
57
|
+
return cls(out)
|
|
58
|
+
|
|
59
|
+
def to_utc(self) -> "WeatherSet":
|
|
60
|
+
if self.df.index.tz is None:
|
|
61
|
+
self.df.index = self.df.index.tz_localize("UTC")
|
|
62
|
+
else:
|
|
63
|
+
self.df.index = self.df.index.tz_convert("UTC")
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def normalize_units(self, mapping: Dict) -> "WeatherSet":
|
|
67
|
+
fields = mapping.get("fields", {})
|
|
68
|
+
for var, cfg in fields.items():
|
|
69
|
+
if var not in self.df.columns:
|
|
70
|
+
continue
|
|
71
|
+
unit = (cfg or {}).get("unit")
|
|
72
|
+
if unit is None:
|
|
73
|
+
continue
|
|
74
|
+
convs = UNIT_CONVERTERS.get(var)
|
|
75
|
+
if not convs:
|
|
76
|
+
continue
|
|
77
|
+
func = convs.get(unit)
|
|
78
|
+
if func is None:
|
|
79
|
+
continue
|
|
80
|
+
self.df[var] = func(self.df[var].astype(float))
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def insert_missing(self, frequency: Optional[str] = None) -> "WeatherSet":
|
|
84
|
+
freq = frequency or pd.infer_freq(self.df.index)
|
|
85
|
+
if freq is None:
|
|
86
|
+
return self
|
|
87
|
+
# Normalize deprecated frequency aliases (H->h, T->min, etc.)
|
|
88
|
+
if freq == 'H':
|
|
89
|
+
freq = 'h'
|
|
90
|
+
elif freq and freq.endswith('H') and freq[:-1].isdigit():
|
|
91
|
+
freq = freq[:-1] + 'h'
|
|
92
|
+
full = pd.date_range(self.df.index.min(), self.df.index.max(), freq=freq, tz="UTC")
|
|
93
|
+
before = self.df.index
|
|
94
|
+
self.df = self.df.reindex(full)
|
|
95
|
+
self.df.index.name = CANONICAL_INDEX
|
|
96
|
+
# Mark gaps: True where index not in original
|
|
97
|
+
gap_mask = ~self.df.index.isin(before)
|
|
98
|
+
if "gap" in self.df.columns:
|
|
99
|
+
self.df["gap"] = self.df["gap"].fillna(gap_mask)
|
|
100
|
+
else:
|
|
101
|
+
self.df["gap"] = gap_mask
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def fix_accum_rain(self) -> "WeatherSet":
|
|
105
|
+
if "rain_mm" not in self.df.columns:
|
|
106
|
+
return self
|
|
107
|
+
s = self.df["rain_mm"].astype(float)
|
|
108
|
+
ds = s.diff()
|
|
109
|
+
# If negative diff, assume counter reset: use current value as new accumulation for that step
|
|
110
|
+
reset_idx = ds[ds < 0].index
|
|
111
|
+
ds.loc[reset_idx] = s.loc[reset_idx]
|
|
112
|
+
# Negative tiny noise -> clamp to 0
|
|
113
|
+
ds = ds.clip(lower=0.0)
|
|
114
|
+
self.df["rain_mm"] = ds.fillna(0.0)
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def resample(self, rule: str, agg: Optional[dict] = None) -> "WeatherSet":
|
|
118
|
+
agg = agg or {
|
|
119
|
+
"temp_c": "mean",
|
|
120
|
+
"rh_pct": "mean",
|
|
121
|
+
"pres_hpa": "mean",
|
|
122
|
+
"wspd_ms": "mean",
|
|
123
|
+
"wdir_deg": "mean",
|
|
124
|
+
"gust_ms": "max",
|
|
125
|
+
"rain_mm": "sum",
|
|
126
|
+
"solar_wm2": "mean",
|
|
127
|
+
"uv_index": "max",
|
|
128
|
+
}
|
|
129
|
+
# Normalize frequency strings to use lowercase (pandas 2.0+ requirement)
|
|
130
|
+
# Replace deprecated uppercase 'H' with lowercase 'h' for hours
|
|
131
|
+
rule = rule.replace('H', 'h')
|
|
132
|
+
# Filter aggregation dict to only include columns that exist
|
|
133
|
+
agg = {k: v for k, v in agg.items() if k in self.df.columns}
|
|
134
|
+
grouped = self.df.resample(rule)
|
|
135
|
+
out = grouped.agg(agg) if agg else pd.DataFrame(index=grouped.groups.keys())
|
|
136
|
+
# Propagate gap as True if any gap in period
|
|
137
|
+
if "gap" in self.df.columns:
|
|
138
|
+
out["gap"] = grouped["gap"].max()
|
|
139
|
+
# Propagate qc_* flags as any over window
|
|
140
|
+
for col in self.df.columns:
|
|
141
|
+
if isinstance(col, str) and col.startswith("qc_"):
|
|
142
|
+
try:
|
|
143
|
+
out[col] = grouped[col].max()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
self.df = out
|
|
147
|
+
self.df.index = self.df.index.tz_convert("UTC") if self.df.index.tz is not None else self.df.index.tz_localize("UTC")
|
|
148
|
+
self.df.index.name = CANONICAL_INDEX
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def calendar_features(self, cyclical: bool = True) -> "WeatherSet":
|
|
152
|
+
idx = self.df.index.tz_convert("UTC") if self.df.index.tz is not None else self.df.index.tz_localize("UTC")
|
|
153
|
+
self.df["hour"] = idx.hour
|
|
154
|
+
self.df["weekday"] = idx.weekday
|
|
155
|
+
self.df["month"] = idx.month
|
|
156
|
+
if cyclical:
|
|
157
|
+
import numpy as np
|
|
158
|
+
self.df["hour_sin"] = np.sin(2 * np.pi * self.df["hour"] / 24.0)
|
|
159
|
+
self.df["hour_cos"] = np.cos(2 * np.pi * self.df["hour"] / 24.0)
|
|
160
|
+
self.df["doy"] = idx.dayofyear
|
|
161
|
+
self.df["doy_sin"] = np.sin(2 * np.pi * self.df["doy"] / 365.25)
|
|
162
|
+
self.df["doy_cos"] = np.cos(2 * np.pi * self.df["doy"] / 365.25)
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def add_exogenous(self, exo: pd.DataFrame, how: str = "left") -> "WeatherSet":
|
|
166
|
+
# exo should have time index in UTC or tz-aware
|
|
167
|
+
if exo.index.tz is None:
|
|
168
|
+
exo.index = exo.index.tz_localize("UTC")
|
|
169
|
+
else:
|
|
170
|
+
exo.index = exo.index.tz_convert("UTC")
|
|
171
|
+
self.df = self.df.join(exo, how=how)
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def qc_range(self) -> "WeatherSet":
|
|
175
|
+
self.df = qc_range(self.df)
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def qc_spike(self) -> "WeatherSet":
|
|
179
|
+
from .qc import qc_spike
|
|
180
|
+
self.df = qc_spike(self.df)
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def qc_flatline(self) -> "WeatherSet":
|
|
184
|
+
from .qc import qc_flatline
|
|
185
|
+
self.df = qc_flatline(self.df)
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def qc_consistency(self) -> "WeatherSet":
|
|
189
|
+
from .qc import qc_consistency, qc_any
|
|
190
|
+
self.df = qc_consistency(self.df)
|
|
191
|
+
self.df = qc_any(self.df)
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
195
|
+
return self.df
|
|
196
|
+
|
|
197
|
+
def to_netcdf(
|
|
198
|
+
self,
|
|
199
|
+
path: str,
|
|
200
|
+
metadata: Optional[Dict] = None,
|
|
201
|
+
station_metadata: Optional[Dict] = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Export to CF-compliant NetCDF4 file."""
|
|
204
|
+
from .io import to_netcdf
|
|
205
|
+
to_netcdf(self.df, path, metadata, station_metadata)
|
|
206
|
+
|
|
207
|
+
def derive(self, metrics: list[str]) -> "WeatherSet":
|
|
208
|
+
if "dew_point" in metrics and {"temp_c", "rh_pct"}.issubset(self.df.columns):
|
|
209
|
+
self.df["dew_point_c"] = dew_point_c(self.df["temp_c"], self.df["rh_pct"]).astype(float)
|
|
210
|
+
if "vpd" in metrics and {"temp_c", "rh_pct"}.issubset(self.df.columns):
|
|
211
|
+
self.df["vpd_kpa"] = vpd_kpa(self.df["temp_c"], self.df["rh_pct"]).astype(float)
|
|
212
|
+
if "heat_index" in metrics and {"temp_c", "rh_pct"}.issubset(self.df.columns):
|
|
213
|
+
from .derive import heat_index_c
|
|
214
|
+
self.df["heat_index_c"] = heat_index_c(self.df["temp_c"], self.df["rh_pct"]).astype(float)
|
|
215
|
+
if "wind_chill" in metrics and {"temp_c", "wspd_ms"}.issubset(self.df.columns):
|
|
216
|
+
from .derive import wind_chill_c
|
|
217
|
+
self.df["wind_chill_c"] = wind_chill_c(self.df["temp_c"], self.df["wspd_ms"]).astype(float)
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
|