Download scripts/train_2026_photosynthesis.py from SolarWine/api: direct link, hf CLI and curl.
- Browser
- Download file 8.18 kB
-
https://huggingface.co/spaces/SolarWine/api/resolve/main/scripts/train_2026_photosynthesis.py
- Command line
-
hf download hf://spaces/SolarWine/api/scripts/train_2026_photosynthesis.py
-
curl -L -o train_2026_photosynthesis.py https://huggingface.co/spaces/SolarWine/api/resolve/main/scripts/train_2026_photosynthesis.py
8.18 kB
| """Experimental Stage-2 photosynthesis ML training on the 2026-schema parquet. | |
| This is an exploratory smoke test on ~10 days of data — **not** a production | |
| model. The goal is to (a) validate the training pipeline end-to-end, (b) see | |
| whether the 2026-fleet sensor signals (NDVI, PRI, PSRI, soil moisture, leaf | |
| temp) carry information beyond IMS weather, and (c) establish a baseline that | |
| we can revisit once enough growing-season data accumulates (~Aug 2026). | |
| Two feature sets are trained side by side: | |
| Model A — forecast-grade | |
| Features: IMS weather only (ghi, air_temp, rh, wind) | |
| Use case: day-ahead forecasting (no on-site data needed at inference) | |
| Model B — full | |
| Features: Model A + 2026 canopy state (leaf_temp, NDVI, PRI, PSRI, | |
| soil_moisture_shallow, soil_temp_shallow) | |
| Use case: nowcasting / control-loop input | |
| Excluded from both: | |
| par_umol_derived, vpd_kpa_derived, cwsi_proxy — these feed the Farquhar | |
| equation that generates the label, so including them reduces ML to | |
| reverse-engineering. The whole point of Stage-2 ML is to predict A from | |
| signals the Farquhar inputs are derived from. | |
| Honest expectation: with 964 daytime rows and a 2-day hold-out, both models | |
| will likely look very good on this hold-out (the test conditions are close | |
| to training) and the contrast between A and B may not be statistically | |
| meaningful. Treat numbers as directional. | |
| Usage:: | |
| python -m scripts.train_2026_photosynthesis | |
| python -m scripts.train_2026_photosynthesis --holdout-days 2 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import pickle | |
| import sys | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import numpy as np | |
| import pandas as pd | |
| _PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(_PROJECT_ROOT)) | |
| _PARQUET = _PROJECT_ROOT / "Data" / "2026" / "sensor_history.parquet" | |
| _OUT_PKL = _PROJECT_ROOT / "Data" / "2026" / "photosynthesis_model.pkl" | |
| _TARGET = "a_farquhar_umol" | |
| _FEATURES_A = ["ghi_w_m2", "air_temperature_c", "rh_percent", "wind_speed_ms"] | |
| _FEATURES_B = _FEATURES_A + [ | |
| "leaf_temperature", "ndvi", "pri", "psri", | |
| "soil_moisture_shallow_pct", "soil_temp_shallow_c", | |
| ] | |
| def _load_daytime() -> pd.DataFrame: | |
| if not _PARQUET.exists(): | |
| raise FileNotFoundError( | |
| f"{_PARQUET} not found. Run scripts.collect_2026_training_data first." | |
| ) | |
| df = pd.read_parquet(_PARQUET) | |
| # Daytime only — A is 0 at night by construction, includes those would | |
| # inflate R² without testing model skill. | |
| df = df[df["ghi_w_m2"] > 50].copy() | |
| df = df.dropna(subset=_FEATURES_B + [_TARGET]) | |
| df = df.sort_index() | |
| return df | |
| def _train_and_eval(name: str, X_tr, y_tr, X_te, y_te, model): | |
| from sklearn.metrics import mean_absolute_error, r2_score | |
| model.fit(X_tr, y_tr) | |
| pred_tr = model.predict(X_tr) | |
| pred_te = model.predict(X_te) | |
| mae_tr = mean_absolute_error(y_tr, pred_tr) | |
| mae_te = mean_absolute_error(y_te, pred_te) | |
| r2_tr = r2_score(y_tr, pred_tr) | |
| r2_te = r2_score(y_te, pred_te) | |
| print(f" {name:<22} MAE train={mae_tr:.3f} test={mae_te:.3f} " | |
| f"R² train={r2_tr:.3f} test={r2_te:.3f}") | |
| return model, mae_te, r2_te | |
| def main() -> None: | |
| p = argparse.ArgumentParser(description="Train an exploratory 2026 PS model.") | |
| p.add_argument("--holdout-days", type=int, default=2) | |
| args = p.parse_args() | |
| from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor | |
| from sklearn.linear_model import LinearRegression | |
| df = _load_daytime() | |
| print(f"Loaded {len(df):,} daytime rows " | |
| f"{df.index.min()} → {df.index.max()}") | |
| # Temporal split — last N days as hold-out | |
| split = df.index.max() - pd.Timedelta(days=args.holdout_days) | |
| train = df[df.index < split] | |
| test = df[df.index >= split] | |
| print(f"Train: {len(train):,} rows Hold-out: {len(test):,} rows " | |
| f"(split at {split})") | |
| if len(test) < 50: | |
| raise RuntimeError("Hold-out window too small — collect more days first.") | |
| y_tr = train[_TARGET].to_numpy() | |
| y_te = test[_TARGET].to_numpy() | |
| naive_mean = float(y_tr.mean()) | |
| naive_mae = float(np.abs(y_te - naive_mean).mean()) | |
| print(f"\nNaive 'predict training-mean' baseline: MAE = {naive_mae:.3f} " | |
| f"(model has to beat this to be useful)") | |
| results: dict[str, dict] = {} | |
| for set_name, feats in [("A_ims_only", _FEATURES_A), ("B_ims+canopy", _FEATURES_B)]: | |
| print(f"\n=== Feature set {set_name} ({len(feats)} features) ===") | |
| print(f" features: {feats}") | |
| X_tr = train[feats].to_numpy() | |
| X_te = test[feats].to_numpy() | |
| lr, lr_mae, lr_r2 = _train_and_eval("LinearRegression", X_tr, y_tr, X_te, y_te, | |
| LinearRegression()) | |
| rf, rf_mae, rf_r2 = _train_and_eval("RandomForest", X_tr, y_tr, X_te, y_te, | |
| RandomForestRegressor( | |
| n_estimators=300, | |
| max_depth=8, | |
| min_samples_leaf=4, | |
| random_state=42, | |
| n_jobs=-1, | |
| )) | |
| gbr, gbr_mae, gbr_r2 = _train_and_eval("GradientBoosting", X_tr, y_tr, X_te, y_te, | |
| GradientBoostingRegressor( | |
| n_estimators=300, | |
| max_depth=4, | |
| learning_rate=0.05, | |
| random_state=42, | |
| )) | |
| # Feature importances from RF (most interpretable) | |
| imp = pd.Series(rf.feature_importances_, index=feats).sort_values(ascending=False) | |
| print(" RF feature importances:") | |
| for k, v in imp.items(): | |
| print(f" {k:<28} {v:.3f}") | |
| results[set_name] = { | |
| "features": feats, | |
| "lr": {"model": lr, "mae": lr_mae, "r2": lr_r2}, | |
| "rf": {"model": rf, "mae": rf_mae, "r2": rf_r2}, | |
| "gbr": {"model": gbr, "mae": gbr_mae, "r2": gbr_r2}, | |
| "feature_importances": imp.to_dict(), | |
| } | |
| # ---------- Summary ---------- | |
| print("\n=== Summary (hold-out MAE / R²) ===") | |
| print(f" Naive baseline MAE = {naive_mae:.3f}") | |
| for set_name, r in results.items(): | |
| for mname in ("lr", "rf", "gbr"): | |
| d = r[mname] | |
| print(f" {set_name:<14} {mname:<4} MAE = {d['mae']:.3f} R² = {d['r2']:.3f}") | |
| # Save the best Model B variant for downstream consumption. | |
| best_b = min(("lr", "rf", "gbr"), key=lambda m: results["B_ims+canopy"][m]["mae"]) | |
| bundle = { | |
| "model_a_features": _FEATURES_A, | |
| "model_a_rf": results["A_ims_only"]["rf"]["model"], | |
| "model_b_features": _FEATURES_B, | |
| "model_b_best": results["B_ims+canopy"][best_b]["model"], | |
| "model_b_best_name": best_b, | |
| "test_mae_b": results["B_ims+canopy"][best_b]["mae"], | |
| "test_r2_b": results["B_ims+canopy"][best_b]["r2"], | |
| "test_mae_a": results["A_ims_only"]["rf"]["mae"], | |
| "naive_mae": naive_mae, | |
| "train_rows": int(len(train)), | |
| "test_rows": int(len(test)), | |
| "train_range": (str(train.index.min()), str(train.index.max())), | |
| "test_range": (str(test.index.min()), str(test.index.max())), | |
| "trained_at": datetime.now(tz=timezone.utc).isoformat(), | |
| "schema": "2026", | |
| "source": str(_PARQUET.relative_to(_PROJECT_ROOT)), | |
| "feature_importances_b": results["B_ims+canopy"]["rf"]["feature_importances"], | |
| } | |
| _OUT_PKL.parent.mkdir(parents=True, exist_ok=True) | |
| with open(_OUT_PKL, "wb") as f: | |
| pickle.dump(bundle, f) | |
| print(f"\nSaved bundle → {_OUT_PKL}") | |
| if __name__ == "__main__": | |
| main() | |