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 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
+