diff --git a/.dockerignore b/.dockerignore index 14f59eb..9bfbb0d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,5 +15,7 @@ scripts/deploy.local.env **/*.db-* **/*.sqlite **/*.sqlite-* -data/cloudsea/models +data/cloudsea/models/*.pkl +data/cloudsea/models/*.pickle +data/cloudsea/models/candidates *.tar diff --git a/.env.example b/.env.example index 71f38e6..c4b55aa 100644 --- a/.env.example +++ b/.env.example @@ -31,8 +31,9 @@ ANALYTICS_DB_PATH=/app/data/analytics/analytics.db CLOUDSEA_ENABLED=true CLOUDSEA_ADMIN_TOKEN=your_cloudsea_admin_token_here CLOUDSEA_DB_PATH=/app/data/cloudsea/cloudsea.db -# 生产默认规则引擎;模型须经 operational/time-split 门禁并随受审镜像晋级 +# 默认关闭;生产 compose 仅对随镜像发布且通过 strict JSON/time-split 门禁的模型开启 CLOUDSEA_ML_ENABLED=false +# CLOUDSEA_MODEL_PATH=/app/baked/models/spot_wunvshan_dianjiangtai.json # 仅迁移旧模型时临时开启;pickle 可执行任意代码,生产保持 false CLOUDSEA_ALLOW_LEGACY_PICKLE_MODELS=false CLOUDSEA_CONTRIBUTE_ENABLED=true diff --git a/.gitignore b/.gitignore index 2a85b41..acd76c4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ yunhai-*-amd64.tar **/*.sqlite **/*.sqlite-* data/cloudsea/models/*.pkl +data/cloudsea/models/candidates/ internal/config.local.json internal/PAPER-ABSTRACT.md internal/research-paper.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 3244f64..a7d1086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2.1.0 — 2026-07-28 + +### Operational ML + +- Five-Woman Mountain now uses a reviewed point model trained from exact ECMWF IFS HRES 00Z single runs that were available before the D-1 18:00 decision cutoff. +- The fixed 14-feature logistic model uses time-ordered out-of-fold Platt calibration. Its final 15-day holdout achieved 0.65 balanced accuracy, 1.00 recall, and 0.209 Brier score versus the rule baseline's 0.50, 0.60, and 0.250. +- Model artifacts are non-executable JSON and load only after strict schema, point identity, finite-number, source, chronological-evaluation, and quality-gate validation. +- Admin retraining produces a candidate only. Production activation requires review, CI, an immutable image, backup, and smoke tests. + +### Data quality + +- Forecast archive backfill now uses the Single Runs API and records the exact ECMWF run. The former Historical Forecast backfill could no longer be mislabeled as an as-issued forecast. +- Training uses only the surface fields consistently available across the long-term HRES archive; unavailable pressure-level values and constant point features are excluded. +- The Five-Woman Mountain terrain and sunrise-sector profile is baked into the image so rule comparison and production inference use the same auditable DEM and local-water context without a runtime DEM dependency. + ## 2.0.0 — 2026-07-28 ### Security diff --git a/Dockerfile b/Dockerfile index 27443c3..224b118 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ RUN pip install --no-cache-dir --require-hashes -r requirements.lock -i "${PIP_I COPY backend/app ./app COPY scripts/docker-entrypoint.sh /app/docker-entrypoint.sh +COPY scripts/train_cloudsea_model.py scripts/train_operational_model.py /app/scripts/ RUN groupadd --gid 10001 yunhai \ && useradd --uid 10001 --gid yunhai --no-create-home --shell /usr/sbin/nologin yunhai \ && chmod +x /app/docker-entrypoint.sh @@ -39,6 +40,7 @@ RUN groupadd --gid 10001 yunhai \ COPY data/scenic-spots ./data/scenic-spots COPY data/terrain ./data/terrain COPY data/cloudsea/curated-spots ./baked/curated-spots +COPY data/cloudsea/models ./baked/models COPY --from=frontend-build /app/dist ./static ARG BUILD_ID=dev @@ -52,7 +54,9 @@ ENV STATIC_DIR=/app/static ENV ANALYTICS_DB_PATH=/app/data/analytics/analytics.db ENV CLOUDSEA_DB_PATH=/app/data/cloudsea/cloudsea.db ENV CLOUDSEA_CURATED_SPOTS_DIR=/app/data/cloudsea/curated-spots +ENV CLOUDSEA_MODEL_PATH=/app/baked/models/spot_wunvshan_dianjiangtai.json ENV CLOUDSEA_ML_ENABLED=false +ENV CLOUDSEA_ALLOW_LEGACY_PICKLE_MODELS=false ENV SHARE_ASSETS_DIR=/app/data/share-assets ENV SHARE_CACHE_DIR=/app/data/share-cache ENV TZ=Asia/Shanghai diff --git a/README.md b/README.md index d036b65..0d9329c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ | 层级 | 说明 | |------|------| | **规则引擎** | 基于文献的模糊逻辑评分(850/700 hPa 垂直场、逆温、低云/能见度补偿、Type A/B 型态) | -| **ML 模型** | 日出窗口 03–07 点由标注数据训练的 Logistic 回归(**v7**,62 维 precursor + dawn 特征,点位专属 pkl) | +| **ML 模型** | 五女山点将台在 03–07 点启用点位 Logistic 回归;使用前夜至日出的 14 个跨年份稳定特征,模型为严格校验的只读 JSON | | **底层观测** | 分层云量、垂直场剖面、云海型态、地面态等原始气象字段完整展示 | 内置 **五女山、大黑山、黄山、庐山** 等精选景区观景点,也支持天地图 POI 搜索与地图拖拽自定义坐标。配套 **开放标注 / 审核 / ML 训练** 共建闭环(见下方 [共建计划](#-日出云海预测模型--共建计划))。 @@ -59,11 +59,21 @@ | 🔗 **预测分享** | 分享页、OG 预览图、竖版长图;分享图动态渲染,不落盘占用硬盘 | | 🏔️ **精选景区** | 预置观景点海拔、坐标、季节权重;支持全国 POI | | 📝 **开放标注** | 匿名贡献 ID、POI/社区点云海标注、审核后入训练集 | -| 🔬 **标注回测** | 开放标注页、Historical Forecast 回测、Admin 审核与重训 API | +| 🔬 **标注回测** | 开放标注页、精确历史模型运行回测、Admin 审核与受审候选生成 | | 📈 **预测反馈** | 用户访问预测时自动快照;次日 forecast vs 实况回测;**标注页所有人可看**历史预测 vs 实况双轨对比 | --- +## 🆕 2026-07-28 · 可审计 operational ML + +- 训练输入改为 Open-Meteo Single Runs 中决策截点前已发布的 ECMWF IFS HRES 00Z 运行,不再把事后拼接资料标成前夜预报。 +- 五女山点将台使用 72 个可重建标注日训练;最后 15 天完全隔离评估,含 5 个正例、10 个负例。 +- 生产融合模型时间外平衡准确率 **65%**、召回率 **100%**、Brier **0.209**;同期规则基线为 **50% / 60% / 0.250**。 +- 模型为非可执行 JSON,严格校验点位、特征、来源、时间外协议和质量门禁后才加载;旧 Pickle 仍保持禁用。 +- Admin 只生成候选模型,不能在线热替换;通过审查后随不可变镜像发布并可回滚。 + +--- + ## 🆕 2026-06-24 补丁 · v7.1 贴地雾校准 > 详细说明:[`internal/RELEASE-2026-06-V7.1.md`](internal/RELEASE-2026-06-V7.1.md) · 用户文档:[更新说明](https://yunhai.timkj.com/docs/release-notes.html) @@ -187,10 +197,10 @@ python3 scripts/export_prediction_feedback.py --spot-id wunvshan --viewpoint-id | | 日出质量独立标注(可见 / 遮挡 / 不可拍) | ✅ **已完成**(入库;**暂不入**云海 ML,待日出模型) | | **开放贡献与审核** | 匿名 Contributor ID、30 条/日限额、pending → approved 审核 | ✅ **已完成** | | | 标注页 Admin 可视化审核队列 UI | 🔶 **部分**(API 已有,`label.html?admin=1` 待增强) | -| **社区点位专属模型** | 单点 accumulated ≥30 天 approved 后启用 spot 专属 ML pkl | ✅ **已完成**(五女山、东灵山 v7 已部署) | +| **社区点位专属模型** | 单点 ≥30 天后生成受审 JSON 候选,通过时间外门禁再随版本发布 | 🔶 **部分**(五女山点将台已部署;其他点位继续积累/评估) | | **全国 POI 通用模型** | 汇总多地区 approved 样本,训练跨点位泛化 ML | 🔶 **部分**(全局 `--approved-only` 训练已有;POI 专项管线规划中) | | **精选落库** | approved ≥15 天 → 写入 `scenic-spots/*.json`,搜索可见 | ✅ **已完成**(Admin API) | -| **定期重训** | 标注增量或周期触发自动重训 + LOOCV 门禁部署 | 🔶 **部分**(手动 `POST .../train` + `hot-patch-prod.sh`;cron 未接) | +| **定期重训** | 标注增量后生成候选 + 时间外门禁 + 受审镜像部署 | 🔶 **部分**(Admin 可生成候选;自动发布刻意关闭) | | **预测反馈** | 访问快照、次日回测、标注页历史预测 vs 实况(**全员可见**) | ✅ **已完成**(2026-06) | | **深链与收藏** | `/?spot=&vp=`、`/?lat=&lng=`、`/label.html?...` 直达预测/标注 | ✅ **已完成** | @@ -206,7 +216,7 @@ python3 scripts/export_prediction_feedback.py --spot-id wunvshan --viewpoint-id **贡献流程**:主页选 POI → 「标注此点位」→ 选择日期 → 1/2/3 标注云海 → 可选标注日出质量 → 社区点可持久化编辑坐标。 -**Admin 流程**(需 Token):审核队列 → approve/reject → 达标社区点 `curate` 落库 → `train` 重训云海 ML(不含日出质量字段)。 +**Admin 流程**(需 Token):审核队列 → approve/reject → 达标社区点 `curate` 落库 → `train` 生成受审候选(不含日出质量字段,不在线热部署)。 详细设计见 [`internal/open-annotation-plan.md`](internal/open-annotation-plan.md) · 标注操作见 [`internal/CLOUDSEA-LABEL.md`](internal/CLOUDSEA-LABEL.md) @@ -215,7 +225,7 @@ python3 scripts/export_prediction_feedback.py --spot-id wunvshan --viewpoint-id | 阶段 | 目标 | |------|------| | **近期** | 模型版本与贡献数公开展示;日出 ML 训练脚本(基于 `sunrise_quality`) | -| **中期** | **单点专属 ML**(每社区点/精选点独立 pkl + 自动切换) | +| **中期** | **单点专属 ML**(每社区点/精选点独立安全 JSON + 受审版本切换) | | **远期** | **全国 POI 通用 ML**(跨纬度/地形泛化);定时重训 CI;贡献排行榜 | **确认结论(截至当前版本)**: @@ -285,16 +295,22 @@ cd frontend && npm install && npm run dev # http://localhost:5173 ``` -### 3. 评估云海 ML(实验性) +### 3. 评估受审云海 ML ```bash -# 只用于本地评估;生产默认规则引擎 -export CLOUDSEA_ML_ENABLED=true -# 实际按 spot 加载未纳入 Git 的 spot_{spot}_{viewpoint}.pkl -export CLOUDSEA_MODEL_PATH=../data/cloudsea/models/cloudsea_candidate.pkl +# 先用精确 ECMWF 单次运行回填候选库(不要对生产库直接操作) +python3 scripts/backfill_forecast_archive.py \ + --db /path/to/review-copy.db \ + --spot-id wunvshan --viewpoint-id dianjiangtai + +# 最后 15 天隔离评估;门禁失败不会写模型 +python3 scripts/train_operational_model.py \ + --db /path/to/review-copy.db \ + --spot-id wunvshan --viewpoint-id dianjiangtai \ + --output data/cloudsea/models/spot_wunvshan_dianjiangtai.json ``` -模型不得由发布脚本临时训练。候选模型必须使用 `operational` 气象输入,并通过时间外/月度验证、样本量与特征复杂度审查后,才能在单独的受审版本中晋级;当前生产保持 `CLOUDSEA_ML_ENABLED=false`。 +模型不得由发布脚本临时训练或在线热替换。候选必须使用精确 single-run 气象输入,并通过时间外验证、安全 JSON 校验、样本量与特征复杂度审查后,才能在单独的受审版本中晋级。五女山受审模型在生产启用,其他点位门禁未通过时继续使用规则引擎。 ### 4. Docker @@ -330,7 +346,7 @@ docker compose -f docker-compose.prod.yml up -d | `PATCH` | `/api/contribute/locations/{id}` | 编辑我的社区点位名称/坐标/海拔 | | `GET` | `/api/satellite/cloud` | Himawari 红外裁切 | | `GET` | `/api/internal/cloudsea/review-queue` | 待审标注(Admin Token) | -| `POST` | `/api/internal/cloudsea/train` | 手动重训 ML(Admin Token) | +| `POST` | `/api/internal/cloudsea/train` | 生成受审 ML 候选(Admin Token;不热部署) | | `GET` | `/api/internal/cloudsea/prediction-history` | 某日历史预测访问(Admin Token) | | `POST` | `/api/internal/cloudsea/reconcile` | 手动触发预测回测(Admin Token) | | `POST` | `/api/internal/cloudsea/watch` | 手动触发气象 watcher(Admin Token) | @@ -343,20 +359,22 @@ Swagger:`http://localhost:8000/docs` ## 🔬 ML 与标注工作流 ```bash -# 1. 回填标注日完整垂直场气象 -python3 scripts/backfill_meteo_hourly.py - -# 2. 训练 ML v7(仅 approved 标注 · operational 气象) -python3 scripts/train_cloudsea_model.py \ - --db data/cloudsea/cloudsea.prod.db \ +# 1. 在生产数据库副本中回填决策时可见的精确 00Z 模型运行 +python3 scripts/backfill_forecast_archive.py \ + --db /path/to/review-copy.db \ + --spot-id wunvshan --viewpoint-id dianjiangtai + +# 2. 训练 + 时间外质量门禁(失败不产出) +python3 scripts/train_operational_model.py \ + --db /path/to/review-copy.db \ --spot-id wunvshan --viewpoint-id dianjiangtai \ - --window v7 --mode operational --enhanced --db-only + --output data/cloudsea/models/spot_wunvshan_dianjiangtai.json # 3. 模型输出 -# data/cloudsea/models/spot_wunvshan_dianjiangtai.pkl -# data/cloudsea/models/spot_donglingshan_fengding.pkl +# data/cloudsea/models/spot_wunvshan_dianjiangtai.json +# 仅包含系数、缩放参数、训练指纹与质量证据,不包含原始标注 -# 4. 生产标准发版(规则引擎;含测试、审计、备份与自动回滚) +# 4. 生产标准发版(受审模型 + 规则;含测试、审计、备份与自动回滚) cp scripts/deploy.local.env.example scripts/deploy.local.env # 首次 bash scripts/release-prod.sh @@ -367,7 +385,7 @@ bash scripts/run-scheduled-tasks.sh **开放标注页**:https://yunhai.timkj.com/label.html(无需账号,服务端签发匿名会话 Cookie) -**Admin**:审核 / 落库 / 重训见 [共建计划](#-日出云海预测模型--共建计划) · 操作细节 [`internal/CLOUDSEA-LABEL.md`](internal/CLOUDSEA-LABEL.md) +**Admin**:审核 / 落库 / 候选模型生成见 [共建计划](#-日出云海预测模型--共建计划) · 操作细节 [`internal/CLOUDSEA-LABEL.md`](internal/CLOUDSEA-LABEL.md) --- diff --git a/backend/.env.example b/backend/.env.example index 9b8f3a6..3c0188c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,8 +31,9 @@ CLOUDSEA_ENABLED=true CLOUDSEA_ADMIN_TOKEN=dev_cloudsea_token_change_me CLOUDSEA_DB_PATH=data/cloudsea/cloudsea.db CLOUDSEA_AUTO_SNAPSHOT=true -# 本地训练/评估可显式开启;默认与生产一致使用规则引擎 +# 默认关闭;生产 compose 仅对随镜像发布且通过 strict JSON/time-split 门禁的模型开启 CLOUDSEA_ML_ENABLED=false +# CLOUDSEA_MODEL_PATH=/app/baked/models/spot_wunvshan_dianjiangtai.json CLOUDSEA_ALLOW_LEGACY_PICKLE_MODELS=false CLOUDSEA_CONTRIBUTE_ENABLED=true CONTRIBUTOR_SESSION_SECRET=dev_contributor_secret_change_me_at_least_32_bytes diff --git a/backend/app/config.py b/backend/app/config.py index 0443500..36068e8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -35,7 +35,9 @@ class Settings(BaseSettings): cloudsea_auto_snapshot: bool = True cloudsea_ml_enabled: bool = False cloudsea_allow_legacy_pickle_models: bool = False - cloudsea_model_path: str = str(_PROJECT_ROOT / "data" / "cloudsea" / "models" / "cloudsea_ml_v2.pkl") + cloudsea_model_path: str = str( + _PROJECT_ROOT / "data" / "cloudsea" / "models" / "cloudsea_ml_v2.json" + ) cloudsea_contribute_enabled: bool = True cloudsea_daily_label_cap: int = 30 cloudsea_max_locations_per_contributor: int = 10 diff --git a/backend/app/engine/cloudsea_features.py b/backend/app/engine/cloudsea_features.py index d8e4f3b..4d69237 100644 --- a/backend/app/engine/cloudsea_features.py +++ b/backend/app/engine/cloudsea_features.py @@ -114,6 +114,16 @@ DAY_FEATURE_NAMES = DAY_FEATURE_NAMES + TERRAIN_FEATURE_NAMES + OBSERVABLE_FEATURE_NAMES _REQUIRED_METEO_KEYS = ("rh_700", "cloud_high", "inversion") +_OPERATIONAL_REQUIRED_METEO_KEYS = ( + "cloud_low", + "cloud_mid", + "cloud_high", + "visibility", + "rh", + "wind", + "temp", + "dewpoint", +) def label_to_target(status: str) -> float: @@ -132,6 +142,11 @@ def meteo_row_complete(row: dict) -> bool: return all(row.get(k) is not None for k in _REQUIRED_METEO_KEYS) +def operational_meteo_row_complete(row: dict) -> bool: + """Fields consistently archived by ECMWF IFS HRES single runs since March 2024.""" + return all(row.get(k) is not None for k in _OPERATIONAL_REQUIRED_METEO_KEYS) + + def build_meteo_hour_row(hourly: dict, idx: int, *, precip48: float | None = None) -> dict: times = hourly.get("time", []) t_str = times[idx] diff --git a/backend/app/engine/cloudsea_ml.py b/backend/app/engine/cloudsea_ml.py index 3389d37..7ed2675 100644 --- a/backend/app/engine/cloudsea_ml.py +++ b/backend/app/engine/cloudsea_ml.py @@ -12,9 +12,16 @@ aggregate_day_features, aggregate_precursor_features, aggregate_v7_features, + operational_meteo_row_complete, ) from app.engine.cloudsea_scorer import is_ground_fog_proxy from app.engine.ml_eligibility import build_ml_status, spot_model_path +from app.engine.operational_ml import ( + ArtifactValidationError, + artifact_feature_contributions, + load_safe_artifact, + predict_artifact_probability, +) from app.engine.utils import grade_from_probability from app.models.schemas import FactorDetail, PredictionScore @@ -32,9 +39,14 @@ def _legacy_pickle_loading_enabled() -> bool: @lru_cache(maxsize=1) def load_default_artifact() -> dict | None: - if not _legacy_pickle_loading_enabled(): - return None path = _default_model_path() + if path.suffix.lower() == ".json": + try: + return load_safe_artifact(path) + except ArtifactValidationError: + return None + if not _legacy_pickle_loading_enabled() or path.suffix.lower() not in (".pkl", ".pickle"): + return None if not path.is_file(): return None with open(path, "rb") as f: @@ -43,14 +55,24 @@ def load_default_artifact() -> dict | None: @lru_cache(maxsize=64) def load_spot_artifact(spot_id: str, viewpoint_id: str) -> dict | None: - if not _legacy_pickle_loading_enabled(): - return None path = spot_model_path(spot_id, viewpoint_id) if path.is_file(): - with open(path, "rb") as f: - return pickle.load(f) + try: + return load_safe_artifact( + path, + expected_spot_id=spot_id, + expected_viewpoint_id=viewpoint_id, + ) + except ArtifactValidationError: + return None if spot_id == "wunvshan": - return load_default_artifact() + artifact = load_default_artifact() + if ( + artifact + and artifact.get("spot_id") == spot_id + and artifact.get("viewpoint_id") == viewpoint_id + ): + return artifact return None @@ -59,7 +81,7 @@ def ml_enabled() -> bool: def get_ml_status(spot_id: str | None, viewpoint_id: str | None) -> dict: - if not _legacy_pickle_loading_enabled(): + if not spot_id or not viewpoint_id: return build_ml_status( spot_id, viewpoint_id, @@ -67,32 +89,9 @@ def get_ml_status(spot_id: str | None, viewpoint_id: str | None) -> dict: model_trained_days=0, ) - has_spot_model = False - model_trained_days = 0 - if spot_id and viewpoint_id: - has_spot_model = spot_model_path(spot_id, viewpoint_id).is_file() - artifact = None - if has_spot_model: - try: - import pickle - - with open(spot_model_path(spot_id, viewpoint_id), "rb") as f: - artifact = pickle.load(f) - except Exception: - artifact = None - if spot_id == "wunvshan" and not has_spot_model: - default_path = _default_model_path() - has_spot_model = default_path.is_file() - if has_spot_model: - try: - import pickle - - with open(default_path, "rb") as f: - artifact = pickle.load(f) - except Exception: - artifact = None - if artifact: - model_trained_days = int(artifact.get("n_days") or 0) + artifact = load_spot_artifact(spot_id, viewpoint_id) + has_spot_model = artifact is not None + model_trained_days = int(artifact.get("n_days") or 0) if artifact else 0 return build_ml_status( spot_id, viewpoint_id, @@ -440,6 +439,17 @@ def predict_day_cloudsea( if window == "v7": rows = precursor_rows if precursor_rows is not None else hour_rows date_key = target_date or str((rows[0] if rows else {}).get("time") or "")[:10] + if artifact.get("meteo_mode") == "operational_single_run": + from app.services.meteo_backfill import precursor_hour_keys + + expected = set(precursor_hour_keys(date_key)) + complete = { + str(row.get("time") or "") + for row in rows + if operational_meteo_row_complete(row) + } + if not expected.issubset(complete): + return None use_observable = any(n in feature_names for n in ("observable_fraction_mean",)) day_feat = aggregate_v7_features( rows, @@ -461,15 +471,21 @@ def predict_day_cloudsea( use_observable_field=use_observable, ) model = artifact["model"] - x = np.array([[day_feat.get(n, 0.0) for n in infer_names]]) - prob = float(model.predict_proba(x)[0, 1]) - calibrator = artifact.get("calibrator") - if calibrator is not None: - from app.engine.ml_tuning import apply_calibrator - - prob = float(apply_calibrator(calibrator, np.array([prob]))[0]) + if isinstance(model, dict): + prob = predict_artifact_probability(artifact, day_feat) + explains = artifact_feature_contributions(artifact, day_feat) + else: + x = np.array([[day_feat.get(n, 0.0) for n in infer_names]]) + prob = float(model.predict_proba(x)[0, 1]) + calibrator = artifact.get("calibrator") + if calibrator is not None: + from app.engine.ml_tuning import apply_calibrator + + prob = float(apply_calibrator(calibrator, np.array([prob]))[0]) + explains = _explain(model, day_feat, infer_names) probability = int(round(prob * 100)) - explains = _explain(model, day_feat, infer_names) + evaluation = artifact.get("evaluation") or {} + holdout_metrics = evaluation.get("metrics") or {} factors = { "ml_model": FactorDetail( @@ -478,7 +494,7 @@ def predict_day_cloudsea( label="ML 云海模型", description=( f"基于 {artifact.get('n_days', '?')} 日有效标注训练" - f"(LOOCV {artifact.get('loocv_accuracy', 0):.0%})" + f"(时间外平衡准确率 {holdout_metrics.get('balanced_accuracy', 0):.0%})" ), value=f"P={probability}%", reference=artifact.get("version", "cloudsea_ml"), diff --git a/backend/app/engine/ml_eligibility.py b/backend/app/engine/ml_eligibility.py index d1681a9..9efa581 100644 --- a/backend/app/engine/ml_eligibility.py +++ b/backend/app/engine/ml_eligibility.py @@ -19,7 +19,7 @@ def min_labels_for_ml() -> int: def spot_model_filename(spot_id: str, viewpoint_id: str) -> str: safe = f"{spot_id}_{viewpoint_id}".replace("/", "_") - return f"spot_{safe}.pkl" + return f"spot_{safe}.json" def spot_model_path(spot_id: str, viewpoint_id: str, *, models_dir: Path | None = None) -> Path: @@ -194,14 +194,21 @@ def build_ml_status( eligible = counts["eligible_labels"] trained_days = model_trained_days or 0 model_ready = has_spot_model and max(eligible, trained_days) >= min_labels + model_active = model_ready and settings.cloudsea_ml_enabled - if model_ready: + if model_active: if spot_id == "wunvshan": mode = "wunvshan_model" message = f"本点位有效标注 {eligible} 天,已启用 ML + 规则融合(五女山模型)" else: mode = "spot_model" message = f"本点位有效标注 {eligible} 天,已启用该点位专属 ML + 规则融合" + elif model_ready: + mode = "rule_only" + message = ( + f"本点位已有通过时间外评估的模型(训练 {trained_days} 天)," + "但生产 ML 开关尚未启用;03–07 点暂仅使用规则引擎" + ) else: mode = "rule_only" if eligible < min_labels: @@ -222,7 +229,7 @@ def build_ml_status( ) return { - "ml_active": model_ready and settings.cloudsea_ml_enabled, + "ml_active": model_active, "mode": mode, "min_labels": min_labels, "eligible_labels": eligible, diff --git a/backend/app/engine/operational_ml.py b/backend/app/engine/operational_ml.py new file mode 100644 index 0000000..ecde8ce --- /dev/null +++ b/backend/app/engine/operational_ml.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import json +import math +from datetime import date, datetime +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np + +from app.engine.cloudsea_features import V7_FEATURE_NAMES + +SAFE_ARTIFACT_SCHEMA_VERSION = 1 +SAFE_ARTIFACT_ALGORITHM = "logistic_regression" +SAFE_ARTIFACT_SOURCE = "open_meteo_single_runs" +SAFE_ARTIFACT_PROTOCOL = "chronological_holdout" +MAX_ARTIFACT_BYTES = 1024 * 1024 + + +class ArtifactValidationError(ValueError): + """Raised when a model artifact is unsafe, malformed, or failed review.""" + + +def chronological_holdout_indices( + dates: Sequence[str], + *, + holdout_days: int, +) -> tuple[np.ndarray, np.ndarray]: + if holdout_days <= 0 or holdout_days >= len(dates): + raise ValueError("holdout_days must leave non-empty train and holdout sets") + normalized = [str(value) for value in dates] + if normalized != sorted(normalized) or len(set(normalized)) != len(normalized): + raise ValueError("dates must be unique and sorted chronologically") + split = len(normalized) - holdout_days + train_idx = np.arange(0, split, dtype=int) + holdout_idx = np.arange(split, len(normalized), dtype=int) + if normalized[train_idx[-1]] >= normalized[holdout_idx[0]]: + raise ValueError("training dates must be earlier than holdout dates") + return train_idx, holdout_idx + + +def _reject_json_constant(value: str) -> None: + raise ArtifactValidationError(f"non-finite JSON constant is forbidden: {value}") + + +def _require_mapping(value: Any, name: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ArtifactValidationError(f"{name} must be an object") + return value + + +def _finite_number(value: Any, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ArtifactValidationError(f"{name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ArtifactValidationError(f"{name} must be finite") + return result + + +def _finite_vector(value: Any, name: str, *, length: int) -> list[float]: + if not isinstance(value, list) or len(value) != length: + raise ArtifactValidationError(f"{name} must contain exactly {length} values") + return [_finite_number(item, f"{name}[{idx}]") for idx, item in enumerate(value)] + + +def _iso_date(value: Any, name: str) -> date: + if not isinstance(value, str): + raise ArtifactValidationError(f"{name} must be an ISO date") + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ArtifactValidationError(f"{name} must be an ISO date") from exc + + +def validate_safe_artifact( + artifact: Any, + *, + expected_spot_id: str | None = None, + expected_viewpoint_id: str | None = None, +) -> dict[str, Any]: + data = _require_mapping(artifact, "artifact") + if data.get("schema_version") != SAFE_ARTIFACT_SCHEMA_VERSION: + raise ArtifactValidationError("unsupported model schema_version") + if data.get("algorithm") != SAFE_ARTIFACT_ALGORITHM: + raise ArtifactValidationError("unsupported model algorithm") + if data.get("version") != "cloudsea_logreg_operational_v1": + raise ArtifactValidationError("unsupported model version") + try: + trained_at = datetime.fromisoformat(str(data.get("trained_at") or "")) + except ValueError as exc: + raise ArtifactValidationError("trained_at must be an ISO datetime") from exc + if trained_at.tzinfo is None: + raise ArtifactValidationError("trained_at must include a timezone") + + spot_id = data.get("spot_id") + viewpoint_id = data.get("viewpoint_id") + if not isinstance(spot_id, str) or not spot_id: + raise ArtifactValidationError("spot_id is required") + if not isinstance(viewpoint_id, str) or not viewpoint_id: + raise ArtifactValidationError("viewpoint_id is required") + if expected_spot_id is not None and spot_id != expected_spot_id: + raise ArtifactValidationError("model spot_id does not match request") + if expected_viewpoint_id is not None and viewpoint_id != expected_viewpoint_id: + raise ArtifactValidationError("model viewpoint_id does not match request") + if data.get("window") != "v7": + raise ArtifactValidationError("only the reviewed v7 operational window is supported") + if data.get("meteo_mode") != "operational_single_run": + raise ArtifactValidationError("model must use operational single-run weather") + + feature_names = data.get("feature_names") + if ( + not isinstance(feature_names, list) + or not feature_names + or not all(isinstance(name, str) for name in feature_names) + or len(set(feature_names)) != len(feature_names) + ): + raise ArtifactValidationError("feature_names must be a non-empty unique string list") + unsupported = sorted(set(feature_names) - set(V7_FEATURE_NAMES)) + if unsupported: + raise ArtifactValidationError(f"unsupported features: {', '.join(unsupported)}") + + model = _require_mapping(data.get("model"), "model") + feature_count = len(feature_names) + mean = _finite_vector(model.get("mean"), "model.mean", length=feature_count) + scale = _finite_vector(model.get("scale"), "model.scale", length=feature_count) + coef = _finite_vector(model.get("coef"), "model.coef", length=feature_count) + if any(value <= 0 for value in scale): + raise ArtifactValidationError("model.scale values must be positive") + intercept = _finite_number(model.get("intercept"), "model.intercept") + if model.get("classes") != [0, 1]: + raise ArtifactValidationError("model.classes must be [0, 1]") + + threshold = _finite_number(data.get("decision_threshold"), "decision_threshold") + if not 0.0 < threshold < 1.0: + raise ArtifactValidationError("decision_threshold must be between zero and one") + n_days = data.get("n_days") + if isinstance(n_days, bool) or not isinstance(n_days, int) or n_days < 30: + raise ArtifactValidationError("n_days must meet the 30-day production minimum") + + training = _require_mapping(data.get("training"), "training") + if training.get("source") != SAFE_ARTIFACT_SOURCE: + raise ArtifactValidationError("training source is not an exact single-run archive") + if training.get("model") != "ecmwf_ifs": + raise ArtifactValidationError("training model is not the reviewed ECMWF IFS HRES source") + if training.get("run_hour_utc") != 0: + raise ArtifactValidationError("training run must be 00Z to precede the decision cutoff") + training_start = _iso_date(training.get("date_start"), "training.date_start") + training_end = _iso_date(training.get("date_end"), "training.date_end") + if training_start > training_end: + raise ArtifactValidationError("training date range is reversed") + positive_days = training.get("positive_days") + negative_days = training.get("negative_days") + if ( + isinstance(positive_days, bool) + or not isinstance(positive_days, int) + or positive_days <= 0 + or isinstance(negative_days, bool) + or not isinstance(negative_days, int) + or negative_days <= 0 + or positive_days + negative_days != n_days + ): + raise ArtifactValidationError("training class counts do not match n_days") + fingerprint = training.get("data_sha256") + if ( + not isinstance(fingerprint, str) + or len(fingerprint) != 64 + or any(char not in "0123456789abcdef" for char in fingerprint) + ): + raise ArtifactValidationError("training data_sha256 is invalid") + + evaluation = _require_mapping(data.get("evaluation"), "evaluation") + if evaluation.get("protocol") != SAFE_ARTIFACT_PROTOCOL: + raise ArtifactValidationError("model lacks chronological holdout evaluation") + holdout_positive = evaluation.get("positive_days") + holdout_negative = evaluation.get("negative_days") + if ( + isinstance(holdout_positive, bool) + or not isinstance(holdout_positive, int) + or holdout_positive <= 0 + or isinstance(holdout_negative, bool) + or not isinstance(holdout_negative, int) + or holdout_negative <= 0 + ): + raise ArtifactValidationError("holdout must contain both classes") + holdout_days = evaluation.get("holdout_days") + if ( + isinstance(holdout_days, bool) + or not isinstance(holdout_days, int) + or holdout_days < 10 + or holdout_days != holdout_positive + holdout_negative + ): + raise ArtifactValidationError("holdout is too small for production review") + review_train_start = _iso_date(evaluation.get("train_start"), "evaluation.train_start") + review_train_end = _iso_date(evaluation.get("train_end"), "evaluation.train_end") + holdout_start = _iso_date(evaluation.get("holdout_start"), "evaluation.holdout_start") + holdout_end = _iso_date(evaluation.get("holdout_end"), "evaluation.holdout_end") + if not ( + training_start <= review_train_start <= review_train_end + < holdout_start <= holdout_end <= training_end + ): + raise ArtifactValidationError("evaluation dates are not a chronological holdout") + metrics = _require_mapping(evaluation.get("metrics"), "evaluation.metrics") + for key in ("balanced_accuracy", "recall", "f1", "brier"): + value = _finite_number(metrics.get(key), f"evaluation.metrics.{key}") + if not 0.0 <= value <= 1.0: + raise ArtifactValidationError(f"evaluation.metrics.{key} must be between zero and one") + baselines = _require_mapping(evaluation.get("baselines"), "evaluation.baselines") + for key in ("majority_balanced_accuracy", "rule_balanced_accuracy"): + _finite_number(baselines.get(key), f"evaluation.baselines.{key}") + rule_context = _require_mapping( + evaluation.get("rule_context"), + "evaluation.rule_context", + ) + if rule_context.get("meteorology") != "same_exact_single_run_surface_inputs": + raise ArtifactValidationError("rule baseline does not use the reviewed weather inputs") + if rule_context.get("viewing_mode") not in {"valley_fill", "peak_overlook"}: + raise ArtifactValidationError("rule baseline viewing_mode is invalid") + if not isinstance(rule_context.get("terrain_source"), str) or not rule_context.get( + "terrain_source" + ): + raise ArtifactValidationError("rule baseline terrain_source is required") + _finite_number( + rule_context.get("elev_max_5km_m"), + "evaluation.rule_context.elev_max_5km_m", + ) + if not isinstance(rule_context.get("local_water"), bool): + raise ArtifactValidationError("rule baseline local_water must be boolean") + + quality_gate = _require_mapping(evaluation.get("quality_gate"), "evaluation.quality_gate") + checks = _require_mapping(quality_gate.get("checks"), "evaluation.quality_gate.checks") + required_checks = ("time_ordered", "both_classes", "beats_majority", "beats_rule") + if quality_gate.get("passed") is not True or any(checks.get(key) is not True for key in required_checks): + raise ArtifactValidationError("model quality gate did not pass") + + # Replace validated numeric fields so downstream inference never sees bools or exotic values. + model["mean"] = mean + model["scale"] = scale + model["coef"] = coef + model["intercept"] = intercept + data["decision_threshold"] = threshold + return data + + +def load_safe_artifact( + path: Path, + *, + expected_spot_id: str | None = None, + expected_viewpoint_id: str | None = None, +) -> dict[str, Any]: + try: + stat = path.stat() + except OSError as exc: + raise ArtifactValidationError(f"model artifact is unavailable: {exc}") from exc + if path.is_symlink() or not path.is_file() or stat.st_size <= 0 or stat.st_size > MAX_ARTIFACT_BYTES: + raise ArtifactValidationError("model artifact size is invalid") + try: + raw = json.loads( + path.read_text(encoding="utf-8"), + parse_constant=_reject_json_constant, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ArtifactValidationError(f"model artifact is not valid JSON: {exc}") from exc + return validate_safe_artifact( + raw, + expected_spot_id=expected_spot_id, + expected_viewpoint_id=expected_viewpoint_id, + ) + + +def predict_artifact_probability( + artifact: Mapping[str, Any], + features: Mapping[str, float], +) -> float: + names = artifact["feature_names"] + model = artifact["model"] + values = np.asarray([_finite_number(features.get(name, 0.0), name) for name in names]) + mean = np.asarray(model["mean"], dtype=float) + scale = np.asarray(model["scale"], dtype=float) + coef = np.asarray(model["coef"], dtype=float) + logit = float(np.dot((values - mean) / scale, coef) + float(model["intercept"])) + # Stable sigmoid avoids overflow for highly confident linear outputs. + if logit >= 0: + exp_term = math.exp(-logit) + return 1.0 / (1.0 + exp_term) + exp_term = math.exp(logit) + return exp_term / (1.0 + exp_term) + + +def artifact_feature_contributions( + artifact: Mapping[str, Any], + features: Mapping[str, float], +) -> list[dict[str, float | str]]: + names = artifact["feature_names"] + model = artifact["model"] + values = np.asarray([float(features.get(name, 0.0)) for name in names]) + standardized = (values - np.asarray(model["mean"])) / np.asarray(model["scale"]) + contributions = standardized * np.asarray(model["coef"]) + pairs = sorted( + zip(names, contributions, strict=True), + key=lambda item: abs(float(item[1])), + reverse=True, + ) + return [ + {"feature": name, "contribution": float(contribution)} + for name, contribution in pairs + ] diff --git a/backend/app/main.py b/backend/app/main.py index 26a5c5d..f98046d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -43,7 +43,7 @@ async def lifespan(_: FastAPI): app = FastAPI( title="云海日出预测 API", - version="2.0.0", + version="2.1.0", docs_url=_docs_url, redoc_url=_redoc_url, openapi_url=_openapi_url, diff --git a/backend/app/services/cloudsea_store.py b/backend/app/services/cloudsea_store.py index 2cbd5d5..3bf9b61 100644 --- a/backend/app/services/cloudsea_store.py +++ b/backend/app/services/cloudsea_store.py @@ -690,14 +690,14 @@ def forecast_archive_precursor_complete( issue_time: str | None = None, db_path: Path | None = None, ) -> bool: - from app.engine.cloudsea_features import meteo_row_complete + from app.engine.cloudsea_features import operational_meteo_row_complete from app.services.meteo_backfill import precursor_hour_keys issue = issue_time or default_forecast_issue_time(target_date) rows = load_forecast_archive_precursor( spot_id, viewpoint_id, target_date, issue_time=issue, db_path=db_path ) - have = {str(r.get("time")) for r in rows if meteo_row_complete(r)} + have = {str(r.get("time")) for r in rows if operational_meteo_row_complete(r)} return all(k in have for k in precursor_hour_keys(target_date)) diff --git a/backend/app/services/curate_service.py b/backend/app/services/curate_service.py index f78d75b..5f4f0c4 100644 --- a/backend/app/services/curate_service.py +++ b/backend/app/services/curate_service.py @@ -12,7 +12,10 @@ from app.services.community_store import get_community_location from app.services.spot_loader import reload_spots -_PROJECT_ROOT = Path(__file__).resolve().parents[2] +_APP_ROOT = Path(__file__).resolve().parents[2] +_PROJECT_ROOT = ( + _APP_ROOT.parent if (_APP_ROOT.parent / "scripts").is_dir() else _APP_ROOT +) def _slugify(name: str) -> str: @@ -170,14 +173,33 @@ def run_model_training( *, db_path: Optional[str] = None, output_path: Optional[str] = None, + spot_id: str = "wunvshan", + viewpoint_id: str = "dianjiangtai", ) -> dict[str, Any]: db = db_path or settings.cloudsea_db_path db_parent = Path(db).resolve().parent - out = output_path or str(db_parent / "models" / "cloudsea_ml_v3.pkl") + safe_name = f"spot_{spot_id}_{viewpoint_id}".replace("/", "_") + out = output_path or str(db_parent / "models" / "candidates" / f"{safe_name}.json") + report = str(Path(out).with_suffix(".evaluation.json")) Path(out).parent.mkdir(parents=True, exist_ok=True) - script = _PROJECT_ROOT / "scripts" / "train_cloudsea_model.py" + script = _PROJECT_ROOT / "scripts" / "train_operational_model.py" + if not script.is_file(): + raise RuntimeError("受审训练脚本未包含在当前镜像中,不能生成候选模型") proc = subprocess.run( - [sys.executable, str(script), "--db", db, "--output", out, "--approved-only"], + [ + sys.executable, + str(script), + "--db", + db, + "--spot-id", + spot_id, + "--viewpoint-id", + viewpoint_id, + "--output", + out, + "--report", + report, + ], capture_output=True, text=True, timeout=600, @@ -185,16 +207,26 @@ def run_model_training( ) if proc.returncode != 0: raise RuntimeError(proc.stderr or proc.stdout or "训练失败") - metrics: dict[str, Any] = {"stdout": proc.stdout[-4000:]} - for line in proc.stdout.splitlines(): - if line.startswith("loocv_accuracy:"): - metrics["loocv_accuracy"] = float(line.split(":")[1].strip()) - if "模型已保存" in line: - metrics["output"] = out - loocv = float(metrics.get("loocv_accuracy", 0)) - if loocv < settings.cloudsea_model_min_loocv: - metrics["deploy_recommended"] = False - metrics["reason"] = f"LOOCV {loocv:.3f} 低于门槛 {settings.cloudsea_model_min_loocv}" - else: - metrics["deploy_recommended"] = True - return metrics + from app.engine.operational_ml import ArtifactValidationError, load_safe_artifact + + try: + artifact = load_safe_artifact( + Path(out), + expected_spot_id=spot_id, + expected_viewpoint_id=viewpoint_id, + ) + except ArtifactValidationError as exc: + raise RuntimeError(f"候选模型未通过安全与质量校验:{exc}") from exc + evaluation = artifact["evaluation"] + return { + "status": "candidate_ready", + "deploy_recommended": True, + "reason": "候选已通过时间外评估;生产采用不可变镜像,需代码审查并随新版本发布", + "holdout_balanced_accuracy": evaluation["metrics"]["balanced_accuracy"], + "holdout_recall": evaluation["metrics"]["recall"], + "holdout_brier": evaluation["metrics"]["brier"], + "model_version": artifact["version"], + "trained_days": artifact["n_days"], + "output": out, + "evaluation_report": report, + } diff --git a/backend/app/services/predictor.py b/backend/app/services/predictor.py index 8fea780..f003e94 100644 --- a/backend/app/services/predictor.py +++ b/backend/app/services/predictor.py @@ -507,7 +507,11 @@ def _precursor_window_rows(day_key: str) -> list[dict]: dews=dews, ) ) - if req.spot_id and req.viewpoint_id: + safe_operational_model = ( + ml_artifact is not None + and ml_artifact.get("meteo_mode") == "operational_single_run" + ) + if req.spot_id and req.viewpoint_id and not safe_operational_model: rows = supplement_precursor_rows( req.spot_id, req.viewpoint_id, diff --git a/backend/tests/test_operational_ml.py b/backend/tests/test_operational_ml.py new file mode 100644 index 0000000..a6e2a0b --- /dev/null +++ b/backend/tests/test_operational_ml.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import json +import sqlite3 +import subprocess +from datetime import date +from pathlib import Path + +import numpy as np +import pytest + +from app.engine.operational_ml import ( + ArtifactValidationError, + chronological_holdout_indices, + load_safe_artifact, + predict_artifact_probability, +) +from app.engine import cloudsea_ml +from app.services.meteo_backfill import precursor_hour_keys +from scripts.backfill_forecast_archive import ( + build_single_run_url, + exact_single_run_archive_complete, + single_run_initialization, +) +from scripts.train_operational_model import _exact_archive_dates +from app.services import curate_service + + +def _valid_artifact() -> dict: + return { + "schema_version": 1, + "version": "cloudsea_logreg_operational_v1", + "algorithm": "logistic_regression", + "spot_id": "wunvshan", + "viewpoint_id": "dianjiangtai", + "window": "v7", + "meteo_mode": "operational_single_run", + "feature_names": ["rh_mean", "cloud_low_mean"], + "model": { + "mean": [80.0, 30.0], + "scale": [10.0, 20.0], + "coef": [0.8, -0.4], + "intercept": -0.1, + "classes": [0, 1], + }, + "decision_threshold": 0.5, + "trained_at": "2026-07-28T00:00:00+00:00", + "n_days": 58, + "training": { + "date_start": "2024-10-05", + "date_end": "2026-07-03", + "positive_days": 18, + "negative_days": 40, + "data_sha256": "a" * 64, + "source": "open_meteo_single_runs", + "model": "ecmwf_ifs", + "run_hour_utc": 0, + }, + "evaluation": { + "protocol": "chronological_holdout", + "train_start": "2024-10-05", + "train_end": "2026-06-11", + "holdout_start": "2026-06-19", + "holdout_end": "2026-07-03", + "holdout_days": 15, + "positive_days": 4, + "negative_days": 11, + "metrics": { + "balanced_accuracy": 0.7, + "recall": 0.8, + "f1": 0.6, + "brier": 0.18, + }, + "baselines": { + "majority_balanced_accuracy": 0.5, + "rule_balanced_accuracy": 0.55, + }, + "rule_context": { + "meteorology": "same_exact_single_run_surface_inputs", + "viewing_mode": "valley_fill", + "terrain_source": "terrain_snapshot", + "elev_max_5km_m": 787.0, + "local_water": True, + }, + "quality_gate": { + "passed": True, + "checks": { + "time_ordered": True, + "both_classes": True, + "beats_majority": True, + "beats_rule": True, + }, + }, + }, + } + + +def test_single_run_backfill_uses_exact_00z_run_before_evening_decision() -> None: + assert single_run_initialization("2026-06-19") == "2026-06-18T00:00" + + url = build_single_run_url( + start=date(2026, 6, 18), + lat=41.31976, + lng=125.40773, + run="2026-06-18T00:00", + ) + + assert url.startswith("https://single-runs-api.open-meteo.com/v1/forecast?") + assert "run=2026-06-18T00%3A00" in url + assert "models=ecmwf_ifs" in url + assert "historical-forecast-api" not in url + + +def test_old_stitched_archive_is_not_treated_as_exact_single_run(tmp_path) -> None: + db_path = tmp_path / "archive.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + CREATE TABLE meteo_forecast_archive ( + spot_id TEXT, + viewpoint_id TEXT, + target_date TEXT, + issue_time TEXT, + ts TEXT, + source TEXT + ) + """ + ) + conn.executemany( + """ + INSERT INTO meteo_forecast_archive + (spot_id, viewpoint_id, target_date, issue_time, ts, source) + VALUES ('wunvshan', 'dianjiangtai', '2026-06-19', + '2026-06-18T18:00:00+08:00', ?, 'historical_forecast') + """, + [(timestamp,) for timestamp in precursor_hour_keys("2026-06-19")], + ) + + assert not exact_single_run_archive_complete( + "wunvshan", + "dianjiangtai", + "2026-06-19", + db_path=db_path, + ) + + +def test_training_requires_every_expected_hour_from_the_exact_run(tmp_path) -> None: + db_path = tmp_path / "archive.db" + target_date = "2026-06-19" + issue_time = "2026-06-18T18:00:00+08:00" + source = "open_meteo_single_runs:ecmwf_ifs:run=2026-06-18T00:00" + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + CREATE TABLE meteo_forecast_archive ( + spot_id TEXT, + viewpoint_id TEXT, + target_date TEXT, + issue_time TEXT, + ts TEXT, + source TEXT + ) + """ + ) + conn.executemany( + """ + INSERT INTO meteo_forecast_archive + (spot_id, viewpoint_id, target_date, issue_time, ts, source) + VALUES ('wunvshan', 'dianjiangtai', ?, ?, ?, ?) + """, + [ + (target_date, issue_time, f"2026-06-18T{hour:02d}:00", source) + for hour in range(12) + ], + ) + + assert target_date not in _exact_archive_dates( + db_path, + "wunvshan", + "dianjiangtai", + ) + + +def test_chronological_holdout_never_trains_on_future_dates() -> None: + dates = [f"2026-06-{day:02d}" for day in range(1, 21)] + + train_idx, holdout_idx = chronological_holdout_indices(dates, holdout_days=5) + + assert len(train_idx) == 15 + assert len(holdout_idx) == 5 + assert max(dates[i] for i in train_idx) < min(dates[i] for i in holdout_idx) + + +def test_safe_json_artifact_loads_and_predicts_without_pickle(tmp_path) -> None: + path = tmp_path / "spot_wunvshan_dianjiangtai.json" + path.write_text(json.dumps(_valid_artifact()), encoding="utf-8") + + artifact = load_safe_artifact( + path, + expected_spot_id="wunvshan", + expected_viewpoint_id="dianjiangtai", + ) + probability = predict_artifact_probability( + artifact, + {"rh_mean": 90.0, "cloud_low_mean": 10.0}, + ) + + expected = 1.0 / (1.0 + np.exp(-1.1)) + assert probability == pytest.approx(expected) + + +@pytest.mark.parametrize( + "mutate", + [ + lambda a: a["evaluation"]["quality_gate"].update({"passed": False}), + lambda a: a["evaluation"].update({"protocol": "leave_one_out"}), + lambda a: a["model"].update({"coef": [0.8]}), + lambda a: a["model"].update({"intercept": float("nan")}), + lambda a: a["training"].update({"source": "historical_forecast_stitched"}), + lambda a: a["evaluation"].pop("rule_context"), + ], +) +def test_safe_artifact_rejects_unreviewed_or_malformed_models(tmp_path, mutate) -> None: + artifact = _valid_artifact() + mutate(artifact) + path = tmp_path / "model.json" + path.write_text(json.dumps(artifact), encoding="utf-8") + + with pytest.raises(ArtifactValidationError): + load_safe_artifact( + path, + expected_spot_id="wunvshan", + expected_viewpoint_id="dianjiangtai", + ) + + +def test_safe_artifact_rejects_wrong_spot(tmp_path) -> None: + path = tmp_path / "model.json" + path.write_text(json.dumps(_valid_artifact()), encoding="utf-8") + + with pytest.raises(ArtifactValidationError): + load_safe_artifact( + path, + expected_spot_id="donglingshan", + expected_viewpoint_id="fengding", + ) + + +def test_admin_training_generates_reviewed_json_candidate( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir() + (scripts_dir / "train_operational_model.py").write_text("# test", encoding="utf-8") + output = tmp_path / "models" / "candidate.json" + captured: list[str] = [] + + def fake_run(args, **kwargs): + captured.extend(str(value) for value in args) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(_valid_artifact()), encoding="utf-8") + return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(curate_service, "_PROJECT_ROOT", tmp_path) + monkeypatch.setattr(curate_service.subprocess, "run", fake_run) + + result = curate_service.run_model_training( + db_path=str(tmp_path / "cloudsea.db"), + output_path=str(output), + ) + + assert result["status"] == "candidate_ready" + assert result["deploy_recommended"] is True + assert result["holdout_balanced_accuracy"] == pytest.approx(0.7) + assert any(value.endswith("train_operational_model.py") for value in captured) + assert not any(value.endswith(".pkl") for value in captured) + + +def test_tracked_production_artifact_passes_strict_loader() -> None: + root = Path(__file__).resolve().parents[2] + artifact = load_safe_artifact( + root / "data" / "cloudsea" / "models" / "spot_wunvshan_dianjiangtai.json", + expected_spot_id="wunvshan", + expected_viewpoint_id="dianjiangtai", + ) + + assert artifact["n_days"] == 72 + assert artifact["evaluation"]["quality_gate"]["passed"] is True + + +def test_operational_model_falls_back_when_precursor_window_is_incomplete( + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = Path(__file__).resolve().parents[2] + artifact = load_safe_artifact( + root / "data" / "cloudsea" / "models" / "spot_wunvshan_dianjiangtai.json" + ) + rows = [ + { + "time": timestamp, + "cloud_low": 20.0, + "cloud_mid": 10.0, + "cloud_high": 30.0, + "visibility": 5000.0, + "rh": 85.0, + "wind": 2.0, + "temp": 10.0, + "dewpoint": 8.0, + "precipitation": 0.0, + "precip48": 0.0, + } + for timestamp in precursor_hour_keys("2026-07-03") + ] + monkeypatch.setattr(cloudsea_ml, "resolve_ml_artifact", lambda *_: artifact) + + complete = cloudsea_ml.predict_day_cloudsea( + rows, + spot_id="wunvshan", + viewpoint_id="dianjiangtai", + target_date="2026-07-03", + precursor_rows=rows, + ) + incomplete = cloudsea_ml.predict_day_cloudsea( + rows[:-1], + spot_id="wunvshan", + viewpoint_id="dianjiangtai", + target_date="2026-07-03", + precursor_rows=rows[:-1], + ) + + assert complete is not None + assert incomplete is None diff --git a/data/cloudsea/models/spot_wunvshan_dianjiangtai.json b/data/cloudsea/models/spot_wunvshan_dianjiangtai.json new file mode 100644 index 0000000..6c68d4c --- /dev/null +++ b/data/cloudsea/models/spot_wunvshan_dianjiangtai.json @@ -0,0 +1,159 @@ +{ + "algorithm": "logistic_regression", + "decision_threshold": 0.3300000000000002, + "evaluation": { + "baselines": { + "majority_balanced_accuracy": 0.5, + "majority_brier": 0.22345337026777462, + "rule_balanced_accuracy": 0.5, + "rule_brier": 0.25022666666666665, + "rule_f1": 0.42857142857142855 + }, + "holdout_days": 15, + "holdout_end": "2026-07-03", + "holdout_start": "2026-06-11", + "metrics": { + "accuracy": 0.5333333333333333, + "balanced_accuracy": 0.65, + "brier": 0.20854456441227573, + "f1": 0.5882352941176471, + "precision": 0.4166666666666667, + "recall": 1.0, + "roc_auc": 0.64, + "threshold": 0.3200000000000002 + }, + "negative_days": 10, + "positive_days": 5, + "production_blend_weight": 0.65, + "protocol": "chronological_holdout", + "quality_gate": { + "checks": { + "beats_majority": true, + "beats_rule": true, + "both_classes": true, + "time_ordered": true + }, + "passed": true, + "thresholds": { + "min_balanced_accuracy": 0.6, + "min_negative_days": 3, + "min_positive_days": 3, + "min_recall": 0.5 + } + }, + "raw_ml_metrics": { + "accuracy": 0.5333333333333333, + "balanced_accuracy": 0.6000000000000001, + "brier": 0.19783515220177092, + "f1": 0.5333333333333333, + "precision": 0.4, + "recall": 0.8, + "roc_auc": 0.6799999999999999, + "threshold": 0.3200000000000002 + }, + "review_threshold": 0.3200000000000002, + "rule_context": { + "elev_max_5km_m": 753.0, + "local_water": true, + "meteorology": "same_exact_single_run_surface_inputs", + "terrain_source": "terrain_snapshot", + "viewing_mode": "valley_fill" + }, + "train_end": "2026-06-10", + "train_start": "2024-10-05" + }, + "feature_names": [ + "cloud_low_mean", + "cloud_mid_mean", + "vis_min", + "rh_mean", + "wind_mean", + "precip48", + "evening_rh_mean", + "evening_cloud_low_mean", + "night_rh_mean", + "night_cloud_low_mean", + "delta_rh_night_to_dawn", + "delta_cloud_low_evening_to_dawn", + "doy_sin", + "doy_cos" + ], + "meteo_mode": "operational_single_run", + "model": { + "classes": [ + 0, + 1 + ], + "coef": [ + 0.21746055695468075, + -0.24247065324747102, + -0.2159425232714979, + -0.027782764408385154, + -0.052063977314698674, + -0.1417525106673263, + 0.009257176686700527, + -0.09131918314332389, + 0.0398516367596989, + -0.07296721765971086, + -0.11100539308969859, + 0.28647177769383664, + -0.030227545153171734, + 0.09903875617549447 + ], + "intercept": -1.8587533230482824, + "mean": [ + 45.63055555555556, + 23.063888888888886, + 5960.277777777777, + 92.07222222222222, + 4.571666666666667, + 2.6527777777777777, + 85.61805555555556, + 14.743055555555555, + 91.92129629629629, + 29.94907407407407, + 0.15092592592592632, + 30.887500000000003, + 0.24730612346195033, + -0.6583944858575965 + ], + "scale": [ + 42.201456790576714, + 35.04363670861562, + 7215.658507760555, + 8.213694902455122, + 3.6229772134101106, + 4.8389614422421445, + 10.795042282724161, + 27.492043426684226, + 9.296470366896186, + 38.4980222244214, + 5.393239859277373, + 40.798794704078645, + 0.6120405414676857, + 0.3616113354009741 + ] + }, + "n_days": 72, + "schema_version": 1, + "spot_id": "wunvshan", + "trained_at": "2026-07-28T03:49:25+00:00", + "training": { + "calibration": "platt_from_time_ordered_oof", + "calibration_c": 1.0, + "calibration_splits": 4, + "data_sha256": "932c83c7ced1e3e23837deb5c55b574d7a801e8aa552d0f2d67bbcd47c3ddb02", + "date_end": "2026-07-03", + "date_start": "2024-10-05", + "feature_policy": "predeclared_surface_v1", + "fixed_c": 0.1, + "model": "ecmwf_ifs", + "negative_days": 50, + "positive_days": 22, + "run_hour_utc": 0, + "source": "open_meteo_single_runs" + }, + "version": "cloudsea_logreg_operational_v1", + "viewpoint_id": "dianjiangtai", + "window": "v7" +} diff --git a/data/terrain/wunvshan__dianjiangtai.json b/data/terrain/wunvshan__dianjiangtai.json new file mode 100644 index 0000000..15b8f39 --- /dev/null +++ b/data/terrain/wunvshan__dianjiangtai.json @@ -0,0 +1,2640 @@ +{ + "version": "v1", + "spot_id": "wunvshan", + "viewpoint_id": "dianjiangtai", + "lat": 41.321319, + "lng": 125.424537, + "elevation": 804.0, + "base": { + "elev_open_meteo_m": 485.0, + "aspect_deg": 140.8, + "elev_min_5km_m": 242.0, + "elev_max_1km_m": 753.0, + "relief_5km_m": 511.0, + "source": "terrain_snapshot_baked", + "lng": 125.424537, + "elev_min_1km_m": 325.0, + "slope_deg": 19.4, + "sample_counts": { + "radius_1km": 29, + "radius_5km": 85 + }, + "dem_version": "v0_grid_sample", + "relief_1km_m": 428.0, + "lat": 41.321319, + "elev_viewpoint_m": 485.0, + "elev_max_5km_m": 753.0 + }, + "profiles": { + "60": [ + { + "distance_km": 0.0, + "azimuth_deg": 61.1, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 16.1, + "lat": 41.342894, + "lng": 125.432838, + "elev_m": 420.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 61.1, + "lat": 41.332166, + "lng": 125.45072, + "elev_m": 487.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 106.1, + "lat": 41.315085, + "lng": 125.453265, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 16.1, + "lat": 41.364469, + "lng": 125.44114, + "elev_m": 513.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 61.1, + "lat": 41.343014, + "lng": 125.476904, + "elev_m": 353.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 106.1, + "lat": 41.30885, + "lng": 125.481993, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 16.1, + "lat": 41.386044, + "lng": 125.449441, + "elev_m": 621.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 61.1, + "lat": 41.353861, + "lng": 125.503087, + "elev_m": 712.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 106.1, + "lat": 41.302616, + "lng": 125.51072, + "elev_m": 315.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 16.1, + "lat": 41.407619, + "lng": 125.457742, + "elev_m": 553.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 61.1, + "lat": 41.364709, + "lng": 125.529271, + "elev_m": 352.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 106.1, + "lat": 41.296381, + "lng": 125.539448, + "elev_m": 484.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 16.1, + "lat": 41.429194, + "lng": 125.466043, + "elev_m": 471.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 61.1, + "lat": 41.375556, + "lng": 125.555454, + "elev_m": 420.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 106.1, + "lat": 41.290147, + "lng": 125.568176, + "elev_m": 347.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 16.1, + "lat": 41.450769, + "lng": 125.474345, + "elev_m": 391.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 61.1, + "lat": 41.386404, + "lng": 125.581638, + "elev_m": 300.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 106.1, + "lat": 41.283913, + "lng": 125.596904, + "elev_m": 383.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 16.1, + "lat": 41.472345, + "lng": 125.482646, + "elev_m": 417.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 61.1, + "lat": 41.397251, + "lng": 125.607821, + "elev_m": 361.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 106.1, + "lat": 41.277678, + "lng": 125.625631, + "elev_m": 633.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 16.1, + "lat": 41.49392, + "lng": 125.490947, + "elev_m": 459.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 61.1, + "lat": 41.408099, + "lng": 125.634005, + "elev_m": 463.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 106.1, + "lat": 41.271444, + "lng": 125.654359, + "elev_m": 684.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 16.1, + "lat": 41.515495, + "lng": 125.499248, + "elev_m": 650.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 61.1, + "lat": 41.418946, + "lng": 125.660188, + "elev_m": 442.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 106.1, + "lat": 41.265209, + "lng": 125.683087, + "elev_m": 645.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 16.1, + "lat": 41.53707, + "lng": 125.50755, + "elev_m": 512.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 61.1, + "lat": 41.429794, + "lng": 125.686372, + "elev_m": 310.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 106.1, + "lat": 41.258975, + "lng": 125.711815, + "elev_m": 537.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 16.1, + "lat": 41.558645, + "lng": 125.515851, + "elev_m": 488.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 61.1, + "lat": 41.440641, + "lng": 125.712555, + "elev_m": 444.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 106.1, + "lat": 41.252741, + "lng": 125.740542, + "elev_m": 690.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 16.1, + "lat": 41.58022, + "lng": 125.524152, + "elev_m": 492.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 61.1, + "lat": 41.451489, + "lng": 125.738739, + "elev_m": 390.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 106.1, + "lat": 41.246506, + "lng": 125.76927, + "elev_m": 534.0 + } + ], + "70": [ + { + "distance_km": 0.0, + "azimuth_deg": 72.2, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 27.2, + "lat": 41.341297, + "lng": 125.438195, + "elev_m": 328.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 72.2, + "lat": 41.328193, + "lng": 125.453005, + "elev_m": 356.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 117.2, + "lat": 41.311061, + "lng": 125.451139, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 27.2, + "lat": 41.361276, + "lng": 125.451853, + "elev_m": 582.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 72.2, + "lat": 41.335066, + "lng": 125.481473, + "elev_m": 401.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 117.2, + "lat": 41.300804, + "lng": 125.47774, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 27.2, + "lat": 41.381254, + "lng": 125.465511, + "elev_m": 727.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 72.2, + "lat": 41.34194, + "lng": 125.509941, + "elev_m": 389.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 117.2, + "lat": 41.290546, + "lng": 125.504342, + "elev_m": 298.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 27.2, + "lat": 41.401232, + "lng": 125.47917, + "elev_m": 418.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 72.2, + "lat": 41.348814, + "lng": 125.538409, + "elev_m": 496.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 117.2, + "lat": 41.280289, + "lng": 125.530944, + "elev_m": 344.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 27.2, + "lat": 41.421211, + "lng": 125.492828, + "elev_m": 316.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 72.2, + "lat": 41.355687, + "lng": 125.566877, + "elev_m": 322.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 117.2, + "lat": 41.270031, + "lng": 125.557546, + "elev_m": 326.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 27.2, + "lat": 41.441189, + "lng": 125.506486, + "elev_m": 557.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 72.2, + "lat": 41.362561, + "lng": 125.595345, + "elev_m": 300.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 117.2, + "lat": 41.259774, + "lng": 125.584147, + "elev_m": 408.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 27.2, + "lat": 41.461168, + "lng": 125.520144, + "elev_m": 418.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 72.2, + "lat": 41.369435, + "lng": 125.623813, + "elev_m": 300.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 117.2, + "lat": 41.249516, + "lng": 125.610749, + "elev_m": 530.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 27.2, + "lat": 41.481146, + "lng": 125.533802, + "elev_m": 591.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 72.2, + "lat": 41.376309, + "lng": 125.652281, + "elev_m": 300.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 117.2, + "lat": 41.239259, + "lng": 125.637351, + "elev_m": 338.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 27.2, + "lat": 41.501124, + "lng": 125.54746, + "elev_m": 578.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 72.2, + "lat": 41.383182, + "lng": 125.680749, + "elev_m": 313.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 117.2, + "lat": 41.229001, + "lng": 125.663952, + "elev_m": 435.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 27.2, + "lat": 41.521103, + "lng": 125.561118, + "elev_m": 446.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 72.2, + "lat": 41.390056, + "lng": 125.709217, + "elev_m": 567.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 117.2, + "lat": 41.218744, + "lng": 125.690554, + "elev_m": 363.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 27.2, + "lat": 41.541081, + "lng": 125.574776, + "elev_m": 481.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 72.2, + "lat": 41.39693, + "lng": 125.737685, + "elev_m": 629.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 117.2, + "lat": 41.208486, + "lng": 125.717156, + "elev_m": 548.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 27.2, + "lat": 41.561059, + "lng": 125.588435, + "elev_m": 469.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 72.2, + "lat": 41.403803, + "lng": 125.766153, + "elev_m": 580.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 117.2, + "lat": 41.198229, + "lng": 125.743757, + "elev_m": 561.0 + } + ], + "85": [ + { + "distance_km": 0.0, + "azimuth_deg": 86.2, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 41.2, + "lat": 41.338205, + "lng": 125.444252, + "elev_m": 544.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 86.2, + "lat": 41.32279, + "lng": 125.454376, + "elev_m": 298.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 131.2, + "lat": 41.306513, + "lng": 125.447021, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 41.2, + "lat": 41.355091, + "lng": 125.463966, + "elev_m": 606.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 86.2, + "lat": 41.32426, + "lng": 125.484215, + "elev_m": 436.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 131.2, + "lat": 41.291707, + "lng": 125.469505, + "elev_m": 391.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 41.2, + "lat": 41.371977, + "lng": 125.483681, + "elev_m": 545.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 86.2, + "lat": 41.325731, + "lng": 125.514054, + "elev_m": 461.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 131.2, + "lat": 41.276901, + "lng": 125.491989, + "elev_m": 416.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 41.2, + "lat": 41.388862, + "lng": 125.503396, + "elev_m": 417.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 86.2, + "lat": 41.327201, + "lng": 125.543893, + "elev_m": 463.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 131.2, + "lat": 41.262095, + "lng": 125.514473, + "elev_m": 427.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 41.2, + "lat": 41.405748, + "lng": 125.52311, + "elev_m": 333.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 86.2, + "lat": 41.328672, + "lng": 125.573732, + "elev_m": 465.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 131.2, + "lat": 41.247289, + "lng": 125.536957, + "elev_m": 298.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 41.2, + "lat": 41.422634, + "lng": 125.542825, + "elev_m": 366.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 86.2, + "lat": 41.330143, + "lng": 125.60357, + "elev_m": 307.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 131.2, + "lat": 41.232482, + "lng": 125.55944, + "elev_m": 358.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 41.2, + "lat": 41.43952, + "lng": 125.56254, + "elev_m": 396.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 86.2, + "lat": 41.331613, + "lng": 125.633409, + "elev_m": 299.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 131.2, + "lat": 41.217676, + "lng": 125.581924, + "elev_m": 329.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 41.2, + "lat": 41.456406, + "lng": 125.582254, + "elev_m": 449.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 86.2, + "lat": 41.333084, + "lng": 125.663248, + "elev_m": 378.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 131.2, + "lat": 41.20287, + "lng": 125.604408, + "elev_m": 405.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 41.2, + "lat": 41.473292, + "lng": 125.601969, + "elev_m": 340.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 86.2, + "lat": 41.334554, + "lng": 125.693087, + "elev_m": 375.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 131.2, + "lat": 41.188064, + "lng": 125.626892, + "elev_m": 679.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 41.2, + "lat": 41.490177, + "lng": 125.621684, + "elev_m": 408.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 86.2, + "lat": 41.336025, + "lng": 125.722926, + "elev_m": 323.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 131.2, + "lat": 41.173258, + "lng": 125.649376, + "elev_m": 487.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 41.2, + "lat": 41.507063, + "lng": 125.641399, + "elev_m": 568.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 86.2, + "lat": 41.337496, + "lng": 125.752765, + "elev_m": 482.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 131.2, + "lat": 41.158452, + "lng": 125.67186, + "elev_m": 516.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 41.2, + "lat": 41.523949, + "lng": 125.661113, + "elev_m": 494.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 86.2, + "lat": 41.338966, + "lng": 125.782604, + "elev_m": 523.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 131.2, + "lat": 41.143646, + "lng": 125.694344, + "elev_m": 608.0 + } + ], + "105": [ + { + "distance_km": 0.0, + "azimuth_deg": 102.6, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 57.6, + "lat": 41.333359, + "lng": 125.44978, + "elev_m": 607.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 102.6, + "lat": 41.316427, + "lng": 125.453722, + "elev_m": 327.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 147.6, + "lat": 41.302361, + "lng": 125.440568, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 57.6, + "lat": 41.345399, + "lng": 125.475022, + "elev_m": 428.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 102.6, + "lat": 41.311536, + "lng": 125.482907, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 147.6, + "lat": 41.283404, + "lng": 125.4566, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 57.6, + "lat": 41.357439, + "lng": 125.500265, + "elev_m": 683.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 102.6, + "lat": 41.306644, + "lng": 125.512092, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 147.6, + "lat": 41.264446, + "lng": 125.472631, + "elev_m": 342.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 57.6, + "lat": 41.369478, + "lng": 125.525507, + "elev_m": 350.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 102.6, + "lat": 41.301753, + "lng": 125.541278, + "elev_m": 399.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 147.6, + "lat": 41.245488, + "lng": 125.488663, + "elev_m": 508.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 57.6, + "lat": 41.381518, + "lng": 125.55075, + "elev_m": 416.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 102.6, + "lat": 41.296861, + "lng": 125.570463, + "elev_m": 299.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 147.6, + "lat": 41.226531, + "lng": 125.504694, + "elev_m": 504.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 57.6, + "lat": 41.393558, + "lng": 125.575993, + "elev_m": 322.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 102.6, + "lat": 41.291969, + "lng": 125.599648, + "elev_m": 382.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 147.6, + "lat": 41.207573, + "lng": 125.520725, + "elev_m": 464.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 57.6, + "lat": 41.405598, + "lng": 125.601235, + "elev_m": 354.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 102.6, + "lat": 41.287078, + "lng": 125.628833, + "elev_m": 566.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 147.6, + "lat": 41.188615, + "lng": 125.536757, + "elev_m": 532.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 57.6, + "lat": 41.417638, + "lng": 125.626478, + "elev_m": 447.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 102.6, + "lat": 41.282186, + "lng": 125.658018, + "elev_m": 715.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 147.6, + "lat": 41.169658, + "lng": 125.552788, + "elev_m": 570.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 57.6, + "lat": 41.429678, + "lng": 125.651721, + "elev_m": 592.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 102.6, + "lat": 41.277294, + "lng": 125.687203, + "elev_m": 655.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 147.6, + "lat": 41.1507, + "lng": 125.56882, + "elev_m": 363.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 57.6, + "lat": 41.441718, + "lng": 125.676963, + "elev_m": 328.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 102.6, + "lat": 41.272403, + "lng": 125.716388, + "elev_m": 447.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 147.6, + "lat": 41.131742, + "lng": 125.584851, + "elev_m": 792.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 57.6, + "lat": 41.453758, + "lng": 125.702206, + "elev_m": 312.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 102.6, + "lat": 41.267511, + "lng": 125.745573, + "elev_m": 457.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 147.6, + "lat": 41.112785, + "lng": 125.600882, + "elev_m": 568.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 57.6, + "lat": 41.465797, + "lng": 125.727448, + "elev_m": 318.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 102.6, + "lat": 41.26262, + "lng": 125.774759, + "elev_m": 579.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 147.6, + "lat": 41.093827, + "lng": 125.616914, + "elev_m": 430.0 + } + ], + "115": [ + { + "distance_km": 0.0, + "azimuth_deg": 115.6, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 70.6, + "lat": 41.328791, + "lng": 125.452737, + "elev_m": 393.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 115.6, + "lat": 41.311627, + "lng": 125.451512, + "elev_m": 298.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 160.6, + "lat": 41.300141, + "lng": 125.434486, + "elev_m": 416.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 70.6, + "lat": 41.336263, + "lng": 125.480936, + "elev_m": 407.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 115.6, + "lat": 41.301935, + "lng": 125.478487, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 160.6, + "lat": 41.278962, + "lng": 125.444435, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 70.6, + "lat": 41.343734, + "lng": 125.509136, + "elev_m": 431.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 115.6, + "lat": 41.292243, + "lng": 125.505462, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 160.6, + "lat": 41.257784, + "lng": 125.454384, + "elev_m": 298.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 70.6, + "lat": 41.351206, + "lng": 125.537335, + "elev_m": 440.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 115.6, + "lat": 41.282551, + "lng": 125.532437, + "elev_m": 391.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 160.6, + "lat": 41.236605, + "lng": 125.464332, + "elev_m": 316.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 70.6, + "lat": 41.358678, + "lng": 125.565535, + "elev_m": 305.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 115.6, + "lat": 41.272859, + "lng": 125.559412, + "elev_m": 365.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 160.6, + "lat": 41.215427, + "lng": 125.474281, + "elev_m": 633.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 70.6, + "lat": 41.36615, + "lng": 125.593734, + "elev_m": 300.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 115.6, + "lat": 41.263167, + "lng": 125.586387, + "elev_m": 447.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 160.6, + "lat": 41.194249, + "lng": 125.48423, + "elev_m": 597.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 70.6, + "lat": 41.373621, + "lng": 125.621934, + "elev_m": 300.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 115.6, + "lat": 41.253475, + "lng": 125.613362, + "elev_m": 564.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 160.6, + "lat": 41.17307, + "lng": 125.494179, + "elev_m": 386.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 70.6, + "lat": 41.381093, + "lng": 125.650133, + "elev_m": 301.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 115.6, + "lat": 41.243783, + "lng": 125.640337, + "elev_m": 532.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 160.6, + "lat": 41.151892, + "lng": 125.504128, + "elev_m": 685.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 70.6, + "lat": 41.388565, + "lng": 125.678333, + "elev_m": 334.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 115.6, + "lat": 41.234091, + "lng": 125.667312, + "elev_m": 493.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 160.6, + "lat": 41.130713, + "lng": 125.514077, + "elev_m": 420.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 70.6, + "lat": 41.396037, + "lng": 125.706533, + "elev_m": 570.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 115.6, + "lat": 41.224399, + "lng": 125.694287, + "elev_m": 591.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 160.6, + "lat": 41.109535, + "lng": 125.524026, + "elev_m": 221.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 70.6, + "lat": 41.403509, + "lng": 125.734732, + "elev_m": 509.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 115.6, + "lat": 41.214707, + "lng": 125.721262, + "elev_m": 649.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 160.6, + "lat": 41.088357, + "lng": 125.533975, + "elev_m": 350.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 70.6, + "lat": 41.41098, + "lng": 125.762932, + "elev_m": 741.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 115.6, + "lat": 41.205015, + "lng": 125.748237, + "elev_m": 646.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 160.6, + "lat": 41.067178, + "lng": 125.543923, + "elev_m": 410.0 + } + ], + "120": [ + { + "distance_km": 0.0, + "azimuth_deg": 121.8, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 76.8, + "lat": 41.326446, + "lng": 125.45365, + "elev_m": 298.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 121.8, + "lat": 41.309484, + "lng": 125.449951, + "elev_m": 298.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 166.8, + "lat": 41.299454, + "lng": 125.431364, + "elev_m": 360.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 76.8, + "lat": 41.331574, + "lng": 125.482764, + "elev_m": 434.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 121.8, + "lat": 41.297649, + "lng": 125.475365, + "elev_m": 397.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 166.8, + "lat": 41.27759, + "lng": 125.438192, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 76.8, + "lat": 41.336701, + "lng": 125.511877, + "elev_m": 341.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 121.8, + "lat": 41.285814, + "lng": 125.500779, + "elev_m": 329.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 166.8, + "lat": 41.255725, + "lng": 125.445019, + "elev_m": 298.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 76.8, + "lat": 41.341829, + "lng": 125.54099, + "elev_m": 506.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 121.8, + "lat": 41.273979, + "lng": 125.526192, + "elev_m": 298.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 166.8, + "lat": 41.233861, + "lng": 125.451846, + "elev_m": 399.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 76.8, + "lat": 41.346956, + "lng": 125.570103, + "elev_m": 328.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 121.8, + "lat": 41.262144, + "lng": 125.551606, + "elev_m": 298.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 166.8, + "lat": 41.211996, + "lng": 125.458674, + "elev_m": 480.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 76.8, + "lat": 41.352084, + "lng": 125.599217, + "elev_m": 386.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 121.8, + "lat": 41.250309, + "lng": 125.57702, + "elev_m": 374.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 166.8, + "lat": 41.190131, + "lng": 125.465501, + "elev_m": 518.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 76.8, + "lat": 41.357211, + "lng": 125.62833, + "elev_m": 354.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 121.8, + "lat": 41.238474, + "lng": 125.602434, + "elev_m": 399.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 166.8, + "lat": 41.168267, + "lng": 125.472328, + "elev_m": 681.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 76.8, + "lat": 41.362339, + "lng": 125.657443, + "elev_m": 313.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 121.8, + "lat": 41.226639, + "lng": 125.627848, + "elev_m": 597.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 166.8, + "lat": 41.146402, + "lng": 125.479156, + "elev_m": 610.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 76.8, + "lat": 41.367466, + "lng": 125.686556, + "elev_m": 372.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 121.8, + "lat": 41.214805, + "lng": 125.653262, + "elev_m": 446.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 166.8, + "lat": 41.124538, + "lng": 125.485983, + "elev_m": 552.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 76.8, + "lat": 41.372594, + "lng": 125.71567, + "elev_m": 530.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 121.8, + "lat": 41.20297, + "lng": 125.678676, + "elev_m": 400.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 166.8, + "lat": 41.102673, + "lng": 125.492811, + "elev_m": 293.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 76.8, + "lat": 41.377721, + "lng": 125.744783, + "elev_m": 589.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 121.8, + "lat": 41.191135, + "lng": 125.704089, + "elev_m": 695.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 166.8, + "lat": 41.080808, + "lng": 125.499638, + "elev_m": 379.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 76.8, + "lat": 41.382849, + "lng": 125.773896, + "elev_m": 502.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 121.8, + "lat": 41.1793, + "lng": 125.729503, + "elev_m": 765.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 166.8, + "lat": 41.058944, + "lng": 125.506465, + "elev_m": 180.0 + } + ], + "110": [ + { + "distance_km": 0.0, + "azimuth_deg": 108.5, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 63.5, + "lat": 41.331341, + "lng": 125.451297, + "elev_m": 455.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 108.5, + "lat": 41.314194, + "lng": 125.452895, + "elev_m": 298.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 153.5, + "lat": 41.301221, + "lng": 125.437881, + "elev_m": 323.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 63.5, + "lat": 41.341363, + "lng": 125.478058, + "elev_m": 312.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 108.5, + "lat": 41.30707, + "lng": 125.481254, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 153.5, + "lat": 41.281124, + "lng": 125.451226, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 63.5, + "lat": 41.351385, + "lng": 125.504818, + "elev_m": 629.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 108.5, + "lat": 41.299945, + "lng": 125.509612, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 153.5, + "lat": 41.261026, + "lng": 125.46457, + "elev_m": 345.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 63.5, + "lat": 41.361407, + "lng": 125.531579, + "elev_m": 376.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 108.5, + "lat": 41.292821, + "lng": 125.537971, + "elev_m": 516.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 153.5, + "lat": 41.240929, + "lng": 125.477915, + "elev_m": 493.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 63.5, + "lat": 41.371429, + "lng": 125.558339, + "elev_m": 333.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 108.5, + "lat": 41.285696, + "lng": 125.566329, + "elev_m": 356.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 153.5, + "lat": 41.220831, + "lng": 125.491259, + "elev_m": 335.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 63.5, + "lat": 41.381451, + "lng": 125.5851, + "elev_m": 314.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 108.5, + "lat": 41.278572, + "lng": 125.594688, + "elev_m": 392.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 153.5, + "lat": 41.200734, + "lng": 125.504604, + "elev_m": 435.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 63.5, + "lat": 41.391473, + "lng": 125.61186, + "elev_m": 433.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 108.5, + "lat": 41.271447, + "lng": 125.623046, + "elev_m": 635.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 153.5, + "lat": 41.180636, + "lng": 125.517948, + "elev_m": 467.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 63.5, + "lat": 41.401494, + "lng": 125.63862, + "elev_m": 347.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 108.5, + "lat": 41.264323, + "lng": 125.651405, + "elev_m": 839.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 153.5, + "lat": 41.160538, + "lng": 125.531293, + "elev_m": 565.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 63.5, + "lat": 41.411516, + "lng": 125.665381, + "elev_m": 306.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 108.5, + "lat": 41.257198, + "lng": 125.679763, + "elev_m": 511.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 153.5, + "lat": 41.140441, + "lng": 125.544637, + "elev_m": 503.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 63.5, + "lat": 41.421538, + "lng": 125.692141, + "elev_m": 310.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 108.5, + "lat": 41.250073, + "lng": 125.708121, + "elev_m": 641.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 153.5, + "lat": 41.120343, + "lng": 125.557982, + "elev_m": 523.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 63.5, + "lat": 41.43156, + "lng": 125.718902, + "elev_m": 456.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 108.5, + "lat": 41.242949, + "lng": 125.73648, + "elev_m": 775.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 153.5, + "lat": 41.100246, + "lng": 125.571326, + "elev_m": 526.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 63.5, + "lat": 41.441582, + "lng": 125.745662, + "elev_m": 367.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 108.5, + "lat": 41.235824, + "lng": 125.764838, + "elev_m": 553.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 153.5, + "lat": 41.080148, + "lng": 125.58467, + "elev_m": 456.0 + } + ], + "95": [ + { + "distance_km": 0.0, + "azimuth_deg": 93.0, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 48.0, + "lat": 41.33636, + "lng": 125.446743, + "elev_m": 599.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 93.0, + "lat": 41.320162, + "lng": 125.4544, + "elev_m": 298.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 138.0, + "lat": 41.304642, + "lng": 125.444565, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 48.0, + "lat": 41.351401, + "lng": 125.468948, + "elev_m": 417.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 93.0, + "lat": 41.319006, + "lng": 125.484264, + "elev_m": 309.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 138.0, + "lat": 41.287965, + "lng": 125.464592, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 48.0, + "lat": 41.366442, + "lng": 125.491154, + "elev_m": 472.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 93.0, + "lat": 41.317849, + "lng": 125.514127, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 138.0, + "lat": 41.271288, + "lng": 125.48462, + "elev_m": 421.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 48.0, + "lat": 41.381483, + "lng": 125.51336, + "elev_m": 467.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 93.0, + "lat": 41.316692, + "lng": 125.543991, + "elev_m": 467.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 138.0, + "lat": 41.254611, + "lng": 125.504647, + "elev_m": 390.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 48.0, + "lat": 41.396524, + "lng": 125.535566, + "elev_m": 312.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 93.0, + "lat": 41.315535, + "lng": 125.573854, + "elev_m": 423.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 138.0, + "lat": 41.237935, + "lng": 125.524675, + "elev_m": 400.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 48.0, + "lat": 41.411565, + "lng": 125.557771, + "elev_m": 346.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 93.0, + "lat": 41.314379, + "lng": 125.603717, + "elev_m": 299.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 138.0, + "lat": 41.221258, + "lng": 125.544702, + "elev_m": 341.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 48.0, + "lat": 41.426606, + "lng": 125.579977, + "elev_m": 455.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 93.0, + "lat": 41.313222, + "lng": 125.633581, + "elev_m": 398.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 138.0, + "lat": 41.204581, + "lng": 125.56473, + "elev_m": 600.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 48.0, + "lat": 41.441647, + "lng": 125.602183, + "elev_m": 456.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 93.0, + "lat": 41.312065, + "lng": 125.663444, + "elev_m": 412.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 138.0, + "lat": 41.187904, + "lng": 125.584757, + "elev_m": 489.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 48.0, + "lat": 41.456688, + "lng": 125.624388, + "elev_m": 385.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 93.0, + "lat": 41.310909, + "lng": 125.693308, + "elev_m": 431.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 138.0, + "lat": 41.171227, + "lng": 125.604785, + "elev_m": 617.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 48.0, + "lat": 41.471729, + "lng": 125.646594, + "elev_m": 446.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 93.0, + "lat": 41.309752, + "lng": 125.723171, + "elev_m": 401.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 138.0, + "lat": 41.15455, + "lng": 125.624812, + "elev_m": 897.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 48.0, + "lat": 41.48677, + "lng": 125.6688, + "elev_m": 391.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 93.0, + "lat": 41.308595, + "lng": 125.753034, + "elev_m": 405.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 138.0, + "lat": 41.137873, + "lng": 125.64484, + "elev_m": 730.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 48.0, + "lat": 41.501812, + "lng": 125.691005, + "elev_m": 349.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 93.0, + "lat": 41.307438, + "lng": 125.782898, + "elev_m": 376.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 138.0, + "lat": 41.121196, + "lng": 125.664867, + "elev_m": 823.0 + } + ], + "75": [ + { + "distance_km": 0.0, + "azimuth_deg": 77.2, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 32.2, + "lat": 41.340329, + "lng": 125.440459, + "elev_m": 417.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 77.2, + "lat": 41.326306, + "lng": 125.453694, + "elev_m": 298.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 122.2, + "lat": 41.309361, + "lng": 125.449849, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 32.2, + "lat": 41.359338, + "lng": 125.456381, + "elev_m": 499.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 77.2, + "lat": 41.331292, + "lng": 125.48285, + "elev_m": 434.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 122.2, + "lat": 41.297404, + "lng": 125.475161, + "elev_m": 389.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 32.2, + "lat": 41.378348, + "lng": 125.472302, + "elev_m": 610.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 77.2, + "lat": 41.336279, + "lng": 125.512007, + "elev_m": 343.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 122.2, + "lat": 41.285446, + "lng": 125.500473, + "elev_m": 329.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 32.2, + "lat": 41.397358, + "lng": 125.488224, + "elev_m": 368.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 77.2, + "lat": 41.341265, + "lng": 125.541163, + "elev_m": 518.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 122.2, + "lat": 41.273489, + "lng": 125.525784, + "elev_m": 298.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 32.2, + "lat": 41.416367, + "lng": 125.504146, + "elev_m": 314.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 77.2, + "lat": 41.346252, + "lng": 125.57032, + "elev_m": 333.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 122.2, + "lat": 41.261531, + "lng": 125.551096, + "elev_m": 298.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 32.2, + "lat": 41.435377, + "lng": 125.520068, + "elev_m": 415.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 77.2, + "lat": 41.351239, + "lng": 125.599477, + "elev_m": 383.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 122.2, + "lat": 41.249574, + "lng": 125.576408, + "elev_m": 410.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 32.2, + "lat": 41.454387, + "lng": 125.53599, + "elev_m": 601.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 77.2, + "lat": 41.356225, + "lng": 125.628633, + "elev_m": 325.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 122.2, + "lat": 41.237616, + "lng": 125.60172, + "elev_m": 420.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 32.2, + "lat": 41.473396, + "lng": 125.551912, + "elev_m": 449.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 77.2, + "lat": 41.361212, + "lng": 125.65779, + "elev_m": 322.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 122.2, + "lat": 41.225658, + "lng": 125.627032, + "elev_m": 583.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 32.2, + "lat": 41.492406, + "lng": 125.567833, + "elev_m": 654.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 77.2, + "lat": 41.366198, + "lng": 125.686947, + "elev_m": 364.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 122.2, + "lat": 41.213701, + "lng": 125.652344, + "elev_m": 439.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 32.2, + "lat": 41.511416, + "lng": 125.583755, + "elev_m": 366.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 77.2, + "lat": 41.371185, + "lng": 125.716103, + "elev_m": 489.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 122.2, + "lat": 41.201743, + "lng": 125.677656, + "elev_m": 384.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 32.2, + "lat": 41.530426, + "lng": 125.599677, + "elev_m": 390.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 77.2, + "lat": 41.376172, + "lng": 125.74526, + "elev_m": 503.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 122.2, + "lat": 41.189786, + "lng": 125.702968, + "elev_m": 634.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 32.2, + "lat": 41.549435, + "lng": 125.615599, + "elev_m": 417.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 77.2, + "lat": 41.381158, + "lng": 125.774416, + "elev_m": 547.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 122.2, + "lat": 41.177828, + "lng": 125.728279, + "elev_m": 806.0 + } + ], + "65": [ + { + "distance_km": 0.0, + "azimuth_deg": 64.1, + "lat": 41.321319, + "lng": 125.424537, + "elev_m": 485.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 19.1, + "lat": 41.34254, + "lng": 125.434323, + "elev_m": 335.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 64.1, + "lat": 41.331127, + "lng": 125.451437, + "elev_m": 455.0 + }, + { + "distance_km": 2.5, + "azimuth_deg": 109.1, + "lat": 41.313969, + "lng": 125.452793, + "elev_m": 298.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 19.1, + "lat": 41.363761, + "lng": 125.44411, + "elev_m": 480.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 64.1, + "lat": 41.340936, + "lng": 125.478338, + "elev_m": 326.0 + }, + { + "distance_km": 5.0, + "azimuth_deg": 109.1, + "lat": 41.306619, + "lng": 125.48105, + "elev_m": 298.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 19.1, + "lat": 41.384982, + "lng": 125.453896, + "elev_m": 623.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 64.1, + "lat": 41.350744, + "lng": 125.505238, + "elev_m": 585.0 + }, + { + "distance_km": 7.5, + "azimuth_deg": 109.1, + "lat": 41.299269, + "lng": 125.509306, + "elev_m": 298.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 19.1, + "lat": 41.406203, + "lng": 125.463683, + "elev_m": 533.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 64.1, + "lat": 41.360553, + "lng": 125.532138, + "elev_m": 370.0 + }, + { + "distance_km": 10.0, + "azimuth_deg": 109.1, + "lat": 41.29192, + "lng": 125.537562, + "elev_m": 479.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 19.1, + "lat": 41.427424, + "lng": 125.473469, + "elev_m": 378.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 64.1, + "lat": 41.370361, + "lng": 125.559039, + "elev_m": 339.0 + }, + { + "distance_km": 12.5, + "azimuth_deg": 109.1, + "lat": 41.28457, + "lng": 125.565819, + "elev_m": 375.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 19.1, + "lat": 41.448645, + "lng": 125.483256, + "elev_m": 488.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 64.1, + "lat": 41.380169, + "lng": 125.585939, + "elev_m": 347.0 + }, + { + "distance_km": 15.0, + "azimuth_deg": 109.1, + "lat": 41.27722, + "lng": 125.594075, + "elev_m": 373.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 19.1, + "lat": 41.469866, + "lng": 125.493042, + "elev_m": 617.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 64.1, + "lat": 41.389978, + "lng": 125.612839, + "elev_m": 468.0 + }, + { + "distance_km": 17.5, + "azimuth_deg": 109.1, + "lat": 41.26987, + "lng": 125.622331, + "elev_m": 649.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 19.1, + "lat": 41.491087, + "lng": 125.502829, + "elev_m": 503.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 64.1, + "lat": 41.399786, + "lng": 125.63974, + "elev_m": 333.0 + }, + { + "distance_km": 20.0, + "azimuth_deg": 109.1, + "lat": 41.26252, + "lng": 125.650588, + "elev_m": 809.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 19.1, + "lat": 41.512308, + "lng": 125.512615, + "elev_m": 476.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 64.1, + "lat": 41.409595, + "lng": 125.66664, + "elev_m": 302.0 + }, + { + "distance_km": 22.5, + "azimuth_deg": 109.1, + "lat": 41.25517, + "lng": 125.678844, + "elev_m": 519.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 19.1, + "lat": 41.533529, + "lng": 125.522402, + "elev_m": 470.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 64.1, + "lat": 41.419403, + "lng": 125.69354, + "elev_m": 366.0 + }, + { + "distance_km": 25.0, + "azimuth_deg": 109.1, + "lat": 41.247821, + "lng": 125.7071, + "elev_m": 726.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 19.1, + "lat": 41.55475, + "lng": 125.532188, + "elev_m": 495.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 64.1, + "lat": 41.429211, + "lng": 125.720441, + "elev_m": 420.0 + }, + { + "distance_km": 27.5, + "azimuth_deg": 109.1, + "lat": 41.240471, + "lng": 125.735357, + "elev_m": 738.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 19.1, + "lat": 41.575971, + "lng": 125.541975, + "elev_m": 436.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 64.1, + "lat": 41.43902, + "lng": 125.747341, + "elev_m": 372.0 + }, + { + "distance_km": 30.0, + "azimuth_deg": 109.1, + "lat": 41.233121, + "lng": 125.763613, + "elev_m": 566.0 + } + ] + } +} \ No newline at end of file diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 630334b..279fd9b 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -34,7 +34,9 @@ services: - CLOUDSEA_ENABLED=true - CLOUDSEA_ADMIN_TOKEN=${CLOUDSEA_ADMIN_TOKEN:?set in server environment} - CLOUDSEA_DB_PATH=/app/data/cloudsea/cloudsea.db - - CLOUDSEA_ML_ENABLED=false + - CLOUDSEA_MODEL_PATH=/app/baked/models/spot_wunvshan_dianjiangtai.json + - CLOUDSEA_ML_ENABLED=true + - CLOUDSEA_ALLOW_LEGACY_PICKLE_MODELS=false - CLOUDSEA_CONTRIBUTE_ENABLED=true - CLOUDSEA_COMMUNITY_AUTO_APPROVE=false - CLOUDSEA_CURATE_MIN_LABELS=3 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6e404b5..3469c9f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -24,7 +24,9 @@ services: - ALLOWED_HOSTS=["yunhai.timkj.com","localhost","127.0.0.1"] - CLOUDSEA_ADMIN_TOKEN=${CLOUDSEA_ADMIN_TOKEN:?set CLOUDSEA_ADMIN_TOKEN} - CLOUDSEA_DB_PATH=/app/data/cloudsea/cloudsea.db - - CLOUDSEA_ML_ENABLED=false + - CLOUDSEA_MODEL_PATH=/app/baked/models/spot_wunvshan_dianjiangtai.json + - CLOUDSEA_ML_ENABLED=true + - CLOUDSEA_ALLOW_LEGACY_PICKLE_MODELS=false - CONTRIBUTOR_SESSION_SECRET=${CONTRIBUTOR_SESSION_SECRET:?set CONTRIBUTOR_SESSION_SECRET} - CONTRIBUTOR_COOKIE_SECURE=true volumes: diff --git a/docs/adr/0002-reviewed-operational-ml.md b/docs/adr/0002-reviewed-operational-ml.md new file mode 100644 index 0000000..ddac882 --- /dev/null +++ b/docs/adr/0002-reviewed-operational-ml.md @@ -0,0 +1,56 @@ +# ADR 0002: Reviewed operational point models + +- Status: Accepted +- Date: 2026-07-28 + +## Context + +The UI reported 73 training-eligible labels for Five-Woman Mountain, but production +correctly remained rule-only after the unsafe legacy Pickle models were disabled. +The former archive backfill used Open-Meteo Historical Forecast, then assigned an +arbitrary D-1 18:00 issue time. That stitched product does not prove which forecast +was available at the decision cutoff. The former LOOCV pipeline also selected +features, regularization, calibration, and thresholds using the same observations +reported as evaluation results. + +## Decision + +1. Reconstruct each label day from the ECMWF IFS HRES 00Z Single Run initialized on + D-1. This run precedes the D-1 18:00 China Standard Time decision cutoff. +2. Use only the 14 surface and precursor features consistently available in the + long-term archive. Do not impute missing pressure levels into the reviewed model. +3. Reserve the last 15 usable dates as an untouched chronological holdout. + Hyperparameters are fixed. Platt calibration and the decision threshold use only + time-ordered out-of-fold predictions inside the training period. +4. Compare the production ML/rule blend against both a majority prior and the rule + engine using the same archived surface rows, baked DEM/viewing mode, and curated + local-water context. Activation requires all explicit quality checks to pass. +5. Serialize only finite scaler and logistic coefficients to strict JSON. The loader + verifies schema, point identity, features, source, protocol, metrics, and gate + evidence. Legacy Pickle loading remains disabled. +6. Bake reviewed models into the read-only image. Admin may create a candidate in + the data volume, but cannot hot-replace the active model. + +## Evidence + +The reviewed Five-Woman Mountain model uses 72 reconstructable dates (22 positive, +50 negative). The chronological holdout covers 2026-06-11 through 2026-07-03 with +5 positive and 10 negative dates. The rule baseline used the baked point terrain +snapshot (`valley_fill`, 5 km maximum elevation 753 m) and the curated Huanlong +Lake context. + +| Metric | Reviewed blend | Rule baseline | Majority prior | +| --- | ---: | ---: | ---: | +| Balanced accuracy | 0.65 | 0.50 | 0.50 | +| Recall | 1.00 | 0.60 | 0.00 | +| F1 | 0.588 | 0.429 | 0.00 | +| Brier | 0.209 | 0.250 | 0.223 | + +## Consequences + +- Five-Woman Mountain can safely activate ML while other points remain rule-only + until their own exact-run data and time holdout pass the same gate. +- Historical headline LOOCV values are retained only as historical context and are + not used to justify production activation. +- A new candidate requires an image release and rollback point; online model hot + patches are intentionally unsupported. diff --git a/docs/docs/architecture.html b/docs/docs/architecture.html index ea4c65a..805dfd1 100644 --- a/docs/docs/architecture.html +++ b/docs/docs/architecture.html @@ -94,11 +94,11 @@

预测流水线

标注 · ML · 预测反馈闭环

label.html 人工标注
     ↓
-cloudsea.db (SQLite 持久化卷)
+cloudsea.db 副本 + 精确 ECMWF Single Runs
     ↓
-backfill_meteo_hourly → train_cloudsea_model (--window v7)
+backfill_forecast_archive → train_operational_model
     ↓
-spot 模型 pkl → Docker 镜像 / 热补丁
+时间外门禁 + 安全 JSON → 受审不可变 Docker 镜像
     ↓
 线上 03–07 融合预测
     ↓
diff --git a/docs/docs/cloudsea-forecast.html b/docs/docs/cloudsea-forecast.html
index 8e336ce..9d54c3d 100644
--- a/docs/docs/cloudsea-forecast.html
+++ b/docs/docs/cloudsea-forecast.html
@@ -59,7 +59,7 @@ 

核心判断因子

规则引擎 + ML v7 如何协作

-

本站采用混合预测:规则引擎解释气象机理并做能见度/型态补偿;ML v7 根据历史标注与前夜 precursor 过程(D-1 20:00 → 日出)修正本点位偏差。日出窗口内按点位融合(如五女山 ML 约 65%);近库水体雾、低能见度谷地雾时降低 ML 权重。五女山 LOOCV 85.5%(较 v6 +4 pp)。

+

本站采用混合预测:规则引擎解释气象机理并做能见度/型态补偿;受审 ML 根据历史标注与前夜 precursor 过程(D-1 20:00 → 日出)修正本点位偏差。五女山日出窗口约 65% ML / 35% 规则;近库水体雾、低能见度谷地雾时降低 ML 权重。当前模型最后 15 天时间外平衡准确率 65%(规则基线 50%)。

页面同时展示 Meteogram 气象详图、因子雷达图与底层观测,便于核对「为什么是这个概率」。

延伸阅读:预测模型说明 · 2026-06 更新 · 五女山云海预报 · 东灵山云海预报

diff --git a/docs/docs/data-sources.html b/docs/docs/data-sources.html index 7da8582..819b50c 100644 --- a/docs/docs/data-sources.html +++ b/docs/docs/data-sources.html @@ -64,10 +64,10 @@

数据源一览

- 历史预报(回测) - Open-Meteo Historical Forecast - 标注日回测、ML 训练气象回填 - 按日请求;缓存 7 天 + 历史单次运行(ML 审查) + Open-Meteo Single Runs · ECMWF IFS HRES 00Z + 重建决策截点前可见的预报,用于受审模型训练与时间外评估 + 生成候选时按标注日回填 ML 标注样本 diff --git a/docs/docs/index.html b/docs/docs/index.html index 788a075..faf84e7 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -62,7 +62,7 @@

云海 · 日出观测概率预测

- 2026-06 更新:线上 ML 已升级至 v7(precursor 12h · 62 维特征)。五女山 LOOCV 85.5%(较 v6 +4 pp)。详见 更新说明。 + 2026-07 更新:五女山上线可审计 operational ML(精确 ECMWF 00Z 历史运行 · 14 个稳定特征 · 安全 JSON)。最后 15 天时间外平衡准确率 65%,规则基线 50%。详见 更新说明
@@ -104,7 +104,7 @@

Admin 审核(维护者)

@@ -138,7 +138,7 @@

界面说明

云海概率

-

日出窗口 03:00–07:00 由 规则引擎 + ML v7 按点位权重融合(五女山约 65% ML / 35% 规则;precursor 12h 特征;近库水体雾场景会降 ML 权重)。详见 预测模型 · 更新说明

+

日出窗口 03:00–07:00 在五女山由 规则引擎 + 受审 ML 按点位权重融合(约 65% ML / 35% 规则;precursor 12h 特征;近库水体雾场景会降 ML 权重);其他点位未通过门禁时仅使用规则。详见 预测模型 · 更新说明

日出概率

diff --git a/docs/docs/prediction-model.html b/docs/docs/prediction-model.html index 4073b19..874190a 100644 --- a/docs/docs/prediction-model.html +++ b/docs/docs/prediction-model.html @@ -4,7 +4,7 @@ 预测模型 · 云海日出预测 - +