Deploy 2026-05-20T07:09:24Z — 11e81c5 (code)
Browse files- .dockerignore +29 -0
- .gitignore +75 -0
- .pre-commit-config.yaml +22 -0
- CHANGELOG.md +139 -0
- CONTRIBUTING.md +63 -0
- Dockerfile +60 -0
- LICENSE +28 -0
- Makefile +78 -0
- README.md +272 -5
- backend/__init__.py +3 -0
- backend/cache.py +190 -0
- backend/config.py +208 -0
- backend/errors.py +26 -0
- backend/main.py +455 -0
- backend/ml_engine.py +126 -0
- backend/rule_engine.py +480 -0
- backend/schemas.py +72 -0
- backend/terrain.py +151 -0
- docker-compose.yml +26 -0
- docs/DEPLOY_HF.md +143 -0
- docs/MEETING_CHEAT_SHEET.html +644 -0
- docs/MEETING_CHEAT_SHEET.md +372 -0
- docs/architecture.md +116 -0
- docs/dataset.md +111 -0
- docs/pipeline_order.md +109 -0
- docs/progress_update_brief.html +619 -0
- docs/progress_update_brief.md +235 -0
- docs/supervisor_meeting_brief.md +161 -0
- docs/thresholds.md +150 -0
- docs/项目大白话讲解.html +883 -0
- docs/项目大白话讲解.md +383 -0
- frontend/index.html +579 -0
- models/.gitkeep +0 -0
- models/MODEL_CARD.md +133 -0
- models/feature_columns.json +20 -0
- models/training_report.json +82 -0
- pyproject.toml +45 -0
- requirements-dev.txt +14 -0
- requirements.txt +17 -0
- scripts/1_download_dataset.py +138 -0
- scripts/1b_synth_dataset.py +168 -0
- scripts/2_preprocess.py +160 -0
- scripts/3_train_model.py +183 -0
- scripts/4_evaluate_model.py +272 -0
- scripts/deploy_hf.sh +135 -0
- scripts/start_demo.sh +86 -0
.dockerignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git/
|
| 2 |
+
.github/
|
| 3 |
+
.venv/
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
| 7 |
+
*.pyo
|
| 8 |
+
|
| 9 |
+
# Local databases — must NOT bake into the image
|
| 10 |
+
cache.sqlite3
|
| 11 |
+
cache.sqlite3-*
|
| 12 |
+
*.sqlite
|
| 13 |
+
*.sqlite3*
|
| 14 |
+
|
| 15 |
+
# Large dataset artefacts — re-download or regenerate inside the image
|
| 16 |
+
data/
|
| 17 |
+
figures/
|
| 18 |
+
|
| 19 |
+
# Dev / IDE
|
| 20 |
+
.idea/
|
| 21 |
+
.vscode/
|
| 22 |
+
*.iml
|
| 23 |
+
.DS_Store
|
| 24 |
+
|
| 25 |
+
# Docs not needed at runtime
|
| 26 |
+
docs/
|
| 27 |
+
*.docx
|
| 28 |
+
*.md
|
| 29 |
+
!README.md
|
.gitignore
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
env/
|
| 10 |
+
ENV/
|
| 11 |
+
.pytest_cache/
|
| 12 |
+
.mypy_cache/
|
| 13 |
+
.ruff_cache/
|
| 14 |
+
*.egg-info/
|
| 15 |
+
build/
|
| 16 |
+
dist/
|
| 17 |
+
|
| 18 |
+
# Data & Models — big binaries stay local
|
| 19 |
+
data/*.csv
|
| 20 |
+
data/*.parquet
|
| 21 |
+
models/*.pkl
|
| 22 |
+
models/*.joblib
|
| 23 |
+
models/*.onnx
|
| 24 |
+
*.db
|
| 25 |
+
*.sqlite
|
| 26 |
+
*.sqlite3
|
| 27 |
+
*.sqlite3-shm
|
| 28 |
+
*.sqlite3-wal
|
| 29 |
+
|
| 30 |
+
# Coverage + test artefacts
|
| 31 |
+
.coverage
|
| 32 |
+
coverage.xml
|
| 33 |
+
htmlcov/
|
| 34 |
+
|
| 35 |
+
# Generated figures — re-create with `make evaluate`
|
| 36 |
+
# (we keep evaluation_summary.json + threshold_sweep.csv as audit trail)
|
| 37 |
+
figures/*.png
|
| 38 |
+
!figures/.gitkeep
|
| 39 |
+
*.db-journal
|
| 40 |
+
*.db-wal
|
| 41 |
+
*.db-shm
|
| 42 |
+
# …but keep small JSON artefacts that document the training run.
|
| 43 |
+
!models/training_report.json
|
| 44 |
+
!models/feature_columns.json
|
| 45 |
+
|
| 46 |
+
# Notebooks
|
| 47 |
+
.ipynb_checkpoints/
|
| 48 |
+
*.ipynb
|
| 49 |
+
|
| 50 |
+
# IDE
|
| 51 |
+
.vscode/
|
| 52 |
+
.idea/
|
| 53 |
+
*.swp
|
| 54 |
+
*.swo
|
| 55 |
+
.DS_Store
|
| 56 |
+
Thumbs.db
|
| 57 |
+
|
| 58 |
+
# OS
|
| 59 |
+
.DS_Store?
|
| 60 |
+
._*
|
| 61 |
+
.Spotlight-V100
|
| 62 |
+
.Trashes
|
| 63 |
+
ehthumbs.db
|
| 64 |
+
|
| 65 |
+
# Logs
|
| 66 |
+
*.log
|
| 67 |
+
logs/
|
| 68 |
+
|
| 69 |
+
# Env
|
| 70 |
+
.env
|
| 71 |
+
.env.local
|
| 72 |
+
|
| 73 |
+
# Keep directory placeholders
|
| 74 |
+
!data/.gitkeep
|
| 75 |
+
!models/.gitkeep
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pre-commit hooks — see https://pre-commit.com/
|
| 2 |
+
# Install: pip install pre-commit && pre-commit install
|
| 3 |
+
|
| 4 |
+
repos:
|
| 5 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 6 |
+
rev: v5.0.0
|
| 7 |
+
hooks:
|
| 8 |
+
- id: trailing-whitespace
|
| 9 |
+
- id: end-of-file-fixer
|
| 10 |
+
- id: check-yaml
|
| 11 |
+
- id: check-json
|
| 12 |
+
- id: check-toml
|
| 13 |
+
- id: check-merge-conflict
|
| 14 |
+
- id: check-added-large-files
|
| 15 |
+
args: [--maxkb=2048]
|
| 16 |
+
|
| 17 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 18 |
+
rev: v0.6.9
|
| 19 |
+
hooks:
|
| 20 |
+
- id: ruff
|
| 21 |
+
args: [--fix]
|
| 22 |
+
- id: ruff-format
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to this project will be documented in this file.
|
| 4 |
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
| 5 |
+
versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## [1.0.0] — 2026-05-11
|
| 10 |
+
|
| 11 |
+
The first complete release. Engineering-grade hardening across backend,
|
| 12 |
+
ML pipeline, frontend, and DevOps; the rule engine is fully aligned with
|
| 13 |
+
the D5 thesis proposal §3.7 / P4.
|
| 14 |
+
|
| 15 |
+
### Added — Backend
|
| 16 |
+
|
| 17 |
+
- **Request-ID middleware** that stamps every response with `X-Request-ID`
|
| 18 |
+
and `X-Response-Time-ms`. Incoming `X-Request-ID` headers propagate end
|
| 19 |
+
to end, enabling cross-service tracing.
|
| 20 |
+
- **Centralised error contract** (`backend/errors.py`) — every non-2xx
|
| 21 |
+
response is a typed `ErrorResponse { error, detail, request_id, context }`
|
| 22 |
+
JSON document; no bare 500-HTML responses leak.
|
| 23 |
+
- **Structured logging** with per-request log records (`request_id` field
|
| 24 |
+
on every line, ISO-8601 timestamps).
|
| 25 |
+
- **Enriched `/api/health`** reporting uptime, cache row counts (live /
|
| 26 |
+
expired / total), DB size, and inference-log size.
|
| 27 |
+
- **`/api/version`** endpoint returning version + git short SHA + ML
|
| 28 |
+
feature schema.
|
| 29 |
+
- **Cache hygiene** — `prune_expired()` runs on startup, sweeps inference-log
|
| 30 |
+
rows older than 7 days, and `cache_stats()` is exposed via `/api/health`.
|
| 31 |
+
- **Fire-and-forget cache writes** with the task reference retained
|
| 32 |
+
(`asyncio.create_task` lint compliance).
|
| 33 |
+
- **Defensive ML engine** — `predict_rain_probability` always returns
|
| 34 |
+
`float ∈ [0, 1]`; NaN/Inf/wrong-type feature values gracefully degrade;
|
| 35 |
+
model-load failures fall through to the heuristic instead of crashing.
|
| 36 |
+
- **Improved heuristic fallback** — now also responds to
|
| 37 |
+
`pressure_change_3h` so the "no model yet" demo still behaves sensibly.
|
| 38 |
+
- **Terrain edge cases** — antimeridian wrap, polar clamp, ocean / no-data
|
| 39 |
+
DEM cells handled instead of raising obscure type errors.
|
| 40 |
+
|
| 41 |
+
### Added — Rule Engine (already shipped, now fully tested)
|
| 42 |
+
|
| 43 |
+
- 4 sub-hazard scorers — rainfall / fog / wind gust / thunderstorm.
|
| 44 |
+
- D5 §3.7.2 R1-R4 Decision Table.
|
| 45 |
+
- Activity-aware weighted composite (`hiker | driver | construction | general`).
|
| 46 |
+
- Dominant-hazard composite formula: `0.80·max + 0.20·mean(rest)`.
|
| 47 |
+
|
| 48 |
+
### Added — ML pipeline
|
| 49 |
+
|
| 50 |
+
- **`scripts/4_evaluate_model.py`** generating publication-quality figures
|
| 51 |
+
(ROC + AUC, PR + AP, calibration / Brier, threshold sweep, top-20
|
| 52 |
+
feature importance, confusion matrix at F2-optimal threshold).
|
| 53 |
+
- **`figures/evaluation_summary.json`** machine-readable evaluation blob
|
| 54 |
+
for the thesis appendix.
|
| 55 |
+
- **`figures/threshold_sweep.csv`** for full reproducibility of the
|
| 56 |
+
precision-recall trade-off table.
|
| 57 |
+
- **`models/MODEL_CARD.md`** — HuggingFace-style model card with intended
|
| 58 |
+
use, training data, evaluation, limitations, and ethical considerations.
|
| 59 |
+
|
| 60 |
+
### Added — Tests
|
| 61 |
+
|
| 62 |
+
- HTTP integration tests with `respx`-mocked external APIs
|
| 63 |
+
(`tests/test_api.py`): happy path, cache hit, distinct cache slot per
|
| 64 |
+
activity, invalid input → 422, upstream failure → 502, CORS, OpenAPI
|
| 65 |
+
schema.
|
| 66 |
+
- Cache layer tests (`tests/test_cache.py`): TTL, expiry, prune, stats.
|
| 67 |
+
- Terrain edge-case tests (`tests/test_terrain_edge.py`): antimeridian,
|
| 68 |
+
polar clamp, malformed DEM.
|
| 69 |
+
- ML engine tests (`tests/test_ml_engine.py`): unloaded behaviour,
|
| 70 |
+
heuristic monotonicity, NaN/None resilience.
|
| 71 |
+
- Session-scoped `conftest.py` sets an isolated `MICROCLIMATEX_DB` for
|
| 72 |
+
every test run (no clobbering the dev cache).
|
| 73 |
+
- **Total: 70 tests; backend coverage 97 %.**
|
| 74 |
+
|
| 75 |
+
### Added — Frontend
|
| 76 |
+
|
| 77 |
+
- Activity selector (Hiker / Driver / Construction / General) with
|
| 78 |
+
`localStorage` persistence and keyboard accessibility (`aria-pressed`
|
| 79 |
+
+ `focus-visible`).
|
| 80 |
+
- 4 mini-gauges for the per-hazard sub-scores, each with a tooltip
|
| 81 |
+
explaining what drives it.
|
| 82 |
+
- D5 §3.7.2 R1-R4 indicator badges (highlight when fired).
|
| 83 |
+
- Demo scenarios dropdown (Genting · Cameron · Kinabalu · Everest · Singapore).
|
| 84 |
+
- **Loading spinner** during in-flight requests.
|
| 85 |
+
- **Toast notification** for errors and "no model loaded" warnings.
|
| 86 |
+
- **Map layer switcher** — Dark base + Topographic option.
|
| 87 |
+
- Bilingual EN/ZH UI persisted across reloads.
|
| 88 |
+
|
| 89 |
+
### Added — DevOps / Reproducibility
|
| 90 |
+
|
| 91 |
+
- **GitHub Actions CI** (`.github/workflows/ci.yml`) — pytest matrix on
|
| 92 |
+
Python 3.9 / 3.11 / 3.12, ruff lint, coverage XML artefact, plus a
|
| 93 |
+
Docker image-build smoke test with Buildx + GHA cache.
|
| 94 |
+
- **Multi-stage Dockerfile** — builder stage for wheels, slim runtime
|
| 95 |
+
with a non-root `mcx` user, baked-in HEALTHCHECK against `/api/health`.
|
| 96 |
+
- **`docker-compose.yml`** with a named data volume.
|
| 97 |
+
- **`Makefile`** — single-word recipes for `install`, `test`, `lint`,
|
| 98 |
+
`run`, `synth`, `preprocess`, `train`, `evaluate`, `docker`, `clean`.
|
| 99 |
+
- **`requirements-dev.txt`** — split dev tooling (pytest-cov, ruff,
|
| 100 |
+
respx, matplotlib) from runtime requirements.
|
| 101 |
+
- **`pyproject.toml`** — ruff configuration + pytest config.
|
| 102 |
+
- **`.pre-commit-config.yaml`** — trailing-whitespace, end-of-file,
|
| 103 |
+
YAML/JSON/TOML checks, large-file guard, ruff lint + format.
|
| 104 |
+
- **`.dockerignore`** keeping the image lean.
|
| 105 |
+
|
| 106 |
+
### Added — Documentation
|
| 107 |
+
|
| 108 |
+
- `docs/architecture.md` — P4.1 → P4.6 internal flow + dominant-hazard
|
| 109 |
+
formula rationale.
|
| 110 |
+
- `docs/thresholds.md` — every threshold cited; new §8-§12 for the four
|
| 111 |
+
hazard categories, R1-R4 table, and activity-weight matrix.
|
| 112 |
+
- `docs/dataset.md` — formal target definition (`is_rain_event`) and
|
| 113 |
+
train/test split rationale.
|
| 114 |
+
|
| 115 |
+
### Changed
|
| 116 |
+
|
| 117 |
+
- Rainfall sub-scorer calibration — 45 % macro probability now lands at
|
| 118 |
+
~ 40 (Caution band), matching the proposal's intent.
|
| 119 |
+
- Composite-score formula switched from naive arithmetic mean to
|
| 120 |
+
**dominant-hazard + secondary** to avoid mean dilution.
|
| 121 |
+
- Cache key now incorporates `activity` — different weights → different
|
| 122 |
+
composite → must not share a slot.
|
| 123 |
+
|
| 124 |
+
### Fixed
|
| 125 |
+
|
| 126 |
+
- `tenacity.RetryError` from the retry decorator was not caught by the
|
| 127 |
+
`except httpx.HTTPError` clause, producing a misleading 500. Now caught
|
| 128 |
+
alongside `httpx.HTTPError` and `ValueError`, returning a clean 502.
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## [0.2.0] — 2026-05-11
|
| 133 |
+
|
| 134 |
+
Initial D5-alignment pass — see commit `55fd759`.
|
| 135 |
+
|
| 136 |
+
## [0.1.0] — 2026-05-11
|
| 137 |
+
|
| 138 |
+
Project scaffolding and Hybrid Engine v1 — see commits `b218f5b`
|
| 139 |
+
through `4639890`.
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to MicroClimate-X
|
| 2 |
+
|
| 3 |
+
Thanks for your interest! This is a Final Year Project (UKM) and we
|
| 4 |
+
welcome both academic feedback and code contributions.
|
| 5 |
+
|
| 6 |
+
## Quick setup
|
| 7 |
+
|
| 8 |
+
```bash
|
| 9 |
+
git clone https://github.com/KyoukoLi/microclimate-x
|
| 10 |
+
cd microclimate-x
|
| 11 |
+
make install-dev # creates ./.venv, installs runtime + dev deps
|
| 12 |
+
make test # runs the full suite; should be 70+ passes
|
| 13 |
+
make lint # ruff check
|
| 14 |
+
make run # uvicorn dev server on http://localhost:8000
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
The full developer toolbox is in the [Makefile](./Makefile) — `make help`
|
| 18 |
+
lists every target.
|
| 19 |
+
|
| 20 |
+
## Project rhythm
|
| 21 |
+
|
| 22 |
+
| Layer | Source of truth |
|
| 23 |
+
|---|---|
|
| 24 |
+
| Engineering thresholds & academic citations | `backend/config.py` + `docs/thresholds.md` |
|
| 25 |
+
| Hybrid engine flow & section mapping | `backend/rule_engine.py` + `docs/architecture.md` |
|
| 26 |
+
| ML pipeline (features ↔ training ↔ evaluation) | `scripts/2_preprocess.py` ↔ `scripts/3_train_model.py` ↔ `scripts/4_evaluate_model.py` |
|
| 27 |
+
| Frontend contract | `backend/schemas.py` (Pydantic) is consumed verbatim by `frontend/index.html` |
|
| 28 |
+
|
| 29 |
+
If you change something in one column, please update the corresponding
|
| 30 |
+
artefact in the same column.
|
| 31 |
+
|
| 32 |
+
## Pull-request checklist
|
| 33 |
+
|
| 34 |
+
1. **All tests pass**: `make test` — 70 / 70.
|
| 35 |
+
2. **Linter is clean**: `make lint` — 0 ruff errors.
|
| 36 |
+
3. **New behaviour is tested.** Add a unit test or an HTTP integration
|
| 37 |
+
test that fails *without* your change.
|
| 38 |
+
4. **Public APIs documented.** Update `docs/` and the OpenAPI docstrings
|
| 39 |
+
if you change request / response shapes.
|
| 40 |
+
5. **Thresholds are cited.** Any new numeric threshold in `config.py`
|
| 41 |
+
needs an `# Citation:` block referencing peer-reviewed literature or
|
| 42 |
+
an authoritative regulation.
|
| 43 |
+
6. **No secrets, no large binaries.** Pre-commit hooks (`make install-dev`
|
| 44 |
+
then `pre-commit install`) enforce both.
|
| 45 |
+
|
| 46 |
+
## Safety-critical code review
|
| 47 |
+
|
| 48 |
+
This is decision-support software for outdoor activity. Reviewers should
|
| 49 |
+
specifically check:
|
| 50 |
+
|
| 51 |
+
* **Does this change weaken the Veto cascade?** If a behavioural change
|
| 52 |
+
could let a "Safe" verdict fire in a situation that previously fired
|
| 53 |
+
Danger, the PR needs an explicit test demonstrating the new threshold
|
| 54 |
+
is still life-safety-compliant.
|
| 55 |
+
* **Does this change leak temporal autocorrelation?** Random train/test
|
| 56 |
+
splits on time-series data are *forbidden*; always use the time-based
|
| 57 |
+
split in `scripts/3_train_model.py`.
|
| 58 |
+
|
| 59 |
+
## Reporting issues
|
| 60 |
+
|
| 61 |
+
Bugs, academic critique, or threshold disputes — please open an issue
|
| 62 |
+
with the **scenario**, the **expected verdict**, and the **observed
|
| 63 |
+
verdict**. Citations to the relevant safety literature are very welcome.
|
Dockerfile
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
|
| 3 |
+
# ─────────────────────────────────────────────────────────────────
|
| 4 |
+
# Stage 1 — builder: install Python deps into a self-contained venv
|
| 5 |
+
# ─────────────────────────────────────────────────────────────────
|
| 6 |
+
FROM python:3.12-slim AS builder
|
| 7 |
+
|
| 8 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 9 |
+
PYTHONUNBUFFERED=1 \
|
| 10 |
+
PIP_NO_CACHE_DIR=1 \
|
| 11 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1
|
| 12 |
+
|
| 13 |
+
WORKDIR /build
|
| 14 |
+
|
| 15 |
+
# Build deps for any C-extension wheels that need compilation
|
| 16 |
+
# (scikit-learn / numpy ship wheels for linux/amd64+arm64 so this is usually a no-op).
|
| 17 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 18 |
+
build-essential \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
COPY requirements.txt .
|
| 22 |
+
RUN python -m venv /opt/venv \
|
| 23 |
+
&& /opt/venv/bin/pip install --upgrade pip \
|
| 24 |
+
&& /opt/venv/bin/pip install -r requirements.txt
|
| 25 |
+
|
| 26 |
+
# ─────────────────────────────────────────────────────────────────
|
| 27 |
+
# Stage 2 — runtime: minimal image with only the venv + app code
|
| 28 |
+
# ─────────────────────────────────────────────────────────────────
|
| 29 |
+
FROM python:3.12-slim AS runtime
|
| 30 |
+
|
| 31 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 32 |
+
PYTHONUNBUFFERED=1 \
|
| 33 |
+
PATH="/opt/venv/bin:$PATH" \
|
| 34 |
+
MICROCLIMATEX_DB=/tmp/cache.sqlite3
|
| 35 |
+
|
| 36 |
+
# Non-root user for least-privilege execution.
|
| 37 |
+
RUN useradd --create-home --shell /bin/bash --uid 10001 mcx \
|
| 38 |
+
&& mkdir -p /app /data \
|
| 39 |
+
&& chown -R mcx:mcx /app /data
|
| 40 |
+
|
| 41 |
+
COPY --from=builder /opt/venv /opt/venv
|
| 42 |
+
|
| 43 |
+
WORKDIR /app
|
| 44 |
+
COPY --chown=mcx:mcx backend/ backend/
|
| 45 |
+
COPY --chown=mcx:mcx frontend/ frontend/
|
| 46 |
+
COPY --chown=mcx:mcx scripts/ scripts/
|
| 47 |
+
COPY --chown=mcx:mcx models/ models/
|
| 48 |
+
COPY --chown=mcx:mcx README.md LICENSE ./
|
| 49 |
+
|
| 50 |
+
USER mcx
|
| 51 |
+
|
| 52 |
+
EXPOSE 8000
|
| 53 |
+
VOLUME ["/data"]
|
| 54 |
+
|
| 55 |
+
# Container-aware health check — uses the same /api/health endpoint as humans.
|
| 56 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 57 |
+
CMD python -c "import urllib.request, sys; \
|
| 58 |
+
sys.exit(0) if urllib.request.urlopen('http://localhost:8000/api/health', timeout=2).status == 200 else sys.exit(1)" || exit 1
|
| 59 |
+
|
| 60 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 L.ZH (Universiti Kebangsaan Malaysia)
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
| 22 |
+
|
| 23 |
+
DISCLAIMER FOR SAFETY-CRITICAL USE:
|
| 24 |
+
This software is intended as a decision-support tool only. It does NOT
|
| 25 |
+
replace official meteorological forecasts issued by national weather services.
|
| 26 |
+
The authors accept no liability for decisions made based on this software's
|
| 27 |
+
output. Users — particularly hikers, climbers, and outdoor workers — should
|
| 28 |
+
always consult official weather warnings and exercise their own judgment.
|
Makefile
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MicroClimate-X — common dev tasks. Run `make help` for a full list.
|
| 2 |
+
#
|
| 3 |
+
# Conventions:
|
| 4 |
+
# * `make <target>` is the single source of truth for a workflow step.
|
| 5 |
+
# * Targets are idempotent; running twice should not break anything.
|
| 6 |
+
# * Heavy tasks (train, eval) write into git-ignored directories.
|
| 7 |
+
|
| 8 |
+
PYTHON ?= ./.venv/bin/python
|
| 9 |
+
PIP ?= ./.venv/bin/pip
|
| 10 |
+
UVICORN ?= ./.venv/bin/uvicorn
|
| 11 |
+
PYTEST ?= ./.venv/bin/pytest
|
| 12 |
+
RUFF ?= ./.venv/bin/ruff
|
| 13 |
+
|
| 14 |
+
.DEFAULT_GOAL := help
|
| 15 |
+
|
| 16 |
+
.PHONY: help venv install install-dev test test-fast lint format coverage \
|
| 17 |
+
synth preprocess train evaluate run clean docker docker-run
|
| 18 |
+
|
| 19 |
+
help: ## Show this help.
|
| 20 |
+
@awk 'BEGIN{FS=":.*##";print "MicroClimate-X — available targets:"} /^[a-zA-Z_-]+:.*?##/{printf " \033[36m%-15s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST)
|
| 21 |
+
|
| 22 |
+
venv: ## Create a Python 3.10+ venv at ./.venv
|
| 23 |
+
python3 -m venv .venv
|
| 24 |
+
$(PIP) install --upgrade pip
|
| 25 |
+
|
| 26 |
+
install: venv ## Install runtime dependencies.
|
| 27 |
+
$(PIP) install -r requirements.txt
|
| 28 |
+
|
| 29 |
+
install-dev: install ## Install runtime + dev dependencies.
|
| 30 |
+
$(PIP) install -r requirements-dev.txt
|
| 31 |
+
|
| 32 |
+
# ── Quality ────────────────────────────────────────────────────────────
|
| 33 |
+
lint: ## Run ruff lint check.
|
| 34 |
+
$(RUFF) check backend/ scripts/ tests/
|
| 35 |
+
|
| 36 |
+
format: ## Format code with ruff.
|
| 37 |
+
$(RUFF) format backend/ scripts/ tests/
|
| 38 |
+
$(RUFF) check --fix backend/ scripts/ tests/
|
| 39 |
+
|
| 40 |
+
test: ## Run the full test suite with coverage.
|
| 41 |
+
$(PYTEST) tests/ --cov=backend --cov-report=term-missing
|
| 42 |
+
|
| 43 |
+
test-fast: ## Run tests quietly, no coverage.
|
| 44 |
+
$(PYTEST) tests/ -q
|
| 45 |
+
|
| 46 |
+
coverage: ## Generate an HTML coverage report.
|
| 47 |
+
$(PYTEST) tests/ --cov=backend --cov-report=html
|
| 48 |
+
@echo "Open htmlcov/index.html in your browser."
|
| 49 |
+
|
| 50 |
+
# ── ML pipeline ────────────────────────────────────────────────────────
|
| 51 |
+
synth: ## Generate synthetic dataset (no network).
|
| 52 |
+
$(PYTHON) scripts/1b_synth_dataset.py
|
| 53 |
+
|
| 54 |
+
preprocess: ## Build features + target (data/processed.csv).
|
| 55 |
+
$(PYTHON) scripts/2_preprocess.py
|
| 56 |
+
|
| 57 |
+
train: ## Train the Random Forest model.
|
| 58 |
+
$(PYTHON) scripts/3_train_model.py
|
| 59 |
+
|
| 60 |
+
evaluate: ## Generate publication figures + threshold sweep.
|
| 61 |
+
$(PYTHON) scripts/4_evaluate_model.py
|
| 62 |
+
|
| 63 |
+
# ── Local run ──────────────────────────────────────────────────────────
|
| 64 |
+
run: ## Start the FastAPI dev server with auto-reload.
|
| 65 |
+
$(UVICORN) backend.main:app --reload --host 127.0.0.1 --port 8000
|
| 66 |
+
|
| 67 |
+
# ── Docker ─────────────────────────────────────────────────────────────
|
| 68 |
+
docker: ## Build the Docker image.
|
| 69 |
+
docker build -t microclimate-x:latest .
|
| 70 |
+
|
| 71 |
+
docker-run: docker ## Build then run the container on port 8000.
|
| 72 |
+
docker compose up --build
|
| 73 |
+
|
| 74 |
+
# ── Housekeeping ───────────────────────────────────────────────────────
|
| 75 |
+
clean: ## Remove caches, coverage, and SQLite WAL files.
|
| 76 |
+
rm -rf .pytest_cache htmlcov .coverage coverage.xml
|
| 77 |
+
rm -f cache.sqlite3 cache.sqlite3-*
|
| 78 |
+
find . -name __pycache__ -type d -prune -exec rm -rf {} +
|
README.md
CHANGED
|
@@ -1,10 +1,277 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MicroClimate-X
|
| 3 |
+
emoji: 🌧️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Hybrid microclimate risk for complex terrain (FYP demo)
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# MicroClimate-X
|
| 14 |
+
|
| 15 |
+
> Intelligent Meteorological Analysis System for Complex Terrain
|
| 16 |
+
> 面向复杂地形的智能气象分析系统
|
| 17 |
+
|
| 18 |
+
> **Live demo / 在线演示**: <https://huggingface.co/spaces/W1nd5pac/microclimate-x>
|
| 19 |
+
> (Deployed as a Hugging Face Space — Docker SDK. See [`docs/DEPLOY_HF.md`](docs/DEPLOY_HF.md) for the deployment recipe.)
|
| 20 |
+
|
| 21 |
+

|
| 22 |
+

|
| 23 |
+

|
| 24 |
+

|
| 25 |
+

|
| 26 |
+

|
| 27 |
+

|
| 28 |
+

|
| 29 |
+

|
| 30 |
+
|
| 31 |
+
A Final Year Project at **Universiti Kebangsaan Malaysia (UKM)** — Faculty of Information Science & Technology.
|
| 32 |
+
|
| 33 |
+
### For thesis supervisors / 导师阅读路径
|
| 34 |
+
|
| 35 |
+
| Step | Document | What it shows |
|
| 36 |
+
|---|---|---|
|
| 37 |
+
| 1. Dataset | [`docs/dataset.md`](docs/dataset.md) | Source · schema · **Y derivation** · train/test split |
|
| 38 |
+
| 2. Model | [`models/MODEL_CARD.md`](models/MODEL_CARD.md) | Intended use · metrics · limitations · ethics |
|
| 39 |
+
| 3. Evaluation | [`figures/`](figures/) + [`figures/evaluation_summary.json`](figures/evaluation_summary.json) | 6 publication figures, all reproducible via `make evaluate` |
|
| 40 |
+
| 4. Architecture | [`docs/architecture.md`](docs/architecture.md) + [`docs/thresholds.md`](docs/thresholds.md) | Hybrid engine, every threshold cited |
|
| 41 |
+
| 5. Pipeline order | [`docs/pipeline_order.md`](docs/pipeline_order.md) | Explicit "dataset → model → app" sequence |
|
| 42 |
+
| 6. Meeting brief | [`docs/supervisor_meeting_brief.md`](docs/supervisor_meeting_brief.md) | Detailed bilingual EN/ZH script |
|
| 43 |
+
| 7. **Cheat sheet** | [`docs/MEETING_CHEAT_SHEET.md`](docs/MEETING_CHEAT_SHEET.md) · [HTML](docs/MEETING_CHEAT_SHEET.html) | **Open on screen during the meeting** — tab-order · demo script · Q&A · checklist |
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## 1. Problem Statement / 痛点
|
| 48 |
+
|
| 49 |
+
Traditional weather forecasting relies on **macro-scale grids (20 km × 20 km)** that fail catastrophically in complex terrain. A single forecast cell may cover a mountain peak, a valley floor, and a windward slope — all of which have vastly different microclimates.
|
| 50 |
+
|
| 51 |
+
传统天气预报使用 **20 km × 20 km 宏观网格**,在山区会严重失真。同一网格内可能同时包含山顶、谷底和迎风坡,但它们的微气候完全不同。
|
| 52 |
+
|
| 53 |
+
## 2. Solution: The Hybrid Engine / 解决方案
|
| 54 |
+
|
| 55 |
+
MicroClimate-X uses a **dual-engine hybrid architecture** combining a Machine Learning predictor with a topographic Rule-Based Expert System.
|
| 56 |
+
|
| 57 |
+
```
|
| 58 |
+
┌──────────────────────────────────────────────────┐
|
| 59 |
+
│ User clicks a coordinate on the map (lat, lon) │
|
| 60 |
+
└────────────────────┬─────────────────────────────┘
|
| 61 |
+
│
|
| 62 |
+
┌────────────────────▼─────────────────────────────┐
|
| 63 |
+
│ Open-Meteo (weather) + Open Topo Data (DEM) │
|
| 64 |
+
└────────────────────┬─────────────────────────────┘
|
| 65 |
+
│
|
| 66 |
+
┌──────────────────┴───────────────────┐
|
| 67 |
+
│ │
|
| 68 |
+
┌──────────▼──────────┐ ┌────────────▼───────────┐
|
| 69 |
+
│ Engine A │ │ Engine B │
|
| 70 |
+
│ Random Forest │ probability│ Topographic Rules │
|
| 71 |
+
│ (in-distribution ├─────────────►│ + Veto Triggers │
|
| 72 |
+
│ rain probability) │ │ (safety-critical) │
|
| 73 |
+
└─────────────────────┘ └────────────┬───────────┘
|
| 74 |
+
│
|
| 75 |
+
┌────────────▼───────────┐
|
| 76 |
+
│ Risk Score 0-100 │
|
| 77 |
+
│ + Bilingual Advice │
|
| 78 |
+
│ + XAI Inference Log │
|
| 79 |
+
└────────────────────────┘
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
### Why Hybrid? / 为什么混合?
|
| 83 |
+
|
| 84 |
+
Pure ML can fail catastrophically out-of-distribution. Example: feed Mount Everest coordinates → ML predicts 0% rain → returns "Safe" — ignoring -30°C, hypoxia, gale-force winds.
|
| 85 |
+
|
| 86 |
+
**Engine B's Veto mechanism** provides bounded safety guarantees by overriding the ML score when physical thresholds are breached. This follows the **Neuro-Symbolic AI** paradigm (Garcez & Lamb, 2020).
|
| 87 |
+
|
| 88 |
+
### Engine B internals — one-to-one with D5 proposal §3.7 / P4
|
| 89 |
+
|
| 90 |
+
The rule engine is decomposed exactly along the lines of the thesis proposal so every line of code maps to a section number:
|
| 91 |
+
|
| 92 |
+
| Proposal step | Code | Output |
|
| 93 |
+
|---|---|---|
|
| 94 |
+
| **P4.1** Load Dynamic Risk Rules | `backend/config.py` | All thresholds, weights, and the R1-R4 decision table, each annotated with its academic citation |
|
| 95 |
+
| **P4.2** Fetch User Context | `?activity=hiker\|driver\|construction\|general` | Activity is plumbed into the request flow |
|
| 96 |
+
| **P4.3** Evaluate Environmental Risks | Four `score_*_risk()` functions in `rule_engine.py` | Rainfall / Fog / Wind-gust / Thunderstorm sub-scores (each 0-100) |
|
| 97 |
+
| **§3.7.2 Table 4.2** Decision Table | `apply_decision_table_3_7_2()` | Which of R1-R4 fired (hidden rain / no amplification / heavy downpour / standard rain) |
|
| 98 |
+
| Veto cascade | `_collect_veto_triggers()` | Life-safety overrides (Mt-Everest type) — capped at 100 |
|
| 99 |
+
| **P4.4** Activity weighting | `apply_activity_weighting()` | (activity × hazard) weight matrix |
|
| 100 |
+
| **P4.5** Composite score | Same | `0.80 · max(weighted) + 0.20 · mean(rest)` — dominant hazard wins |
|
| 101 |
+
| **P4.6** Actionable advice | `_normal_advice()` / `_veto_advice()` | Bilingual EN/ZH paragraph that names the dominant hazard |
|
| 102 |
+
|
| 103 |
+
Four hazard categories surfaced in the UI as four mini-gauges; the four R1-R4 indicators light up beside the score card whenever a rule fires.
|
| 104 |
+
|
| 105 |
+
## 3. Tech Stack / 技术栈
|
| 106 |
+
|
| 107 |
+
| Layer | Technology |
|
| 108 |
+
|---|---|
|
| 109 |
+
| Frontend | Vue 3 (CDN) + Tailwind CSS + Leaflet.js + ECharts |
|
| 110 |
+
| Backend | Python 3.10+, FastAPI, Uvicorn |
|
| 111 |
+
| ML | Scikit-Learn (Random Forest), Pandas, NumPy |
|
| 112 |
+
| Storage | SQLite 3 (WAL mode, risk-adaptive TTL cache) |
|
| 113 |
+
| External | Open-Meteo Historical Archive (ERA5), Open Topo Data (SRTM DEM) |
|
| 114 |
+
|
| 115 |
+
## 4. Dataset / 数据集
|
| 116 |
+
|
| 117 |
+
- **Source**: [Open-Meteo Historical Weather API](https://open-meteo.com/en/docs/historical-weather-api) (ERA5 reanalysis)
|
| 118 |
+
- **Region**: Malaysian mountain areas (Genting Highlands, Cameron Highlands, Fraser's Hill, Klang Valley, Mount Kinabalu region)
|
| 119 |
+
- **Time Range**: 2020-01-01 to 2023-12-31 (hourly resolution, 5 sites × ~35 000 hours each)
|
| 120 |
+
- **Features (X)**: `elevation_m`, `temperature_c`, `humidity_pct`, `wind_speed_kmh`, `wind_direction_deg`, `surface_pressure_hpa`
|
| 121 |
+
- **Target (Y)**: `is_rain_event` — binary, 1 if `precipitation(t+1h) > 0.1 mm` else 0 (per WMO trace-precipitation definition)
|
| 122 |
+
|
| 123 |
+
## 5. Quick Start / 快速开始
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
git clone https://github.com/KyoukoLi/microclimate-x.git
|
| 127 |
+
cd microclimate-x
|
| 128 |
+
|
| 129 |
+
# Fast path — everything via the Makefile
|
| 130 |
+
make install-dev # 1. create venv + install runtime + dev deps
|
| 131 |
+
make synth # 2. generate synthetic dataset (offline)
|
| 132 |
+
# …or `make` nothing here and run `python scripts/1_download_dataset.py`
|
| 133 |
+
# to fetch real ERA5 data when network is available.
|
| 134 |
+
make preprocess # 3. feature engineering + Y derivation
|
| 135 |
+
make train # 4. RF training + time-based CV
|
| 136 |
+
make evaluate # 5. ROC / PR / calibration / threshold-sweep figures
|
| 137 |
+
make run # 6. uvicorn dev server on http://localhost:8000
|
| 138 |
+
|
| 139 |
+
# Then open frontend/index.html (or browse to http://localhost:8000/app/)
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
### Docker one-liner
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
docker compose up --build
|
| 146 |
+
# API lives on http://localhost:8000 · frontend on http://localhost:8000/app/
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
### Test it
|
| 150 |
+
|
| 151 |
+
```bash
|
| 152 |
+
make test # 70 tests, ~12 s
|
| 153 |
+
make lint # ruff — zero errors expected
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
### Training results on real ERA5 data / 真实 ERA5 数据训练结果
|
| 157 |
+
|
| 158 |
+
Trained on **175 315 hourly samples** from Open-Meteo Historical Archive
|
| 159 |
+
(ECMWF ERA5 reanalysis) covering five Malaysian mountain sites,
|
| 160 |
+
2020-01-01 → 2024-12-31. Time-based split: last 20 % per site held out
|
| 161 |
+
(n = 35 063 test samples). See [`models/MODEL_CARD.md`](models/MODEL_CARD.md)
|
| 162 |
+
for the full evaluation card and `figures/` for publication-ready plots.
|
| 163 |
+
|
| 164 |
+
| Metric | Value | Source |
|
| 165 |
+
|---|---|---|
|
| 166 |
+
| Test ROC AUC | **0.871** | `figures/01_roc_curve.png` |
|
| 167 |
+
| Test PR Average Precision | **0.750** | `figures/02_pr_curve.png` |
|
| 168 |
+
| Brier score (calibration) | **0.138** | `figures/03_calibration_curve.png` |
|
| 169 |
+
| Best F2 @ τ = 0.20 | **0.778** | `figures/04_threshold_sweep.png` |
|
| 170 |
+
| Recall (at chosen τ = 0.20) | **0.934** — safety-critical recall |
|
| 171 |
+
| Class balance | 29.2 % positive (Malaysian mountain climatology) |
|
| 172 |
+
|
| 173 |
+
We deliberately operate at **τ = 0.20**, not the default 0.50, because
|
| 174 |
+
in safety-critical settings a missed rain event (false negative) on a
|
| 175 |
+
windward slope is dramatically worse than a false positive. F2 score
|
| 176 |
+
weights recall 4× higher than precision and is the principled metric
|
| 177 |
+
for this regime.
|
| 178 |
+
|
| 179 |
+
**5-fold time-series CV** on the training fold gives AUC ranging
|
| 180 |
+
0.828-0.908 (mean ≈ 0.858), confirming the model is not over-fitting a
|
| 181 |
+
single temporal slice.
|
| 182 |
+
|
| 183 |
+
#### Feature importance — what the model actually learned
|
| 184 |
+
|
| 185 |
+
| Rank | Feature | Importance | Interpretation |
|
| 186 |
+
|---|---|---|---|
|
| 187 |
+
| 1 | `precipitation_lag_1h` | 37.1 % | Rain autocorrelation — the well-documented "rain begets rain" persistence signal in short-term nowcasting (Wilson et al., 2010). |
|
| 188 |
+
| 2-3 | `hour_cos`, `hour_sin` | 18.6 % | Diurnal convective cycle — Malaysian mountain rainfall peaks in late afternoon. |
|
| 189 |
+
| 4 | `pressure_change_3h` | 4.7 % | Falling pressure precedes incoming storms — the classical synoptic-scale precursor. |
|
| 190 |
+
| 5-6 | `wind_v`, `dew_point_c` | 8.1 % | Moisture transport + saturation potential. |
|
| 191 |
+
| 7-14 | other meteorological X | 22 % | T, humidity, cloud cover, wind, dew-point depression, pressure. |
|
| 192 |
+
| 15-17 | `month_*`, `elevation_m` | 4 % | Low because the time-of-day and lag features already absorb most of the seasonal/static signal. |
|
| 193 |
+
| 18 | `cape_jkg` | **0.0 %** | ⚠️ ERA5 archive CAPE values for these coordinates are predominantly zero — a known coverage gap. The Veto-rule engine still uses CAPE thresholds directly from the live Open-Meteo forecast at inference time. |
|
| 194 |
+
|
| 195 |
+
#### Why F2 instead of accuracy?
|
| 196 |
+
|
| 197 |
+
Accuracy is misleading on imbalanced safety-critical classification.
|
| 198 |
+
A model that predicts "no rain" 100 % of the time achieves
|
| 199 |
+
**69.2 % accuracy** here while being completely useless. F2 weights
|
| 200 |
+
recall twice as heavily as precision, which is correct for a
|
| 201 |
+
hiker-safety app where missing a real rain event (False Negative) is
|
| 202 |
+
far worse than a false alarm (False Positive).
|
| 203 |
+
|
| 204 |
+
See `models/training_report.json` for the full 5-fold CV report.
|
| 205 |
+
|
| 206 |
+
## 6. Project Structure / 项目结构
|
| 207 |
+
|
| 208 |
+
```
|
| 209 |
+
microclimate-x/
|
| 210 |
+
├── backend/
|
| 211 |
+
│ ├── main.py # FastAPI app + lifespan
|
| 212 |
+
│ ├── ml_engine.py # Loads RF model, predict_proba
|
| 213 |
+
│ ├── rule_engine.py # Veto rules + risk scoring + bilingual advice
|
| 214 |
+
│ ├── terrain.py # DEM-based Valley/Slope/Flat classification
|
| 215 |
+
│ ├── cache.py # SQLite WAL cache, risk-adaptive TTL
|
| 216 |
+
│ ├── schemas.py # Pydantic request/response models
|
| 217 |
+
│ └── config.py # Thresholds + academic citations
|
| 218 |
+
├── scripts/
|
| 219 |
+
│ ├── 1_download_dataset.py # Open-Meteo + Open-Topo-Data (real ERA5)
|
| 220 |
+
│ ├── 1b_synth_dataset.py # physically-plausible offline fallback
|
| 221 |
+
│ ├── 2_preprocess.py
|
| 222 |
+
│ └── 3_train_model.py
|
| 223 |
+
├── frontend/
|
| 224 |
+
│ └── index.html # Single-file Vue3 SPA
|
| 225 |
+
├── docs/
|
| 226 |
+
│ ├── architecture.md
|
| 227 |
+
│ └── thresholds.md # Veto thresholds with academic citations
|
| 228 |
+
├── tests/
|
| 229 |
+
│ └── test_rule_engine.py
|
| 230 |
+
├── data/ # raw/processed CSVs (gitignored)
|
| 231 |
+
├── models/ # trained .pkl artifacts (gitignored)
|
| 232 |
+
└── requirements.txt
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
## 7. Key Design Decisions / 关键设计
|
| 236 |
+
|
| 237 |
+
| Decision | Rationale |
|
| 238 |
+
|---|---|
|
| 239 |
+
| **Random Forest over SVM / Deep Learning** | Handles non-linear weather-terrain interactions; outputs interpretable feature importance; no GPU needed; robust on tabular data |
|
| 240 |
+
| **Binary classification (`is_rain_event`)** | One-hour-ahead nowcasting matches the use case (hikers' immediate decisions) |
|
| 241 |
+
| **Time-based train/test split** | Random split would leak temporal correlation → inflated metrics |
|
| 242 |
+
| **Class-weight balanced** | Rain is the minority class (~25% in Malaysian mountains) |
|
| 243 |
+
| **Wind direction as u/v components** | Raw degrees treat 0° and 360° as far apart — mathematically incorrect |
|
| 244 |
+
| **Risk-adaptive cache TTL** | High-risk scenarios refresh faster (60 s) than safe ones (600 s) |
|
| 245 |
+
| **SQLite WAL mode** | Allows concurrent reads during writes — critical for FastAPI async |
|
| 246 |
+
|
| 247 |
+
## 8. Academic References / 学术参考
|
| 248 |
+
|
| 249 |
+
1. **Bhuiyan, M. A. E., et al.** (2020). *Improving satellite-based precipitation estimates over complex terrain using machine learning algorithms*. **Journal of Hydrology**.
|
| 250 |
+
2. **Maclean, I. M., et al.** (2018). *Microclima: An R package for modelling meso- and microclimate*. **Methods in Ecology and Evolution**.
|
| 251 |
+
3. **Garcez, A. d., & Lamb, L. C.** (2020). *Neurosymbolic AI: The 3rd Wave*. arXiv:2012.05876.
|
| 252 |
+
4. **Luks, A. M., et al.** (2019). *Wilderness Medical Society Practice Guidelines for the Prevention and Treatment of Acute Altitude Illness*.
|
| 253 |
+
5. **Vandal, T., et al.** (2017). *DeepSD: Generating high-resolution climate change projections through single image super-resolution*. **KDD**.
|
| 254 |
+
|
| 255 |
+
See `docs/thresholds.md` for the full citation table per Veto threshold.
|
| 256 |
+
|
| 257 |
+
## 9. Roadmap
|
| 258 |
+
|
| 259 |
+
- [x] Frontend dashboard with XAI inference log
|
| 260 |
+
- [x] SQLite caching with WAL + risk-adaptive TTL
|
| 261 |
+
- [x] Terrain detection engine (Valley / Slope / Flat)
|
| 262 |
+
- [x] Rule-based Veto + 0-100 scoring engine (19/19 unit tests passing)
|
| 263 |
+
- [x] Bilingual (EN/ZH) advice generation
|
| 264 |
+
- [x] Dataset download script (Open-Meteo + Open Topo Data) + offline synthetic fallback
|
| 265 |
+
- [x] Preprocessing pipeline (feature engineering + label `is_rain_event`)
|
| 266 |
+
- [x] Random Forest training with time-based CV — **trained on real ERA5 data, test AUC = 0.871**
|
| 267 |
+
- [ ] Model comparison (RFC vs LogReg vs XGBoost) — thesis Chapter 5
|
| 268 |
+
- [ ] Hindcast validation against real Malaysian flood events
|
| 269 |
+
- [ ] PWA offline mode for low-network mountain use
|
| 270 |
+
|
| 271 |
+
## 10. License
|
| 272 |
+
|
| 273 |
+
MIT — see `LICENSE`.
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
|
| 277 |
+
*Developed by L.ZH @ Universiti Kebangsaan Malaysia (UKM) for the Final Year Project (FYP).*
|
backend/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MicroClimate-X backend package."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
backend/cache.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SQLite-backed grid cache with risk-adaptive TTL.
|
| 3 |
+
|
| 4 |
+
Design notes
|
| 5 |
+
------------
|
| 6 |
+
* WAL journal mode lets concurrent reads proceed during writes — critical
|
| 7 |
+
for FastAPI's async I/O. Default rollback-journal mode would serialise
|
| 8 |
+
every reader behind a writer.
|
| 9 |
+
* All blocking sqlite3 calls are wrapped in `asyncio.to_thread` so they
|
| 10 |
+
never stall the event loop.
|
| 11 |
+
* Cache key quantises (lat, lon) to a fixed grid resolution (~1.1 km).
|
| 12 |
+
Without quantisation, floating-point jitter destroys hit rate.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import json
|
| 18 |
+
import sqlite3
|
| 19 |
+
import time
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from . import config
|
| 24 |
+
|
| 25 |
+
_INIT_SQL = """
|
| 26 |
+
CREATE TABLE IF NOT EXISTS grid_cache (
|
| 27 |
+
grid_key TEXT PRIMARY KEY,
|
| 28 |
+
payload TEXT NOT NULL,
|
| 29 |
+
expires_at INTEGER NOT NULL
|
| 30 |
+
);
|
| 31 |
+
CREATE INDEX IF NOT EXISTS idx_expires ON grid_cache(expires_at);
|
| 32 |
+
|
| 33 |
+
CREATE TABLE IF NOT EXISTS inference_log (
|
| 34 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 35 |
+
ts INTEGER NOT NULL,
|
| 36 |
+
lat REAL NOT NULL,
|
| 37 |
+
lon REAL NOT NULL,
|
| 38 |
+
risk INTEGER NOT NULL,
|
| 39 |
+
veto INTEGER NOT NULL,
|
| 40 |
+
summary TEXT NOT NULL
|
| 41 |
+
);
|
| 42 |
+
CREATE INDEX IF NOT EXISTS idx_log_ts ON inference_log(ts);
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
# Inference-log retention — older rows are pruned on startup.
|
| 46 |
+
INFERENCE_LOG_RETENTION_DAYS = 7
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _grid_key(lat: float, lon: float, activity: str = "general") -> str:
|
| 50 |
+
res = config.GRID_RESOLUTION_DEG
|
| 51 |
+
return f"{round(lat / res)}:{round(lon / res)}:{activity}"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
| 55 |
+
conn = sqlite3.connect(db_path, timeout=5.0, isolation_level=None)
|
| 56 |
+
conn.execute("PRAGMA journal_mode=WAL;")
|
| 57 |
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
| 58 |
+
conn.execute("PRAGMA busy_timeout=5000;")
|
| 59 |
+
return conn
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _init_blocking(db_path: Path) -> None:
|
| 63 |
+
conn = _connect(db_path)
|
| 64 |
+
try:
|
| 65 |
+
conn.executescript(_INIT_SQL)
|
| 66 |
+
finally:
|
| 67 |
+
conn.close()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def init_db(db_path: Path = config.DB_PATH) -> None:
|
| 71 |
+
"""Create tables and switch to WAL. Idempotent."""
|
| 72 |
+
await asyncio.to_thread(_init_blocking, db_path)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _get_blocking(db_path: Path, key: str) -> tuple[dict[str, Any], int] | None:
|
| 76 |
+
conn = _connect(db_path)
|
| 77 |
+
try:
|
| 78 |
+
row = conn.execute(
|
| 79 |
+
"SELECT payload, expires_at FROM grid_cache WHERE grid_key=?",
|
| 80 |
+
(key,),
|
| 81 |
+
).fetchone()
|
| 82 |
+
if row is None:
|
| 83 |
+
return None
|
| 84 |
+
payload, expires_at = row
|
| 85 |
+
if expires_at <= int(time.time()):
|
| 86 |
+
return None
|
| 87 |
+
ttl_remaining = expires_at - int(time.time())
|
| 88 |
+
return json.loads(payload), ttl_remaining
|
| 89 |
+
finally:
|
| 90 |
+
conn.close()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def get(lat: float, lon: float, *, activity: str = "general") -> tuple[dict[str, Any], int] | None:
|
| 94 |
+
return await asyncio.to_thread(_get_blocking, config.DB_PATH, _grid_key(lat, lon, activity))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _set_blocking(db_path: Path, key: str, payload: dict[str, Any], ttl_sec: int) -> None:
|
| 98 |
+
conn = _connect(db_path)
|
| 99 |
+
try:
|
| 100 |
+
conn.execute(
|
| 101 |
+
"INSERT OR REPLACE INTO grid_cache(grid_key, payload, expires_at) "
|
| 102 |
+
"VALUES (?, ?, ?)",
|
| 103 |
+
(key, json.dumps(payload), int(time.time()) + ttl_sec),
|
| 104 |
+
)
|
| 105 |
+
finally:
|
| 106 |
+
conn.close()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def set(lat: float, lon: float, payload: dict[str, Any], ttl_sec: int,
|
| 110 |
+
*, activity: str = "general") -> None:
|
| 111 |
+
await asyncio.to_thread(_set_blocking, config.DB_PATH, _grid_key(lat, lon, activity),
|
| 112 |
+
payload, ttl_sec)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def adaptive_ttl(risk_score: int, has_veto: bool) -> int:
|
| 116 |
+
"""Higher risk → shorter TTL. We must not serve stale 'Safe' results
|
| 117 |
+
while severe weather is developing."""
|
| 118 |
+
if has_veto or risk_score >= 70:
|
| 119 |
+
return config.TTL_HIGH_RISK_SEC
|
| 120 |
+
if risk_score >= 40:
|
| 121 |
+
return config.TTL_MID_RISK_SEC
|
| 122 |
+
return config.TTL_LOW_RISK_SEC
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _log_blocking(db_path: Path, lat: float, lon: float, risk: int,
|
| 126 |
+
veto: bool, summary: str) -> None:
|
| 127 |
+
conn = _connect(db_path)
|
| 128 |
+
try:
|
| 129 |
+
conn.execute(
|
| 130 |
+
"INSERT INTO inference_log(ts, lat, lon, risk, veto, summary) "
|
| 131 |
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
| 132 |
+
(int(time.time()), lat, lon, risk, int(veto), summary),
|
| 133 |
+
)
|
| 134 |
+
finally:
|
| 135 |
+
conn.close()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
async def log_inference(lat: float, lon: float, risk: int,
|
| 139 |
+
veto: bool, summary: str) -> None:
|
| 140 |
+
await asyncio.to_thread(_log_blocking, config.DB_PATH, lat, lon,
|
| 141 |
+
risk, veto, summary)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 145 |
+
# GC / introspection
|
| 146 |
+
# ──────────────────────────────────────────────────────────────────���───────
|
| 147 |
+
|
| 148 |
+
def _prune_blocking(db_path: Path) -> int:
|
| 149 |
+
"""Delete expired cache rows + old inference_log rows. Returns total deleted."""
|
| 150 |
+
now = int(time.time())
|
| 151 |
+
log_cutoff = now - INFERENCE_LOG_RETENTION_DAYS * 86_400
|
| 152 |
+
conn = _connect(db_path)
|
| 153 |
+
try:
|
| 154 |
+
c1 = conn.execute("DELETE FROM grid_cache WHERE expires_at <= ?", (now,)).rowcount
|
| 155 |
+
c2 = conn.execute("DELETE FROM inference_log WHERE ts < ?", (log_cutoff,)).rowcount
|
| 156 |
+
return int(c1 or 0) + int(c2 or 0)
|
| 157 |
+
finally:
|
| 158 |
+
conn.close()
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
async def prune_expired(db_path: Path = config.DB_PATH) -> int:
|
| 162 |
+
"""Run cache GC. Returns number of rows removed across both tables."""
|
| 163 |
+
return await asyncio.to_thread(_prune_blocking, db_path)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _stats_blocking(db_path: Path) -> dict[str, Any]:
|
| 167 |
+
now = int(time.time())
|
| 168 |
+
conn = _connect(db_path)
|
| 169 |
+
try:
|
| 170 |
+
total = conn.execute("SELECT COUNT(*) FROM grid_cache").fetchone()[0]
|
| 171 |
+
live = conn.execute(
|
| 172 |
+
"SELECT COUNT(*) FROM grid_cache WHERE expires_at > ?",
|
| 173 |
+
(now,),
|
| 174 |
+
).fetchone()[0]
|
| 175 |
+
logged = conn.execute("SELECT COUNT(*) FROM inference_log").fetchone()[0]
|
| 176 |
+
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
|
| 177 |
+
page_count = conn.execute("PRAGMA page_count").fetchone()[0]
|
| 178 |
+
return {
|
| 179 |
+
"rows_total": int(total),
|
| 180 |
+
"rows_live": int(live),
|
| 181 |
+
"rows_expired": int(total) - int(live),
|
| 182 |
+
"inference_log_rows": int(logged),
|
| 183 |
+
"db_bytes": int(page_size) * int(page_count),
|
| 184 |
+
}
|
| 185 |
+
finally:
|
| 186 |
+
conn.close()
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
async def cache_stats(db_path: Path = config.DB_PATH) -> dict[str, Any]:
|
| 190 |
+
return await asyncio.to_thread(_stats_blocking, db_path)
|
backend/config.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Central configuration for MicroClimate-X.
|
| 3 |
+
|
| 4 |
+
EVERY Veto threshold below has an academic / regulatory citation.
|
| 5 |
+
This is intentional — at thesis defence the panel WILL ask
|
| 6 |
+
"why 3500 m, why -5 °C, why 40 km/h?". Be ready to point to this file.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import subprocess
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 15 |
+
MODEL_DIR = ROOT / "models"
|
| 16 |
+
DATA_DIR = ROOT / "data"
|
| 17 |
+
DB_PATH = Path(os.environ.get("MICROCLIMATEX_DB", str(ROOT / "cache.sqlite3")))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _detect_git_revision() -> str:
|
| 21 |
+
"""Best-effort short SHA. Returns "unknown" if git is not available
|
| 22 |
+
or this directory isn't a checkout (e.g. inside a Docker image)."""
|
| 23 |
+
env = os.environ.get("MICROCLIMATEX_GIT_REV")
|
| 24 |
+
if env:
|
| 25 |
+
return env
|
| 26 |
+
try:
|
| 27 |
+
out = subprocess.run(
|
| 28 |
+
["git", "rev-parse", "--short", "HEAD"],
|
| 29 |
+
cwd=ROOT, capture_output=True, text=True, timeout=2.0,
|
| 30 |
+
)
|
| 31 |
+
if out.returncode == 0:
|
| 32 |
+
return out.stdout.strip()
|
| 33 |
+
except (FileNotFoundError, subprocess.SubprocessError): # pragma: no cover
|
| 34 |
+
pass
|
| 35 |
+
return "unknown"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
GIT_REVISION = _detect_git_revision()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 42 |
+
# Veto thresholds — one-vote rejection rules
|
| 43 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 44 |
+
# Citation: Luks et al. (2019) "Wilderness Medical Society Practice
|
| 45 |
+
# Guidelines for the Prevention and Treatment of Acute Altitude
|
| 46 |
+
# Illness." High altitude (>2500 m) carries clinical risk; severe
|
| 47 |
+
# hypoxia onset is well-documented above ~3500 m.
|
| 48 |
+
ALTITUDE_HYPOXIA_M = 3500.0
|
| 49 |
+
|
| 50 |
+
# Citation: WMO Beaufort scale — Force 6 "Strong breeze" ≈ 39-49 km/h,
|
| 51 |
+
# the threshold above which outdoor activity becomes hazardous.
|
| 52 |
+
GALE_WIND_KMH = 40.0
|
| 53 |
+
|
| 54 |
+
# Citation: UIAA Medical Commission frostbite risk guidance — exposed skin
|
| 55 |
+
# freezes rapidly below approximately -5 °C with wind chill.
|
| 56 |
+
EXTREME_COLD_C = -5.0
|
| 57 |
+
|
| 58 |
+
# Citation: U.S. NWS convective forecasting handbook — CAPE > 1000 J/kg
|
| 59 |
+
# indicates moderate-to-strong instability suitable for
|
| 60 |
+
# thunderstorm development.
|
| 61 |
+
HIGH_CAPE_JKG = 1000.0
|
| 62 |
+
|
| 63 |
+
# Citation: FAA AIM 7-1-12 — visibility below 100 m is classified as
|
| 64 |
+
# Category III instrument-only conditions. Used here as an extreme
|
| 65 |
+
# low-visibility threshold (whiteout / dense fog).
|
| 66 |
+
LOW_VISIBILITY_M = 100.0
|
| 67 |
+
|
| 68 |
+
# Wind alignment with slope normal vector (orographic uplift). The
|
| 69 |
+
# threshold 0.7 corresponds to ~45 degrees of slope-facing wind.
|
| 70 |
+
OROGRAPHIC_DOT_THRESHOLD = 0.7
|
| 71 |
+
|
| 72 |
+
# Wet-flood trigger in a valley basin: high probability of localised rain
|
| 73 |
+
# combined with valley-floor topography.
|
| 74 |
+
VALLEY_FLOOD_PROB = 0.80
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 78 |
+
# Risk scoring (additive penalties when no Veto fires)
|
| 79 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 80 |
+
PENALTY = {
|
| 81 |
+
"ml_high_rain_prob": 35, # ML predicts >= 70 % rain probability
|
| 82 |
+
"ml_mid_rain_prob": 15, # ML predicts 40-70 % rain probability
|
| 83 |
+
"valley_floor": 10,
|
| 84 |
+
"windward_slope": 20,
|
| 85 |
+
"orographic_lift": 25,
|
| 86 |
+
"altitude_high": 15, # 2500-3500 m, sub-Veto altitude band
|
| 87 |
+
"wind_strong": 10, # 25-40 km/h
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 92 |
+
# Four hazard categories — matches D5 proposal §3.7 / P4.3
|
| 93 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 94 |
+
# Fog risk:
|
| 95 |
+
# WMO surface synoptic code: fog ≈ visibility < 1 km, RH typically > 95 %,
|
| 96 |
+
# dew-point depression < ~2 °C. Valley/Slope basins trap radiation fog.
|
| 97 |
+
FOG_HUMIDITY_PCT = 95.0
|
| 98 |
+
FOG_DEW_DEP_MAX_C = 2.0
|
| 99 |
+
FOG_CLOUD_BASE_MAX_M = 800.0 # from D5 §3.7.2 decision table
|
| 100 |
+
|
| 101 |
+
# Wind gust risk:
|
| 102 |
+
# On exposed ridges and mountain passes, sustained 25 km/h winds with
|
| 103 |
+
# topographic acceleration commonly gust to Beaufort F6 levels.
|
| 104 |
+
GUST_WIND_MIN_KMH = 25.0 # below GALE_WIND_KMH but still risky
|
| 105 |
+
|
| 106 |
+
# Thunderstorm risk:
|
| 107 |
+
# NWS "moderate instability" begins at CAPE 500 J/kg; sharp pressure drop
|
| 108 |
+
# often precedes convective initiation.
|
| 109 |
+
THUNDER_CAPE_MIN_JKG = 500.0
|
| 110 |
+
THUNDER_PRESSURE_DROP = -2.0 # hPa over past 3 h (matches D5 §1.3 example)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 114 |
+
# Decision Table — D5 §3.7.2 / Table 4.2 (one-to-one with the thesis)
|
| 115 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 116 |
+
# Each rule fires when ALL of its non-None conditions hold. The thesis
|
| 117 |
+
# narrative motivates this table as: "macro forecast says no rain, but
|
| 118 |
+
# the local terrain conditions imply hidden risk".
|
| 119 |
+
DECISION_TABLE_3_7_2 = {
|
| 120 |
+
"R1": {
|
| 121 |
+
"description": "Hidden rain risk — macro says no, terrain says yes",
|
| 122 |
+
"macro_rain_prob_max": 0.30,
|
| 123 |
+
"macro_rain_prob_min": None,
|
| 124 |
+
"humidity_min_pct": 85.0,
|
| 125 |
+
"wind_into_slope": True,
|
| 126 |
+
"terrain": "WindwardSlope",
|
| 127 |
+
"pressure_change_3h_max": -1.5,
|
| 128 |
+
"cloud_base_max_m": FOG_CLOUD_BASE_MAX_M,
|
| 129 |
+
"conclusion_en": "Hidden rain risk: terrain analysis indicates orographic precipitation despite low macro probability.",
|
| 130 |
+
"conclusion_zh": "隐藏降雨风险:宏观预报概率低,但地形分析表明存在地形抬升降水。",
|
| 131 |
+
},
|
| 132 |
+
"R2": {
|
| 133 |
+
"description": "No significant risk — terrain not aligned",
|
| 134 |
+
"macro_rain_prob_max": 0.30,
|
| 135 |
+
"macro_rain_prob_min": None,
|
| 136 |
+
"humidity_min_pct": 85.0,
|
| 137 |
+
"wind_into_slope": False,
|
| 138 |
+
"terrain": "LeewardOrValley",
|
| 139 |
+
"pressure_change_3h_max": -1.5,
|
| 140 |
+
"cloud_base_max_m": FOG_CLOUD_BASE_MAX_M,
|
| 141 |
+
"conclusion_en": "No significant rainfall danger at this spot in this period.",
|
| 142 |
+
"conclusion_zh": "此地此时无显著降雨危险。",
|
| 143 |
+
},
|
| 144 |
+
"R3": {
|
| 145 |
+
"description": "Heavy downpour incoming — avoid exposure",
|
| 146 |
+
"macro_rain_prob_max": None,
|
| 147 |
+
"macro_rain_prob_min": 0.70,
|
| 148 |
+
"humidity_min_pct": None,
|
| 149 |
+
"wind_into_slope": True,
|
| 150 |
+
"terrain": "WindwardSlope",
|
| 151 |
+
"pressure_change_3h_max": None,
|
| 152 |
+
"cloud_base_max_m": None,
|
| 153 |
+
"conclusion_en": "Heavy downpour incoming. Avoid mountains and valleys.",
|
| 154 |
+
"conclusion_zh": "强降雨即将到来。请避开山区与峡谷。",
|
| 155 |
+
},
|
| 156 |
+
"R4": {
|
| 157 |
+
"description": "Normal rain — no terrain amplification",
|
| 158 |
+
"macro_rain_prob_max": None,
|
| 159 |
+
"macro_rain_prob_min": 0.70,
|
| 160 |
+
"humidity_min_pct": None,
|
| 161 |
+
"wind_into_slope": None,
|
| 162 |
+
"terrain": None,
|
| 163 |
+
"pressure_change_3h_max": None,
|
| 164 |
+
"cloud_base_max_m": None,
|
| 165 |
+
"conclusion_en": "Rain expected, but no terrain-induced amplification. Standard rain precautions apply.",
|
| 166 |
+
"conclusion_zh": "预计有雨,但无地形抬升放大。按一般雨天措施应对即可。",
|
| 167 |
+
},
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 172 |
+
# Activity-aware weighting — D5 §3.7 / P4.4
|
| 173 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 174 |
+
# Composite = Σ w_i · subscore_i, then renormalised to 0-100.
|
| 175 |
+
# Rows: activity. Cols: rainfall, fog, wind_gust, thunderstorm.
|
| 176 |
+
ACTIVITY_WEIGHTS = {
|
| 177 |
+
"hiker": {"rainfall": 1.0, "fog": 1.3, "wind_gust": 1.0, "thunderstorm": 1.4},
|
| 178 |
+
"driver": {"rainfall": 0.8, "fog": 1.5, "wind_gust": 1.3, "thunderstorm": 0.9},
|
| 179 |
+
"construction": {"rainfall": 1.0, "fog": 0.8, "wind_gust": 1.5, "thunderstorm": 1.4},
|
| 180 |
+
"general": {"rainfall": 1.0, "fog": 1.0, "wind_gust": 1.0, "thunderstorm": 1.0},
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 185 |
+
# Cache TTL (risk-adaptive)
|
| 186 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 187 |
+
# Safety-critical apps must not serve stale "Safe" verdicts during developing
|
| 188 |
+
# storms. Bucket TTL by risk band.
|
| 189 |
+
TTL_HIGH_RISK_SEC = 60 # any Veto fired OR risk >= 70
|
| 190 |
+
TTL_MID_RISK_SEC = 300 # risk 40-70
|
| 191 |
+
TTL_LOW_RISK_SEC = 600 # risk < 40
|
| 192 |
+
|
| 193 |
+
# Grid resolution used as cache key (0.01° ≈ 1.1 km at the equator).
|
| 194 |
+
GRID_RESOLUTION_DEG = 0.01
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 198 |
+
# External API endpoints
|
| 199 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 200 |
+
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
| 201 |
+
OPEN_TOPO_URL = "https://api.opentopodata.org/v1/srtm30m"
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 205 |
+
# Domain constants
|
| 206 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 207 |
+
# WMO definition of trace precipitation.
|
| 208 |
+
RAIN_THRESHOLD_MM = 0.1
|
backend/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralised error contract.
|
| 3 |
+
|
| 4 |
+
Every non-2xx response from the API has the same JSON shape so a
|
| 5 |
+
client (Vue SPA, curl, Postman, future mobile app) can rely on it.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ErrorResponse(BaseModel):
|
| 15 |
+
error: str # short, stable identifier (snake_case)
|
| 16 |
+
detail: str # human readable
|
| 17 |
+
request_id: str | None = None
|
| 18 |
+
context: dict[str, Any] | None = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Canonical error identifiers — used as the `error` field. Adding new ones
|
| 22 |
+
# requires updating the OpenAPI docstring on the predict() endpoint too.
|
| 23 |
+
ERR_UPSTREAM_FAILURE = "upstream_failure"
|
| 24 |
+
ERR_INVALID_INPUT = "invalid_input"
|
| 25 |
+
ERR_MODEL_ERROR = "model_error"
|
| 26 |
+
ERR_INTERNAL = "internal_error"
|
backend/main.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI entry point for MicroClimate-X.
|
| 3 |
+
|
| 4 |
+
Endpoints
|
| 5 |
+
---------
|
| 6 |
+
GET / — name / version / banner
|
| 7 |
+
GET /api/predict — main prediction endpoint (?lat=&lon=&activity=)
|
| 8 |
+
GET /api/health — JSON health + cache stats + DB latency
|
| 9 |
+
GET /api/version — version metadata for clients
|
| 10 |
+
|
| 11 |
+
Lifespan
|
| 12 |
+
--------
|
| 13 |
+
* On startup: WAL-mode SQLite init, prune expired cache rows, load ML model.
|
| 14 |
+
* On shutdown: dispose of the shared httpx.AsyncClient.
|
| 15 |
+
|
| 16 |
+
Resilience
|
| 17 |
+
----------
|
| 18 |
+
* `RequestIDMiddleware` stamps every request with `X-Request-ID` for log
|
| 19 |
+
correlation (taken from incoming header if present, otherwise generated).
|
| 20 |
+
* All exceptions surface as a `errors.ErrorResponse` JSON document — no
|
| 21 |
+
bare 500 HTML responses leak.
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import asyncio
|
| 26 |
+
import datetime as _dt
|
| 27 |
+
import logging
|
| 28 |
+
import math
|
| 29 |
+
import time
|
| 30 |
+
import uuid
|
| 31 |
+
from contextlib import asynccontextmanager
|
| 32 |
+
from typing import Any
|
| 33 |
+
|
| 34 |
+
import httpx
|
| 35 |
+
from fastapi import FastAPI, HTTPException, Query, Request
|
| 36 |
+
from fastapi.exceptions import RequestValidationError
|
| 37 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 38 |
+
from fastapi.responses import JSONResponse
|
| 39 |
+
from fastapi.staticfiles import StaticFiles
|
| 40 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 41 |
+
from tenacity import RetryError, retry, stop_after_attempt, wait_exponential
|
| 42 |
+
|
| 43 |
+
from . import cache, config, rule_engine, terrain
|
| 44 |
+
from .errors import (
|
| 45 |
+
ERR_INTERNAL,
|
| 46 |
+
ERR_INVALID_INPUT,
|
| 47 |
+
ERR_UPSTREAM_FAILURE,
|
| 48 |
+
ErrorResponse,
|
| 49 |
+
)
|
| 50 |
+
from .ml_engine import MLEngine
|
| 51 |
+
from .schemas import ActivityType, PredictionResponse
|
| 52 |
+
|
| 53 |
+
__version__ = "1.0.0"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 57 |
+
# Logging — structured records: ts | level | request_id | message
|
| 58 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
class _RequestIDFilter(logging.Filter):
|
| 61 |
+
def filter(self, record: logging.LogRecord) -> bool:
|
| 62 |
+
if not hasattr(record, "request_id"):
|
| 63 |
+
record.request_id = "-"
|
| 64 |
+
return True
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
_handler = logging.StreamHandler()
|
| 68 |
+
_handler.setFormatter(logging.Formatter(
|
| 69 |
+
"%(asctime)s | %(levelname)-7s | %(request_id)s | %(name)s | %(message)s",
|
| 70 |
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
| 71 |
+
))
|
| 72 |
+
_handler.addFilter(_RequestIDFilter())
|
| 73 |
+
logging.basicConfig(level=logging.INFO, handlers=[_handler], force=True)
|
| 74 |
+
log = logging.getLogger("microclimate-x")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 78 |
+
# Lifespan: model + DB + HTTP client
|
| 79 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 80 |
+
|
| 81 |
+
@asynccontextmanager
|
| 82 |
+
async def lifespan(app: FastAPI):
|
| 83 |
+
log.info("Starting MicroClimate-X backend (v%s)…", __version__)
|
| 84 |
+
await cache.init_db()
|
| 85 |
+
pruned = await cache.prune_expired()
|
| 86 |
+
if pruned:
|
| 87 |
+
log.info("Cache GC removed %d expired rows on startup.", pruned)
|
| 88 |
+
|
| 89 |
+
engine = MLEngine()
|
| 90 |
+
engine.load()
|
| 91 |
+
if engine.is_loaded:
|
| 92 |
+
log.info("ML model loaded from %s", engine.loaded_from)
|
| 93 |
+
else:
|
| 94 |
+
log.warning(
|
| 95 |
+
"No trained model found — falling back to heuristic predictor. "
|
| 96 |
+
"Run scripts/3_train_model.py to enable Random Forest."
|
| 97 |
+
)
|
| 98 |
+
app.state.ml = engine
|
| 99 |
+
app.state.http = httpx.AsyncClient(timeout=15.0, http2=False)
|
| 100 |
+
app.state.start_ts = time.time()
|
| 101 |
+
try:
|
| 102 |
+
yield
|
| 103 |
+
finally:
|
| 104 |
+
await app.state.http.aclose()
|
| 105 |
+
log.info("Shutdown complete.")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
app = FastAPI(
|
| 109 |
+
title="MicroClimate-X API",
|
| 110 |
+
version=__version__,
|
| 111 |
+
description=(
|
| 112 |
+
"Hybrid microclimate risk assessment for complex terrain. "
|
| 113 |
+
"Combines a Random Forest macro-rain predictor with a topographic "
|
| 114 |
+
"rule-based expert system (Veto cascade + R1-R4 decision table "
|
| 115 |
+
"+ activity-aware composite). "
|
| 116 |
+
"Implements proposal §3.7 — sub-process P4.1 through P4.6."
|
| 117 |
+
),
|
| 118 |
+
lifespan=lifespan,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
app.add_middleware(
|
| 122 |
+
CORSMiddleware,
|
| 123 |
+
allow_origins=["*"],
|
| 124 |
+
allow_methods=["GET"],
|
| 125 |
+
allow_headers=["*"],
|
| 126 |
+
expose_headers=["X-Request-ID", "X-Response-Time-ms"],
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 131 |
+
# Request-ID + timing middleware
|
| 132 |
+
# ───────────────────────��──────────────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
| 135 |
+
"""Tag every request with `X-Request-ID` and measure latency.
|
| 136 |
+
|
| 137 |
+
The ID propagates from incoming headers (so a load-balancer / front-end
|
| 138 |
+
can supply one) and falls back to a new UUID4 prefix.
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
async def dispatch(self, request: Request, call_next):
|
| 142 |
+
req_id = request.headers.get("x-request-id") or uuid.uuid4().hex[:12]
|
| 143 |
+
# Stash on request state so handlers can read it.
|
| 144 |
+
request.state.request_id = req_id
|
| 145 |
+
start = time.perf_counter()
|
| 146 |
+
try:
|
| 147 |
+
response = await call_next(request)
|
| 148 |
+
except Exception: # pragma: no cover
|
| 149 |
+
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
| 150 |
+
log.exception(
|
| 151 |
+
"unhandled exception",
|
| 152 |
+
extra={"request_id": req_id, "path": request.url.path,
|
| 153 |
+
"elapsed_ms": elapsed_ms},
|
| 154 |
+
)
|
| 155 |
+
return _json_error(
|
| 156 |
+
req_id, 500, ERR_INTERNAL,
|
| 157 |
+
"Internal server error — please retry.",
|
| 158 |
+
)
|
| 159 |
+
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
| 160 |
+
response.headers["X-Request-ID"] = req_id
|
| 161 |
+
response.headers["X-Response-Time-ms"] = str(elapsed_ms)
|
| 162 |
+
# Only log non-static-asset, non-OPTIONS for noise control.
|
| 163 |
+
if request.url.path.startswith("/api/") or request.url.path in {"/"}:
|
| 164 |
+
log.info(
|
| 165 |
+
"%s %s -> %d (%d ms)",
|
| 166 |
+
request.method, request.url.path, response.status_code, elapsed_ms,
|
| 167 |
+
extra={"request_id": req_id},
|
| 168 |
+
)
|
| 169 |
+
return response
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
app.add_middleware(RequestIDMiddleware)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 176 |
+
# Exception handlers — every error follows the ErrorResponse schema
|
| 177 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 178 |
+
|
| 179 |
+
def _json_error(req_id: str | None, status: int, code: str, detail: str,
|
| 180 |
+
ctx: dict[str, Any] | None = None) -> JSONResponse:
|
| 181 |
+
payload = ErrorResponse(error=code, detail=detail, request_id=req_id, context=ctx)
|
| 182 |
+
return JSONResponse(status_code=status, content=payload.model_dump(exclude_none=True))
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@app.exception_handler(RequestValidationError)
|
| 186 |
+
async def _on_validation_error(request: Request, exc: RequestValidationError):
|
| 187 |
+
req_id = getattr(request.state, "request_id", None)
|
| 188 |
+
return _json_error(
|
| 189 |
+
req_id, 422, ERR_INVALID_INPUT,
|
| 190 |
+
"One or more query parameters failed validation.",
|
| 191 |
+
ctx={"errors": exc.errors()[:5]},
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@app.exception_handler(HTTPException)
|
| 196 |
+
async def _on_http_exception(request: Request, exc: HTTPException):
|
| 197 |
+
req_id = getattr(request.state, "request_id", None)
|
| 198 |
+
code = (
|
| 199 |
+
ERR_UPSTREAM_FAILURE if exc.status_code in {502, 503, 504}
|
| 200 |
+
else ERR_INVALID_INPUT if exc.status_code in {400, 422}
|
| 201 |
+
else ERR_INTERNAL
|
| 202 |
+
)
|
| 203 |
+
return _json_error(req_id, exc.status_code, code, str(exc.detail))
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@app.exception_handler(Exception)
|
| 207 |
+
async def _on_unhandled(request: Request, exc: Exception): # pragma: no cover
|
| 208 |
+
req_id = getattr(request.state, "request_id", None)
|
| 209 |
+
log.exception("unhandled top-level exception",
|
| 210 |
+
extra={"request_id": req_id or "-"})
|
| 211 |
+
return _json_error(
|
| 212 |
+
req_id, 500, ERR_INTERNAL,
|
| 213 |
+
"Internal server error — please retry. If the problem persists, file an issue.",
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 218 |
+
# Frontend static files (optional — only if /frontend exists alongside backend)
|
| 219 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
FRONTEND_DIR = config.ROOT / "frontend"
|
| 222 |
+
if FRONTEND_DIR.exists():
|
| 223 |
+
app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 227 |
+
# Health & version & root
|
| 228 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 229 |
+
|
| 230 |
+
@app.get("/")
|
| 231 |
+
async def root() -> dict[str, Any]:
|
| 232 |
+
return {
|
| 233 |
+
"name": "MicroClimate-X",
|
| 234 |
+
"version": __version__,
|
| 235 |
+
"ml_loaded": app.state.ml.is_loaded,
|
| 236 |
+
"frontend_url": "/app/",
|
| 237 |
+
"docs_url": "/docs",
|
| 238 |
+
"openapi_url": "/openapi.json",
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
@app.get("/api/version")
|
| 243 |
+
async def version() -> dict[str, Any]:
|
| 244 |
+
return {
|
| 245 |
+
"version": __version__,
|
| 246 |
+
"git_revision": config.GIT_REVISION,
|
| 247 |
+
"ml_loaded": app.state.ml.is_loaded,
|
| 248 |
+
"ml_loaded_from": app.state.ml.loaded_from,
|
| 249 |
+
"ml_features": [*app.state.ml.feature_columns[:5], "…"]
|
| 250 |
+
if len(app.state.ml.feature_columns) > 5
|
| 251 |
+
else app.state.ml.feature_columns,
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@app.get("/api/health")
|
| 256 |
+
async def health() -> dict[str, Any]:
|
| 257 |
+
stats = await cache.cache_stats()
|
| 258 |
+
return {
|
| 259 |
+
"status": "ok",
|
| 260 |
+
"uptime_sec": int(time.time() - app.state.start_ts),
|
| 261 |
+
"ml_loaded": app.state.ml.is_loaded,
|
| 262 |
+
"cache": stats,
|
| 263 |
+
"db_path": str(config.DB_PATH),
|
| 264 |
+
"version": __version__,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 269 |
+
# External fetching helpers
|
| 270 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 271 |
+
|
| 272 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 273 |
+
async def _fetch_current_weather(client: httpx.AsyncClient, lat: float, lon: float) -> dict[str, Any]:
|
| 274 |
+
resp = await client.get(
|
| 275 |
+
config.OPEN_METEO_FORECAST_URL,
|
| 276 |
+
params={
|
| 277 |
+
"latitude": lat,
|
| 278 |
+
"longitude": lon,
|
| 279 |
+
"current": ",".join([
|
| 280 |
+
"temperature_2m", "relative_humidity_2m", "precipitation",
|
| 281 |
+
"wind_speed_10m", "wind_direction_10m", "surface_pressure",
|
| 282 |
+
"dew_point_2m", "cloud_cover", "cape", "visibility",
|
| 283 |
+
]),
|
| 284 |
+
"windspeed_unit": "kmh",
|
| 285 |
+
"timezone": "auto",
|
| 286 |
+
},
|
| 287 |
+
timeout=15.0,
|
| 288 |
+
)
|
| 289 |
+
resp.raise_for_status()
|
| 290 |
+
raw = resp.json().get("current", {})
|
| 291 |
+
return {
|
| 292 |
+
"temperature_c": raw.get("temperature_2m"),
|
| 293 |
+
"humidity_pct": raw.get("relative_humidity_2m"),
|
| 294 |
+
"precipitation_mm": raw.get("precipitation", 0.0),
|
| 295 |
+
"wind_speed_kmh": raw.get("wind_speed_10m", 0.0),
|
| 296 |
+
"wind_direction_deg": raw.get("wind_direction_10m", 0.0),
|
| 297 |
+
"pressure_hpa": raw.get("surface_pressure"),
|
| 298 |
+
"dew_point_c": raw.get("dew_point_2m"),
|
| 299 |
+
"cloud_cover_pct": raw.get("cloud_cover", 0.0),
|
| 300 |
+
"cape_jkg": raw.get("cape", 0.0),
|
| 301 |
+
"visibility_m": raw.get("visibility", 10000.0),
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 306 |
+
# Main endpoint
|
| 307 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 308 |
+
|
| 309 |
+
@app.get(
|
| 310 |
+
"/api/predict",
|
| 311 |
+
response_model=PredictionResponse,
|
| 312 |
+
responses={
|
| 313 |
+
422: {"model": ErrorResponse, "description": "Invalid query parameters."},
|
| 314 |
+
502: {"model": ErrorResponse, "description": "Upstream weather/DEM service failed."},
|
| 315 |
+
500: {"model": ErrorResponse, "description": "Unexpected server error."},
|
| 316 |
+
},
|
| 317 |
+
)
|
| 318 |
+
async def predict(
|
| 319 |
+
request: Request,
|
| 320 |
+
lat: float = Query(..., ge=-90.0, le=90.0, description="Latitude (WGS84)"),
|
| 321 |
+
lon: float = Query(..., ge=-180.0, le=180.0, description="Longitude (WGS84)"),
|
| 322 |
+
activity: ActivityType = Query(
|
| 323 |
+
"general",
|
| 324 |
+
description="User activity context — affects composite score weighting (D5 §3.7 / P4.4).",
|
| 325 |
+
),
|
| 326 |
+
) -> PredictionResponse:
|
| 327 |
+
req_id = getattr(request.state, "request_id", "-")
|
| 328 |
+
|
| 329 |
+
# ── Cache lookup first (per-coordinate + per-activity) ──
|
| 330 |
+
hit = await cache.get(lat, lon, activity=activity)
|
| 331 |
+
if hit is not None:
|
| 332 |
+
payload, ttl_remaining = hit
|
| 333 |
+
payload["cached"] = True
|
| 334 |
+
payload["cache_ttl"] = ttl_remaining
|
| 335 |
+
log.info("cache hit (ttl_remaining=%ds)", ttl_remaining, extra={"request_id": req_id})
|
| 336 |
+
return PredictionResponse(**payload)
|
| 337 |
+
|
| 338 |
+
client: httpx.AsyncClient = app.state.http
|
| 339 |
+
|
| 340 |
+
# ── Fetch DEM (terrain) and weather in parallel ──
|
| 341 |
+
try:
|
| 342 |
+
dem9, weather = await asyncio.gather(
|
| 343 |
+
terrain.fetch_dem_3x3(lat, lon, client),
|
| 344 |
+
_fetch_current_weather(client, lat, lon),
|
| 345 |
+
)
|
| 346 |
+
except (httpx.HTTPError, RetryError, ValueError) as exc:
|
| 347 |
+
log.warning(
|
| 348 |
+
"upstream API failure: %s",
|
| 349 |
+
type(exc).__name__,
|
| 350 |
+
extra={"request_id": req_id},
|
| 351 |
+
)
|
| 352 |
+
raise HTTPException(
|
| 353 |
+
status_code=502,
|
| 354 |
+
detail=f"Upstream weather/DEM service unavailable ({type(exc).__name__}). "
|
| 355 |
+
f"Please retry shortly.",
|
| 356 |
+
) from exc
|
| 357 |
+
|
| 358 |
+
tinfo = terrain.classify_terrain(dem9)
|
| 359 |
+
|
| 360 |
+
orographic_dot = (
|
| 361 |
+
terrain.orographic_lift_dot(
|
| 362 |
+
weather.get("wind_direction_deg", 0.0),
|
| 363 |
+
tinfo.aspect_deg,
|
| 364 |
+
tinfo.slope_deg,
|
| 365 |
+
)
|
| 366 |
+
if tinfo.terrain == "Slope" else 0.0
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
# ── Build ML feature dict ──
|
| 370 |
+
feats = _build_ml_features(weather, tinfo.elevation_m)
|
| 371 |
+
|
| 372 |
+
try:
|
| 373 |
+
ml_prob = app.state.ml.predict_rain_probability(feats)
|
| 374 |
+
except Exception as exc: # pragma: no cover
|
| 375 |
+
log.exception("ML inference failed", extra={"request_id": req_id})
|
| 376 |
+
raise HTTPException(
|
| 377 |
+
status_code=500,
|
| 378 |
+
detail=f"Model inference failed: {exc!r}",
|
| 379 |
+
) from exc
|
| 380 |
+
|
| 381 |
+
# ── Apply Rule Engine ──
|
| 382 |
+
rule_result = rule_engine.evaluate(
|
| 383 |
+
lat=lat,
|
| 384 |
+
lon=lon,
|
| 385 |
+
elevation_m=tinfo.elevation_m,
|
| 386 |
+
terrain=tinfo.terrain,
|
| 387 |
+
weather=weather,
|
| 388 |
+
ml_rain_prob=ml_prob,
|
| 389 |
+
slope_deg=tinfo.slope_deg,
|
| 390 |
+
aspect_deg=tinfo.aspect_deg,
|
| 391 |
+
orographic_dot=orographic_dot,
|
| 392 |
+
activity=activity,
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
# ── Assemble response ──
|
| 396 |
+
ttl = cache.adaptive_ttl(rule_result.risk_score, rule_result.has_veto)
|
| 397 |
+
response = PredictionResponse(
|
| 398 |
+
latitude=lat,
|
| 399 |
+
longitude=lon,
|
| 400 |
+
elevation_m=tinfo.elevation_m,
|
| 401 |
+
terrain=tinfo.terrain,
|
| 402 |
+
ml_rain_probability=ml_prob,
|
| 403 |
+
hazard_subscores=rule_result.hazard_subscores,
|
| 404 |
+
decision_table_matches=rule_result.decision_table_matches,
|
| 405 |
+
activity=rule_result.activity,
|
| 406 |
+
risk_score=rule_result.risk_score,
|
| 407 |
+
risk_level=rule_result.risk_level,
|
| 408 |
+
veto_triggers=rule_result.veto_triggers,
|
| 409 |
+
inference_log=rule_result.inference_log,
|
| 410 |
+
advice_en=rule_result.advice_en,
|
| 411 |
+
advice_zh=rule_result.advice_zh,
|
| 412 |
+
cached=False,
|
| 413 |
+
cache_ttl=ttl,
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
# ── Cache + audit-log (fire-and-forget — never blocks the response) ──
|
| 417 |
+
payload_dump = response.model_dump(mode="json")
|
| 418 |
+
_bg_tasks: set[asyncio.Task[Any]] = getattr(request.app.state, "bg_tasks", None) or set()
|
| 419 |
+
request.app.state.bg_tasks = _bg_tasks
|
| 420 |
+
for coro in (
|
| 421 |
+
cache.set(lat, lon, payload_dump, ttl, activity=activity),
|
| 422 |
+
cache.log_inference(
|
| 423 |
+
lat, lon, rule_result.risk_score, rule_result.has_veto,
|
| 424 |
+
rule_result.advice_en,
|
| 425 |
+
),
|
| 426 |
+
):
|
| 427 |
+
task = asyncio.create_task(coro)
|
| 428 |
+
_bg_tasks.add(task)
|
| 429 |
+
task.add_done_callback(_bg_tasks.discard)
|
| 430 |
+
return response
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 434 |
+
# Helpers
|
| 435 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 436 |
+
|
| 437 |
+
def _build_ml_features(weather: dict[str, Any], elevation_m: float) -> dict[str, float]:
|
| 438 |
+
"""Mirror of `scripts/2_preprocess.py` — keep features in sync with training."""
|
| 439 |
+
now = _dt.datetime.now()
|
| 440 |
+
feats = dict(weather)
|
| 441 |
+
feats["elevation_m"] = elevation_m
|
| 442 |
+
wind_kmh = weather.get("wind_speed_kmh", 0.0) or 0.0
|
| 443 |
+
wind_dir = weather.get("wind_direction_deg", 0.0) or 0.0
|
| 444 |
+
feats["wind_u"] = wind_kmh * math.sin(math.radians(wind_dir))
|
| 445 |
+
feats["wind_v"] = wind_kmh * math.cos(math.radians(wind_dir))
|
| 446 |
+
feats["hour_sin"] = math.sin(2 * math.pi * now.hour / 24.0)
|
| 447 |
+
feats["hour_cos"] = math.cos(2 * math.pi * now.hour / 24.0)
|
| 448 |
+
feats["month_sin"] = math.sin(2 * math.pi * now.month / 12.0)
|
| 449 |
+
feats["month_cos"] = math.cos(2 * math.pi * now.month / 12.0)
|
| 450 |
+
temp = weather.get("temperature_c") or 25.0
|
| 451 |
+
dew = weather.get("dew_point_c") or temp
|
| 452 |
+
feats["dew_point_depression"] = temp - dew
|
| 453 |
+
feats["pressure_change_3h"] = 0.0 # set by historical training; 0 at inference
|
| 454 |
+
feats["precipitation_lag_1h"] = weather.get("precipitation_mm", 0.0) or 0.0
|
| 455 |
+
return feats
|
backend/ml_engine.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ML Predictor wrapper.
|
| 3 |
+
|
| 4 |
+
The trained Random Forest is loaded ONCE at FastAPI startup (lifespan)
|
| 5 |
+
and held in memory — never reload inside a request handler.
|
| 6 |
+
|
| 7 |
+
When the model artefact is missing we fall back to a physically-motivated
|
| 8 |
+
heuristic so the API still runs end-to-end before `scripts/3_train_model.py`
|
| 9 |
+
has been executed. The heuristic deliberately uses the same feature names
|
| 10 |
+
as the trained model so swapping between them is transparent to callers.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import math
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
import joblib
|
| 21 |
+
|
| 22 |
+
from . import config
|
| 23 |
+
|
| 24 |
+
log = logging.getLogger("microclimate-x.ml")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class MLEngine:
|
| 28 |
+
"""Thin, defensive wrapper around the joblibbed RandomForestClassifier.
|
| 29 |
+
|
| 30 |
+
Invariant: ``predict_rain_probability`` ALWAYS returns a float in [0, 1].
|
| 31 |
+
Any internal failure logs and falls through to the heuristic.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self) -> None:
|
| 35 |
+
self.model: Any | None = None
|
| 36 |
+
self.feature_columns: list[str] = []
|
| 37 |
+
self.loaded_from: str | None = None
|
| 38 |
+
self.training_report: dict[str, Any] | None = None
|
| 39 |
+
|
| 40 |
+
# ── Load --------------------------------------------------------
|
| 41 |
+
def load(self) -> None:
|
| 42 |
+
model_path = config.MODEL_DIR / "rf_model.pkl"
|
| 43 |
+
features_path = config.MODEL_DIR / "feature_columns.json"
|
| 44 |
+
report_path = config.MODEL_DIR / "training_report.json"
|
| 45 |
+
|
| 46 |
+
if not (model_path.exists() and features_path.exists()):
|
| 47 |
+
self.model = None
|
| 48 |
+
self.loaded_from = None
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
self.model = joblib.load(model_path)
|
| 53 |
+
self.feature_columns = json.loads(features_path.read_text())
|
| 54 |
+
self.loaded_from = str(model_path)
|
| 55 |
+
if report_path.exists():
|
| 56 |
+
self.training_report = json.loads(report_path.read_text())
|
| 57 |
+
log.info(
|
| 58 |
+
"loaded RF model with %d features (%s)",
|
| 59 |
+
len(self.feature_columns), Path(model_path).name,
|
| 60 |
+
)
|
| 61 |
+
except Exception as exc: # pragma: no cover — defensive
|
| 62 |
+
log.exception("Failed to load trained model: %s", exc)
|
| 63 |
+
self.model = None
|
| 64 |
+
self.loaded_from = None
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def is_loaded(self) -> bool:
|
| 68 |
+
return self.model is not None
|
| 69 |
+
|
| 70 |
+
# ── Predict -----------------------------------------------------
|
| 71 |
+
def predict_rain_probability(self, feats: dict[str, float]) -> float:
|
| 72 |
+
"""Return P(rain in next hour) ∈ [0, 1]."""
|
| 73 |
+
if self.is_loaded:
|
| 74 |
+
try:
|
| 75 |
+
X = [[self._safe_feat(feats, col) for col in self.feature_columns]]
|
| 76 |
+
p = float(self.model.predict_proba(X)[0, 1])
|
| 77 |
+
return min(1.0, max(0.0, p))
|
| 78 |
+
except Exception as exc: # pragma: no cover
|
| 79 |
+
log.exception("RF inference failed (%s) — falling back to heuristic.", exc)
|
| 80 |
+
return self._fallback_heuristic(feats)
|
| 81 |
+
|
| 82 |
+
# ── Helpers -----------------------------------------------------
|
| 83 |
+
@staticmethod
|
| 84 |
+
def _safe_feat(feats: dict[str, float], col: str) -> float:
|
| 85 |
+
v = feats.get(col, 0.0)
|
| 86 |
+
if v is None:
|
| 87 |
+
return 0.0
|
| 88 |
+
try:
|
| 89 |
+
f = float(v)
|
| 90 |
+
except (TypeError, ValueError):
|
| 91 |
+
return 0.0
|
| 92 |
+
if math.isnan(f) or math.isinf(f):
|
| 93 |
+
return 0.0
|
| 94 |
+
return f
|
| 95 |
+
|
| 96 |
+
@staticmethod
|
| 97 |
+
def _fallback_heuristic(f: dict[str, float]) -> float:
|
| 98 |
+
"""Smooth, physically-motivated proxy used when no trained model
|
| 99 |
+
exists yet. Uses the same feature inputs as the trained model so the
|
| 100 |
+
downstream rule engine sees no behaviour change."""
|
| 101 |
+
humidity = MLEngine._safe_get(f, "humidity_pct", 60.0)
|
| 102 |
+
dew_dep = MLEngine._safe_get(f, "dew_point_depression", 5.0)
|
| 103 |
+
cloud = MLEngine._safe_get(f, "cloud_cover_pct", 50.0)
|
| 104 |
+
cape = MLEngine._safe_get(f, "cape_jkg", 0.0)
|
| 105 |
+
prev = MLEngine._safe_get(f, "precipitation_lag_1h", 0.0)
|
| 106 |
+
pres_dp = MLEngine._safe_get(f, "pressure_change_3h", 0.0)
|
| 107 |
+
|
| 108 |
+
z = (
|
| 109 |
+
0.05 * (humidity - 70.0)
|
| 110 |
+
- 0.22 * dew_dep
|
| 111 |
+
+ 0.02 * (cloud - 50.0)
|
| 112 |
+
+ 0.0015 * cape
|
| 113 |
+
+ 1.50 * (1.0 if prev > 0.1 else 0.0)
|
| 114 |
+
- 0.30 * pres_dp # falling pressure → more rain
|
| 115 |
+
)
|
| 116 |
+
return 1.0 / (1.0 + math.exp(-z))
|
| 117 |
+
|
| 118 |
+
@staticmethod
|
| 119 |
+
def _safe_get(d: dict[str, float], k: str, default: float) -> float:
|
| 120 |
+
v = d.get(k, default)
|
| 121 |
+
if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
|
| 122 |
+
return default
|
| 123 |
+
try:
|
| 124 |
+
return float(v)
|
| 125 |
+
except (TypeError, ValueError):
|
| 126 |
+
return default
|
backend/rule_engine.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Topographic Rule-Based Expert System — Engine B of the hybrid architecture.
|
| 3 |
+
|
| 4 |
+
This module is structured to mirror D5 proposal §3.7 / P4 so it is auditable
|
| 5 |
+
against the thesis section by section:
|
| 6 |
+
|
| 7 |
+
P4.1 Load Dynamic Risk Rules → constants in backend/config.py
|
| 8 |
+
P4.2 Fetch User Context (activity) → `evaluate(activity=…)` parameter
|
| 9 |
+
P4.3 Evaluate Environmental Risks → four `score_*_risk()` functions
|
| 10 |
+
(rainfall / fog / wind_gust / thunderstorm)
|
| 11 |
+
P4.4 Apply Activity-Specific Weight → `apply_activity_weighting()`
|
| 12 |
+
P4.5 Calculate Composite Risk Score → weighted sum + Veto cap
|
| 13 |
+
P4.6 Generate Actionable Advice → bilingual advice helpers
|
| 14 |
+
|
| 15 |
+
In parallel, the Veto cascade (life-safety overrides) and the D5 §3.7.2
|
| 16 |
+
Table 4.2 Decision Table run alongside the composite score.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from dataclasses import dataclass, field
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from . import config
|
| 24 |
+
from .schemas import (
|
| 25 |
+
ActivityType,
|
| 26 |
+
DecisionTableMatch,
|
| 27 |
+
HazardSubscores,
|
| 28 |
+
InferenceStep,
|
| 29 |
+
RiskLevel,
|
| 30 |
+
VetoTrigger,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class RuleResult:
|
| 36 |
+
risk_score: int = 0
|
| 37 |
+
risk_level: RiskLevel = "Safe"
|
| 38 |
+
veto_triggers: list[VetoTrigger] = field(default_factory=list)
|
| 39 |
+
inference_log: list[InferenceStep] = field(default_factory=list)
|
| 40 |
+
advice_en: str = ""
|
| 41 |
+
advice_zh: str = ""
|
| 42 |
+
hazard_subscores: HazardSubscores = field(
|
| 43 |
+
default_factory=lambda: HazardSubscores(rainfall=0, fog=0, wind_gust=0, thunderstorm=0)
|
| 44 |
+
)
|
| 45 |
+
decision_table_matches: list[DecisionTableMatch] = field(default_factory=list)
|
| 46 |
+
activity: ActivityType = "general"
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def has_veto(self) -> bool:
|
| 50 |
+
return len(self.veto_triggers) > 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _bin_level(score: int) -> RiskLevel:
|
| 54 |
+
if score >= 80:
|
| 55 |
+
return "Danger"
|
| 56 |
+
if score >= 55:
|
| 57 |
+
return "Warning"
|
| 58 |
+
if score >= 30:
|
| 59 |
+
return "Caution"
|
| 60 |
+
return "Safe"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _clip(x: float) -> int:
|
| 64 |
+
return max(0, min(100, round(x)))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 68 |
+
# P4.3 — Four Hazard Sub-Scorers (each returns 0-100)
|
| 69 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 70 |
+
|
| 71 |
+
def score_rainfall_risk(
|
| 72 |
+
*, ml_rain_prob: float, terrain: str, orographic_dot: float,
|
| 73 |
+
pressure_change_3h: float, humidity_pct: float,
|
| 74 |
+
) -> int:
|
| 75 |
+
"""Rainfall sub-score. Backbone is ML probability; terrain amplifies.
|
| 76 |
+
|
| 77 |
+
Calibration: ml_rain_prob 0.45 on flat terrain should yield ~40
|
| 78 |
+
(matching the proposal's intuition that 45 % probability already warrants
|
| 79 |
+
a 'Caution' verdict)."""
|
| 80 |
+
s = ml_rain_prob * 55.0 # baseline 0-55 from ML
|
| 81 |
+
if ml_rain_prob >= 0.70:
|
| 82 |
+
s += 20.0 # high-confidence rain bonus
|
| 83 |
+
elif ml_rain_prob >= 0.40:
|
| 84 |
+
s += 12.0
|
| 85 |
+
if terrain == "Valley":
|
| 86 |
+
s += 8.0
|
| 87 |
+
elif terrain == "Slope":
|
| 88 |
+
s += orographic_dot * 25.0 # up to +25 on a windward slope
|
| 89 |
+
if pressure_change_3h <= -1.5: # storm-precursor pressure fall
|
| 90 |
+
s += 8.0
|
| 91 |
+
if humidity_pct >= 90.0:
|
| 92 |
+
s += 6.0
|
| 93 |
+
return _clip(s)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def score_fog_risk(
|
| 97 |
+
*, humidity_pct: float, dew_point_depression: float,
|
| 98 |
+
cloud_cover_pct: float, terrain: str, elevation_m: float,
|
| 99 |
+
) -> int:
|
| 100 |
+
"""Fog sub-score. Saturated boundary layer + heavy low cloud + a basin
|
| 101 |
+
or slope that traps the radiation/advection fog."""
|
| 102 |
+
if dew_point_depression > 5.0:
|
| 103 |
+
return _clip(humidity_pct - 80.0) # near-zero unless very humid
|
| 104 |
+
|
| 105 |
+
s = 0.0
|
| 106 |
+
# Humidity → saturation contribution.
|
| 107 |
+
if humidity_pct >= config.FOG_HUMIDITY_PCT:
|
| 108 |
+
s += 55.0
|
| 109 |
+
elif humidity_pct >= 90.0:
|
| 110 |
+
s += 25.0
|
| 111 |
+
elif humidity_pct >= 85.0:
|
| 112 |
+
s += 10.0
|
| 113 |
+
|
| 114 |
+
# Dew-point depression: smaller = closer to saturation.
|
| 115 |
+
if dew_point_depression <= config.FOG_DEW_DEP_MAX_C:
|
| 116 |
+
s += 25.0
|
| 117 |
+
elif dew_point_depression <= 3.5:
|
| 118 |
+
s += 12.0
|
| 119 |
+
|
| 120 |
+
# Low cloud cover suggests a low-lying cloud deck = potential fog when
|
| 121 |
+
# cloud base meets terrain.
|
| 122 |
+
if cloud_cover_pct >= 90.0:
|
| 123 |
+
s += 10.0
|
| 124 |
+
elif cloud_cover_pct >= 70.0:
|
| 125 |
+
s += 5.0
|
| 126 |
+
|
| 127 |
+
# Terrain modifier: valleys trap radiation fog; high peaks intersect cloud base.
|
| 128 |
+
if terrain == "Valley":
|
| 129 |
+
s += 10.0
|
| 130 |
+
elif terrain == "Peak" and elevation_m >= 1500.0:
|
| 131 |
+
s += 8.0
|
| 132 |
+
|
| 133 |
+
return _clip(s)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def score_wind_gust_risk(
|
| 137 |
+
*, wind_speed_kmh: float, terrain: str, slope_deg: float,
|
| 138 |
+
orographic_dot: float,
|
| 139 |
+
) -> int:
|
| 140 |
+
"""Wind gust sub-score. Sustained wind × topographic acceleration."""
|
| 141 |
+
if wind_speed_kmh < config.GUST_WIND_MIN_KMH * 0.6:
|
| 142 |
+
# Calm conditions — even ridges won't produce dangerous gusts.
|
| 143 |
+
return _clip(wind_speed_kmh)
|
| 144 |
+
|
| 145 |
+
# Baseline: linear in sustained wind, saturating at the gale Veto level.
|
| 146 |
+
s = (wind_speed_kmh / config.GALE_WIND_KMH) * 55.0
|
| 147 |
+
|
| 148 |
+
# Topographic acceleration on ridges and exposed slopes.
|
| 149 |
+
if terrain in {"Peak", "Slope"}:
|
| 150 |
+
s += min(slope_deg, 30.0) # up to +30 for very steep slopes
|
| 151 |
+
if terrain == "Slope" and abs(orographic_dot) >= 0.5:
|
| 152 |
+
s += 8.0 # pass / saddle wind funnel
|
| 153 |
+
|
| 154 |
+
return _clip(s)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def score_thunderstorm_risk(
|
| 158 |
+
*, cape_jkg: float, pressure_change_3h: float, humidity_pct: float,
|
| 159 |
+
) -> int:
|
| 160 |
+
"""Thunderstorm sub-score. Atmospheric instability + storm precursors."""
|
| 161 |
+
s = 0.0
|
| 162 |
+
|
| 163 |
+
# CAPE — primary indicator. Linear up to NWS "strong instability" 2500 J/kg.
|
| 164 |
+
if cape_jkg >= config.HIGH_CAPE_JKG:
|
| 165 |
+
s += 60.0
|
| 166 |
+
elif cape_jkg >= config.THUNDER_CAPE_MIN_JKG:
|
| 167 |
+
s += 35.0 + (cape_jkg - config.THUNDER_CAPE_MIN_JKG) / 20.0
|
| 168 |
+
elif cape_jkg >= 200.0:
|
| 169 |
+
s += 12.0
|
| 170 |
+
|
| 171 |
+
# Falling pressure precedes convective initiation.
|
| 172 |
+
if pressure_change_3h <= config.THUNDER_PRESSURE_DROP:
|
| 173 |
+
s += 20.0
|
| 174 |
+
elif pressure_change_3h <= -1.0:
|
| 175 |
+
s += 8.0
|
| 176 |
+
|
| 177 |
+
# Humidity gates whether instability can actually produce a thunderstorm.
|
| 178 |
+
if humidity_pct >= 80.0:
|
| 179 |
+
s += 10.0
|
| 180 |
+
|
| 181 |
+
return _clip(s)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 185 |
+
# D5 §3.7.2 / Table 4.2 — Decision Table R1-R4
|
| 186 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 187 |
+
|
| 188 |
+
def apply_decision_table_3_7_2(
|
| 189 |
+
*,
|
| 190 |
+
macro_rain_prob: float,
|
| 191 |
+
humidity_pct: float,
|
| 192 |
+
wind_into_slope: bool,
|
| 193 |
+
terrain: str,
|
| 194 |
+
pressure_change_3h: float,
|
| 195 |
+
cloud_base_m: float | None,
|
| 196 |
+
) -> list[DecisionTableMatch]:
|
| 197 |
+
"""Returns the list of decision-table rules (R1-R4) that fired.
|
| 198 |
+
One-to-one match against D5 §3.7.2 Table 4.2."""
|
| 199 |
+
|
| 200 |
+
terrain_kind = "WindwardSlope" if (terrain == "Slope" and wind_into_slope) else \
|
| 201 |
+
"LeewardOrValley" if terrain in {"Valley"} or (terrain == "Slope" and not wind_into_slope) else \
|
| 202 |
+
terrain
|
| 203 |
+
|
| 204 |
+
matches: list[DecisionTableMatch] = []
|
| 205 |
+
for rule_id, rule in config.DECISION_TABLE_3_7_2.items():
|
| 206 |
+
ok = True
|
| 207 |
+
if rule["macro_rain_prob_max"] is not None and macro_rain_prob > rule["macro_rain_prob_max"]:
|
| 208 |
+
ok = False
|
| 209 |
+
if rule["macro_rain_prob_min"] is not None and macro_rain_prob < rule["macro_rain_prob_min"]:
|
| 210 |
+
ok = False
|
| 211 |
+
if rule["humidity_min_pct"] is not None and humidity_pct < rule["humidity_min_pct"]:
|
| 212 |
+
ok = False
|
| 213 |
+
if rule["wind_into_slope"] is not None and wind_into_slope != rule["wind_into_slope"]:
|
| 214 |
+
ok = False
|
| 215 |
+
if rule["terrain"] is not None and terrain_kind != rule["terrain"]:
|
| 216 |
+
ok = False
|
| 217 |
+
if rule["pressure_change_3h_max"] is not None and pressure_change_3h > rule["pressure_change_3h_max"]:
|
| 218 |
+
ok = False
|
| 219 |
+
if rule["cloud_base_max_m"] is not None and (cloud_base_m is None or cloud_base_m > rule["cloud_base_max_m"]):
|
| 220 |
+
ok = False
|
| 221 |
+
if ok:
|
| 222 |
+
matches.append(DecisionTableMatch(
|
| 223 |
+
rule=rule_id,
|
| 224 |
+
description=rule["description"],
|
| 225 |
+
conclusion_en=rule["conclusion_en"],
|
| 226 |
+
conclusion_zh=rule["conclusion_zh"],
|
| 227 |
+
))
|
| 228 |
+
return matches
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 232 |
+
# P4.4 — Activity-aware composite scoring
|
| 233 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 234 |
+
|
| 235 |
+
def apply_activity_weighting(
|
| 236 |
+
subs: HazardSubscores, activity: ActivityType,
|
| 237 |
+
) -> int:
|
| 238 |
+
"""Composite 0-100 score.
|
| 239 |
+
|
| 240 |
+
Design rationale: a naive mean dilutes the dominant hazard — e.g. an
|
| 241 |
+
extreme thunderstorm risk (90) averaged with three safe (10) values
|
| 242 |
+
would yield 30, which understates the actual danger. We therefore use
|
| 243 |
+
a **dominant-hazard + secondary-contribution** formulation:
|
| 244 |
+
|
| 245 |
+
composite = 0.80 · max(weighted sub-scores)
|
| 246 |
+
+ 0.20 · mean(weighted sub-scores excluding max)
|
| 247 |
+
|
| 248 |
+
This ensures the worst hazard for the user's activity drives the score,
|
| 249 |
+
while still allowing multiple moderate hazards to push the score up.
|
| 250 |
+
"""
|
| 251 |
+
w = config.ACTIVITY_WEIGHTS[activity]
|
| 252 |
+
weighted = [
|
| 253 |
+
min(100.0, w["rainfall"] * subs.rainfall),
|
| 254 |
+
min(100.0, w["fog"] * subs.fog),
|
| 255 |
+
min(100.0, w["wind_gust"] * subs.wind_gust),
|
| 256 |
+
min(100.0, w["thunderstorm"] * subs.thunderstorm),
|
| 257 |
+
]
|
| 258 |
+
top = max(weighted)
|
| 259 |
+
rest = sum(weighted) - top
|
| 260 |
+
others_mean = rest / 3.0
|
| 261 |
+
return _clip(top * 0.80 + others_mean * 0.20)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 265 |
+
# Veto cascade (life-safety overrides) — same as before, unchanged behaviour
|
| 266 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 267 |
+
|
| 268 |
+
def _collect_veto_triggers(
|
| 269 |
+
*, elevation_m: float, terrain: str, weather: dict[str, Any],
|
| 270 |
+
ml_rain_prob: float, orographic_dot: float,
|
| 271 |
+
) -> list[VetoTrigger]:
|
| 272 |
+
temp_c = weather.get("temperature_c", 25.0)
|
| 273 |
+
wind_kmh = weather.get("wind_speed_kmh", 0.0)
|
| 274 |
+
cape = weather.get("cape_jkg", 0.0)
|
| 275 |
+
visibility = weather.get("visibility_m", 10000.0)
|
| 276 |
+
out: list[VetoTrigger] = []
|
| 277 |
+
|
| 278 |
+
if elevation_m > config.ALTITUDE_HYPOXIA_M:
|
| 279 |
+
out.append(VetoTrigger(
|
| 280 |
+
rule="altitude_hypoxia", value=elevation_m,
|
| 281 |
+
message_en=f"Altitude {elevation_m:.0f} m exceeds {config.ALTITUDE_HYPOXIA_M:.0f} m — severe hypoxia risk.",
|
| 282 |
+
message_zh=f"海拔 {elevation_m:.0f} m 超过 {config.ALTITUDE_HYPOXIA_M:.0f} m,存在严重缺氧风险。",
|
| 283 |
+
))
|
| 284 |
+
if temp_c <= config.EXTREME_COLD_C:
|
| 285 |
+
out.append(VetoTrigger(
|
| 286 |
+
rule="extreme_cold", value=temp_c,
|
| 287 |
+
message_en=f"Temperature {temp_c:.1f}°C — frostbite risk per UIAA guidance.",
|
| 288 |
+
message_zh=f"温度 {temp_c:.1f}°C,UIAA 指南判定为冻伤风险。",
|
| 289 |
+
))
|
| 290 |
+
if wind_kmh >= config.GALE_WIND_KMH:
|
| 291 |
+
out.append(VetoTrigger(
|
| 292 |
+
rule="gale_wind", value=wind_kmh,
|
| 293 |
+
message_en=f"Wind speed {wind_kmh:.0f} km/h ≥ Beaufort Force 6 — hazardous.",
|
| 294 |
+
message_zh=f"风速 {wind_kmh:.0f} km/h 达到蒲福风级 6 级以上,存在危险。",
|
| 295 |
+
))
|
| 296 |
+
if cape >= config.HIGH_CAPE_JKG:
|
| 297 |
+
out.append(VetoTrigger(
|
| 298 |
+
rule="high_cape_lightning", value=cape,
|
| 299 |
+
message_en=f"CAPE {cape:.0f} J/kg — significant thunderstorm potential.",
|
| 300 |
+
message_zh=f"CAPE {cape:.0f} J/kg,存在显著雷暴风险。",
|
| 301 |
+
))
|
| 302 |
+
if visibility < config.LOW_VISIBILITY_M:
|
| 303 |
+
out.append(VetoTrigger(
|
| 304 |
+
rule="low_visibility", value=visibility,
|
| 305 |
+
message_en=f"Visibility {visibility:.0f} m — whiteout / dense fog.",
|
| 306 |
+
message_zh=f"能见度 {visibility:.0f} m,白毛风或浓雾。",
|
| 307 |
+
))
|
| 308 |
+
if (terrain == "Slope" and orographic_dot >= config.OROGRAPHIC_DOT_THRESHOLD
|
| 309 |
+
and ml_rain_prob >= 0.50):
|
| 310 |
+
out.append(VetoTrigger(
|
| 311 |
+
rule="orographic_lift_storm", value=orographic_dot,
|
| 312 |
+
message_en="Wind impinging on windward slope with high rain probability — enhanced orographic precipitation.",
|
| 313 |
+
message_zh="风向正对迎风坡,叠加高降雨概率,地形抬升强化降水。",
|
| 314 |
+
))
|
| 315 |
+
if terrain == "Valley" and ml_rain_prob >= config.VALLEY_FLOOD_PROB:
|
| 316 |
+
out.append(VetoTrigger(
|
| 317 |
+
rule="valley_flash_flood", value=ml_rain_prob,
|
| 318 |
+
message_en="Valley basin with very high rain probability — flash-flood risk.",
|
| 319 |
+
message_zh="处于山谷盆地且降雨概率极高,存在山洪暴发风险。",
|
| 320 |
+
))
|
| 321 |
+
return out
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 325 |
+
# Top-level entry point — orchestrates P4.2 → P4.3 → P4.4 → P4.5 → P4.6
|
| 326 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 327 |
+
|
| 328 |
+
def evaluate(
|
| 329 |
+
*,
|
| 330 |
+
lat: float,
|
| 331 |
+
lon: float,
|
| 332 |
+
elevation_m: float,
|
| 333 |
+
terrain: str,
|
| 334 |
+
weather: dict[str, Any],
|
| 335 |
+
ml_rain_prob: float,
|
| 336 |
+
slope_deg: float,
|
| 337 |
+
aspect_deg: float,
|
| 338 |
+
orographic_dot: float,
|
| 339 |
+
activity: ActivityType = "general",
|
| 340 |
+
) -> RuleResult:
|
| 341 |
+
"""Apply the full Hybrid scoring + Veto cascade + D5 §3.7 pipeline."""
|
| 342 |
+
result = RuleResult(activity=activity)
|
| 343 |
+
log = result.inference_log
|
| 344 |
+
|
| 345 |
+
log.append(InferenceStep(
|
| 346 |
+
kind="info",
|
| 347 |
+
text_en=f"Inference @ ({lat:.4f}, {lon:.4f}) elev={elevation_m:.0f} m terrain={terrain} activity={activity}",
|
| 348 |
+
text_zh=f"推理位置 ({lat:.4f}, {lon:.4f}) 海拔 {elevation_m:.0f} m 地形 {terrain} 活动���型 {activity}",
|
| 349 |
+
))
|
| 350 |
+
log.append(InferenceStep(
|
| 351 |
+
kind="ml",
|
| 352 |
+
text_en=f"Engine A (Random Forest) — rain probability next hour = {ml_rain_prob:.1%}",
|
| 353 |
+
text_zh=f"引擎 A(随机森林)下一小时降雨概率 = {ml_rain_prob:.1%}",
|
| 354 |
+
))
|
| 355 |
+
|
| 356 |
+
# ── P4.3: Four hazard sub-scores ──
|
| 357 |
+
humidity = weather.get("humidity_pct", 60.0)
|
| 358 |
+
dew_dep = weather.get("dew_point_depression",
|
| 359 |
+
weather.get("temperature_c", 25.0) - weather.get("dew_point_c",
|
| 360 |
+
weather.get("temperature_c", 25.0)))
|
| 361 |
+
pres_dp = weather.get("pressure_change_3h", 0.0)
|
| 362 |
+
cloud = weather.get("cloud_cover_pct", 50.0)
|
| 363 |
+
cape = weather.get("cape_jkg", 0.0)
|
| 364 |
+
wind_kmh = weather.get("wind_speed_kmh", 0.0)
|
| 365 |
+
|
| 366 |
+
subs = HazardSubscores(
|
| 367 |
+
rainfall = score_rainfall_risk(
|
| 368 |
+
ml_rain_prob=ml_rain_prob, terrain=terrain, orographic_dot=orographic_dot,
|
| 369 |
+
pressure_change_3h=pres_dp, humidity_pct=humidity),
|
| 370 |
+
fog = score_fog_risk(
|
| 371 |
+
humidity_pct=humidity, dew_point_depression=dew_dep,
|
| 372 |
+
cloud_cover_pct=cloud, terrain=terrain, elevation_m=elevation_m),
|
| 373 |
+
wind_gust = score_wind_gust_risk(
|
| 374 |
+
wind_speed_kmh=wind_kmh, terrain=terrain,
|
| 375 |
+
slope_deg=slope_deg, orographic_dot=orographic_dot),
|
| 376 |
+
thunderstorm= score_thunderstorm_risk(
|
| 377 |
+
cape_jkg=cape, pressure_change_3h=pres_dp, humidity_pct=humidity),
|
| 378 |
+
)
|
| 379 |
+
result.hazard_subscores = subs
|
| 380 |
+
|
| 381 |
+
log.append(InferenceStep(
|
| 382 |
+
kind="hazard",
|
| 383 |
+
text_en=f"Sub-scores — Rainfall={subs.rainfall} Fog={subs.fog} Gust={subs.wind_gust} Thunder={subs.thunderstorm}",
|
| 384 |
+
text_zh=f"分项评分 — 降雨={subs.rainfall} 雾={subs.fog} 阵风={subs.wind_gust} 雷暴={subs.thunderstorm}",
|
| 385 |
+
))
|
| 386 |
+
|
| 387 |
+
# ── D5 §3.7.2 Decision Table R1-R4 (informational, not score-changing) ──
|
| 388 |
+
wind_into_slope = (terrain == "Slope" and orographic_dot >= 0.3)
|
| 389 |
+
cloud_base_m = weather.get("cloud_base_m")
|
| 390 |
+
if cloud_base_m is None and cloud >= 90.0 and dew_dep <= 2.0:
|
| 391 |
+
cloud_base_m = 600.0 # crude proxy when API doesn't provide cloud base
|
| 392 |
+
|
| 393 |
+
result.decision_table_matches = apply_decision_table_3_7_2(
|
| 394 |
+
macro_rain_prob=ml_rain_prob,
|
| 395 |
+
humidity_pct=humidity,
|
| 396 |
+
wind_into_slope=wind_into_slope,
|
| 397 |
+
terrain=terrain,
|
| 398 |
+
pressure_change_3h=pres_dp,
|
| 399 |
+
cloud_base_m=cloud_base_m,
|
| 400 |
+
)
|
| 401 |
+
for m in result.decision_table_matches:
|
| 402 |
+
log.append(InferenceStep(
|
| 403 |
+
kind="table",
|
| 404 |
+
text_en=f"D5 §3.7.2 {m.rule} fired — {m.conclusion_en}",
|
| 405 |
+
text_zh=f"D5 §3.7.2 {m.rule} 触发 —— {m.conclusion_zh}",
|
| 406 |
+
))
|
| 407 |
+
|
| 408 |
+
# ── Veto cascade (life-safety overrides) ──
|
| 409 |
+
result.veto_triggers = _collect_veto_triggers(
|
| 410 |
+
elevation_m=elevation_m, terrain=terrain, weather=weather,
|
| 411 |
+
ml_rain_prob=ml_rain_prob, orographic_dot=orographic_dot,
|
| 412 |
+
)
|
| 413 |
+
if result.has_veto:
|
| 414 |
+
for v in result.veto_triggers:
|
| 415 |
+
log.append(InferenceStep(kind="veto", text_en=f"VETO: {v.message_en}",
|
| 416 |
+
text_zh=f"否决触发:{v.message_zh}"))
|
| 417 |
+
result.risk_score = 100
|
| 418 |
+
result.risk_level = "Danger"
|
| 419 |
+
result.advice_en, result.advice_zh = _veto_advice(result.veto_triggers)
|
| 420 |
+
log.append(InferenceStep(kind="score",
|
| 421 |
+
text_en="Final risk = 100 (Veto cascade; ML probability overridden).",
|
| 422 |
+
text_zh="最终风险 = 100(一票否决;ML 概率被覆盖)。"))
|
| 423 |
+
return result
|
| 424 |
+
|
| 425 |
+
# ── P4.4 + P4.5: activity-weighted composite score ──
|
| 426 |
+
composite = apply_activity_weighting(subs, activity)
|
| 427 |
+
result.risk_score = composite
|
| 428 |
+
result.risk_level = _bin_level(composite)
|
| 429 |
+
log.append(InferenceStep(
|
| 430 |
+
kind="activity",
|
| 431 |
+
text_en=f"Activity={activity}: weighted composite score = {composite}.",
|
| 432 |
+
text_zh=f"活动类型 {activity}:加权综合评分 = {composite}。",
|
| 433 |
+
))
|
| 434 |
+
|
| 435 |
+
# ── P4.6: bilingual advice ──
|
| 436 |
+
result.advice_en, result.advice_zh = _normal_advice(
|
| 437 |
+
composite, terrain, ml_rain_prob, subs, activity)
|
| 438 |
+
log.append(InferenceStep(kind="score",
|
| 439 |
+
text_en=f"Final risk score = {composite} → {result.risk_level}.",
|
| 440 |
+
text_zh=f"最终风险评分 = {composite} → {result.risk_level}。"))
|
| 441 |
+
return result
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 445 |
+
# P4.6 — Bilingual advice generation
|
| 446 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 447 |
+
|
| 448 |
+
def _veto_advice(triggers: list[VetoTrigger]) -> tuple[str, str]:
|
| 449 |
+
en = "DANGER — do not proceed. " + " ".join(t.message_en for t in triggers)
|
| 450 |
+
zh = "危险 —— 请勿前往。" + " ".join(t.message_zh for t in triggers)
|
| 451 |
+
return en, zh
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def _normal_advice(score: int, terrain: str, ml_prob: float,
|
| 455 |
+
subs: HazardSubscores, activity: ActivityType) -> tuple[str, str]:
|
| 456 |
+
# Pick the dominant hazard to mention specifically.
|
| 457 |
+
by_score = sorted(
|
| 458 |
+
[("Rainfall", "降雨", subs.rainfall),
|
| 459 |
+
("Fog", "雾", subs.fog),
|
| 460 |
+
("Wind gust","阵风", subs.wind_gust),
|
| 461 |
+
("Thunderstorm","雷暴", subs.thunderstorm)],
|
| 462 |
+
key=lambda x: -x[2],
|
| 463 |
+
)
|
| 464 |
+
top_en, top_zh, top_score = by_score[0]
|
| 465 |
+
|
| 466 |
+
if score >= 80:
|
| 467 |
+
en = f"Danger ({top_en} dominant, {top_score}/100): cancel outdoor activity; seek shelter immediately."
|
| 468 |
+
zh = f"危险(主要风险 {top_zh} {top_score}/100):立即取消户外活动,寻找避难所。"
|
| 469 |
+
elif score >= 55:
|
| 470 |
+
en = (f"Warning ({top_en} dominant, {top_score}/100) in {terrain.lower()} terrain "
|
| 471 |
+
f"for activity={activity}. Postpone non-essential travel.")
|
| 472 |
+
zh = f"警告(主要风险 {top_zh} {top_score}/100):{terrain}地形下 {activity} 活动,建议推迟非必要出行。"
|
| 473 |
+
elif score >= 30:
|
| 474 |
+
en = (f"Caution ({top_en} dominant, {top_score}/100): monitor weather closely; "
|
| 475 |
+
f"carry appropriate gear (rain prob {ml_prob:.0%}).")
|
| 476 |
+
zh = f"注意(主要风险 {top_zh} {top_score}/100):密切关注天气,携带适当装备(降雨概率 {ml_prob:.0%})。"
|
| 477 |
+
else:
|
| 478 |
+
en = "Safe: conditions favourable for outdoor activity. Stay aware."
|
| 479 |
+
zh = "安全:当前条件适合户外活动,仍请保持警觉。"
|
| 480 |
+
return en, zh
|
backend/schemas.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request / response schemas — the contract between FE and BE."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Literal
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
TerrainType = Literal["Valley", "Slope", "Flat", "Peak", "Unknown"]
|
| 9 |
+
RiskLevel = Literal["Safe", "Caution", "Warning", "Danger"]
|
| 10 |
+
ActivityType = Literal["hiker", "driver", "construction", "general"]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PredictionRequest(BaseModel):
|
| 14 |
+
latitude: float = Field(..., ge=-90.0, le=90.0, description="WGS84 latitude")
|
| 15 |
+
longitude: float = Field(..., ge=-180.0, le=180.0, description="WGS84 longitude")
|
| 16 |
+
activity: ActivityType = "general"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class VetoTrigger(BaseModel):
|
| 20 |
+
rule: str
|
| 21 |
+
value: float | None
|
| 22 |
+
message_en: str
|
| 23 |
+
message_zh: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class InferenceStep(BaseModel):
|
| 27 |
+
"""One line of the XAI (explainable AI) inference log."""
|
| 28 |
+
kind: Literal["info", "ml", "rule", "veto", "score", "hazard", "table", "activity"]
|
| 29 |
+
text_en: str
|
| 30 |
+
text_zh: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class HazardSubscores(BaseModel):
|
| 34 |
+
"""Per-category risk score 0-100. Matches the four hazard types
|
| 35 |
+
enumerated in the D5 proposal §3.7 (P4.3)."""
|
| 36 |
+
rainfall: int = Field(..., ge=0, le=100)
|
| 37 |
+
fog: int = Field(..., ge=0, le=100)
|
| 38 |
+
wind_gust: int = Field(..., ge=0, le=100)
|
| 39 |
+
thunderstorm: int = Field(..., ge=0, le=100)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class DecisionTableMatch(BaseModel):
|
| 43 |
+
"""A row of D5 §3.7.2 / Table 4.2 that has fired for this request."""
|
| 44 |
+
rule: str # 'R1' | 'R2' | 'R3' | 'R4'
|
| 45 |
+
description: str
|
| 46 |
+
conclusion_en: str
|
| 47 |
+
conclusion_zh: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class PredictionResponse(BaseModel):
|
| 51 |
+
latitude: float
|
| 52 |
+
longitude: float
|
| 53 |
+
elevation_m: float
|
| 54 |
+
terrain: TerrainType
|
| 55 |
+
|
| 56 |
+
ml_rain_probability: float = Field(..., ge=0.0, le=1.0)
|
| 57 |
+
|
| 58 |
+
hazard_subscores: HazardSubscores
|
| 59 |
+
decision_table_matches: list[DecisionTableMatch]
|
| 60 |
+
activity: ActivityType
|
| 61 |
+
|
| 62 |
+
risk_score: int = Field(..., ge=0, le=100)
|
| 63 |
+
risk_level: RiskLevel
|
| 64 |
+
|
| 65 |
+
veto_triggers: list[VetoTrigger]
|
| 66 |
+
inference_log: list[InferenceStep]
|
| 67 |
+
|
| 68 |
+
advice_en: str
|
| 69 |
+
advice_zh: str
|
| 70 |
+
|
| 71 |
+
cached: bool = False
|
| 72 |
+
cache_ttl: int = 0
|
backend/terrain.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DEM-based terrain classification.
|
| 3 |
+
|
| 4 |
+
Given a 3×3 elevation matrix centred on the query point, we classify the
|
| 5 |
+
terrain as Valley / Slope / Flat / Peak and compute the slope vector
|
| 6 |
+
needed for orographic-uplift detection.
|
| 7 |
+
|
| 8 |
+
The classification heuristic follows the **Topographic Position Index
|
| 9 |
+
(TPI)** approach from Weiss (2001) and is the same technique used in the
|
| 10 |
+
microclimate-modelling literature (e.g. Maclean et al., 2018, "Microclima").
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import math
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
import httpx
|
| 18 |
+
|
| 19 |
+
from . import config
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class TerrainInfo:
|
| 24 |
+
elevation_m: float
|
| 25 |
+
terrain: str # "Valley" | "Slope" | "Flat" | "Peak" | "Unknown"
|
| 26 |
+
slope_deg: float # 0-90
|
| 27 |
+
aspect_deg: float # 0-360, direction the slope faces (downhill)
|
| 28 |
+
tpi: float # signed, positive = ridge, negative = valley
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 32 |
+
# DEM fetching
|
| 33 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
def _build_3x3_grid(lat: float, lon: float, step_deg: float = 0.01) -> list[tuple[float, float]]:
|
| 36 |
+
"""Eight neighbours + centre, ordered row-major (NW, N, NE, W, C, E, SW, S, SE).
|
| 37 |
+
|
| 38 |
+
Handles the antimeridian (lon ∈ [-180, 180]) and clips latitudes that
|
| 39 |
+
would walk off the poles. Without this, querying e.g. (89.999, 179.999)
|
| 40 |
+
would produce DEM coordinates that the upstream API rejects.
|
| 41 |
+
"""
|
| 42 |
+
points = []
|
| 43 |
+
for dlat in (+step_deg, 0.0, -step_deg): # north → south
|
| 44 |
+
for dlon in (-step_deg, 0.0, +step_deg): # west → east
|
| 45 |
+
new_lat = max(-90.0, min(90.0, lat + dlat))
|
| 46 |
+
new_lon = lon + dlon
|
| 47 |
+
# Wrap longitudes into (-180, 180] range.
|
| 48 |
+
if new_lon > 180.0:
|
| 49 |
+
new_lon -= 360.0
|
| 50 |
+
elif new_lon < -180.0:
|
| 51 |
+
new_lon += 360.0
|
| 52 |
+
points.append((new_lat, new_lon))
|
| 53 |
+
return points
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def fetch_dem_3x3(lat: float, lon: float, client: httpx.AsyncClient,
|
| 57 |
+
step_deg: float = 0.01) -> list[float]:
|
| 58 |
+
"""Returns 9 elevation values for the 3×3 grid around (lat, lon)."""
|
| 59 |
+
pts = _build_3x3_grid(lat, lon, step_deg)
|
| 60 |
+
locations = "|".join(f"{p[0]},{p[1]}" for p in pts)
|
| 61 |
+
resp = await client.get(
|
| 62 |
+
config.OPEN_TOPO_URL,
|
| 63 |
+
params={"locations": locations},
|
| 64 |
+
timeout=15.0,
|
| 65 |
+
)
|
| 66 |
+
resp.raise_for_status()
|
| 67 |
+
data = resp.json()
|
| 68 |
+
elevations = []
|
| 69 |
+
for r in data.get("results", []):
|
| 70 |
+
e = r.get("elevation")
|
| 71 |
+
# Open-Topo returns None for ocean points and other no-data tiles.
|
| 72 |
+
elevations.append(float(e) if e is not None else 0.0)
|
| 73 |
+
if len(elevations) != 9:
|
| 74 |
+
raise ValueError(
|
| 75 |
+
f"DEM API returned {len(elevations)} cells, expected 9. "
|
| 76 |
+
"Coordinates may be over ocean or outside coverage."
|
| 77 |
+
)
|
| 78 |
+
return elevations
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 82 |
+
# Classification
|
| 83 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 84 |
+
|
| 85 |
+
def classify_terrain(dem9: list[float], cell_size_m: float = 1100.0) -> TerrainInfo:
|
| 86 |
+
"""
|
| 87 |
+
Indices for the 3x3 matrix:
|
| 88 |
+
0 1 2 (NW, N, NE)
|
| 89 |
+
3 4 5 (W, C, E )
|
| 90 |
+
6 7 8 (SW, S, SE)
|
| 91 |
+
"""
|
| 92 |
+
if len(dem9) != 9:
|
| 93 |
+
raise ValueError(f"DEM matrix must be 3x3, got {len(dem9)} cells")
|
| 94 |
+
nw, n, ne, w, c, e, sw, s, se = dem9
|
| 95 |
+
|
| 96 |
+
# Horn's algorithm — surface derivatives.
|
| 97 |
+
dzdx = ((ne + 2 * e + se) - (nw + 2 * w + sw)) / (8 * cell_size_m)
|
| 98 |
+
dzdy = ((sw + 2 * s + se) - (nw + 2 * n + ne)) / (8 * cell_size_m)
|
| 99 |
+
|
| 100 |
+
slope_rad = math.atan(math.hypot(dzdx, dzdy))
|
| 101 |
+
slope_deg = math.degrees(slope_rad)
|
| 102 |
+
|
| 103 |
+
# Aspect: compass bearing pointing DOWNHILL (0=N, 90=E, 180=S, 270=W).
|
| 104 |
+
aspect_rad = math.atan2(dzdy, -dzdx) # math convention
|
| 105 |
+
aspect_deg = (math.degrees(aspect_rad) + 360.0) % 360.0
|
| 106 |
+
|
| 107 |
+
# Topographic Position Index (TPI): centre cell minus mean of neighbours.
|
| 108 |
+
neighbours = [nw, n, ne, w, e, sw, s, se]
|
| 109 |
+
tpi = c - sum(neighbours) / 8.0
|
| 110 |
+
|
| 111 |
+
if abs(tpi) < 5 and slope_deg < 5:
|
| 112 |
+
terrain = "Flat"
|
| 113 |
+
elif tpi < -10:
|
| 114 |
+
terrain = "Valley"
|
| 115 |
+
elif tpi > 20:
|
| 116 |
+
terrain = "Peak"
|
| 117 |
+
else:
|
| 118 |
+
terrain = "Slope"
|
| 119 |
+
|
| 120 |
+
return TerrainInfo(
|
| 121 |
+
elevation_m=c,
|
| 122 |
+
terrain=terrain,
|
| 123 |
+
slope_deg=slope_deg,
|
| 124 |
+
aspect_deg=aspect_deg,
|
| 125 |
+
tpi=tpi,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def orographic_lift_dot(wind_dir_deg: float, slope_aspect_deg: float,
|
| 130 |
+
slope_deg: float) -> float:
|
| 131 |
+
"""
|
| 132 |
+
Returns a unitless 'orographic uplift' index in [-1, +1].
|
| 133 |
+
|
| 134 |
+
Aspect points DOWNHILL — the slope NORMAL (uphill direction) is the
|
| 135 |
+
opposite bearing. If wind blows opposite to aspect (i.e. into the slope),
|
| 136 |
+
the dot product approaches +1, scaled by slope steepness.
|
| 137 |
+
|
| 138 |
+
A high positive value means wind is being forced upward → enhanced rain
|
| 139 |
+
on the windward face.
|
| 140 |
+
"""
|
| 141 |
+
wind_rad = math.radians(wind_dir_deg)
|
| 142 |
+
uphill_rad = math.radians((slope_aspect_deg + 180.0) % 360.0)
|
| 143 |
+
|
| 144 |
+
# Wind direction in meteorology = direction FROM which wind blows, so
|
| 145 |
+
# the wind-vector pointing direction is (wind_dir + 180°). For the dot
|
| 146 |
+
# product we just need the cosine of the angle between (wind FROM) and
|
| 147 |
+
# (uphill direction).
|
| 148 |
+
cos_angle = math.cos(wind_rad - uphill_rad)
|
| 149 |
+
|
| 150 |
+
# Scale by slope steepness — a 1° slope barely matters.
|
| 151 |
+
return cos_angle * math.sin(math.radians(min(slope_deg, 60.0)))
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
api:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
dockerfile: Dockerfile
|
| 6 |
+
image: microclimate-x:latest
|
| 7 |
+
container_name: microclimate-x
|
| 8 |
+
restart: unless-stopped
|
| 9 |
+
ports:
|
| 10 |
+
- "8000:8000"
|
| 11 |
+
environment:
|
| 12 |
+
# Override Dockerfile default (/tmp, HF-Spaces-friendly) with the named
|
| 13 |
+
# volume so cache.sqlite3 survives container restarts on self-hosting.
|
| 14 |
+
- MICROCLIMATEX_DB=/data/cache.sqlite3
|
| 15 |
+
- MICROCLIMATEX_GIT_REV=${MICROCLIMATEX_GIT_REV:-docker}
|
| 16 |
+
volumes:
|
| 17 |
+
- mcx-data:/data
|
| 18 |
+
healthcheck:
|
| 19 |
+
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:8000/api/health',timeout=2).status==200 else sys.exit(1)"]
|
| 20 |
+
interval: 30s
|
| 21 |
+
timeout: 5s
|
| 22 |
+
retries: 3
|
| 23 |
+
start_period: 10s
|
| 24 |
+
|
| 25 |
+
volumes:
|
| 26 |
+
mcx-data:
|
docs/DEPLOY_HF.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying MicroClimate-X to Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
> 一次部署,永久公网 URL,导师随时可看,不用挂笔记本。
|
| 4 |
+
> One-time deploy → permanent public URL your supervisor can open any time, no laptop required.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Why Hugging Face Spaces?
|
| 9 |
+
|
| 10 |
+
* **Free** persistent hosting for ML demos (CPU tier is enough for this project).
|
| 11 |
+
* **Docker SDK** — reuses the existing `Dockerfile` 1:1, no platform-specific build hacks.
|
| 12 |
+
* **Server-side Git LFS** — the 217 MB `rf_model.pkl` uploads through `huggingface-cli`
|
| 13 |
+
without needing local `git-lfs` installed.
|
| 14 |
+
* The HF Space URL (`https://huggingface.co/spaces/<user>/microclimate-x`) is *the*
|
| 15 |
+
canonical demo URL for ML thesis projects in 2026.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## Architecture on HF Spaces
|
| 20 |
+
|
| 21 |
+
```
|
| 22 |
+
┌──────────────────────────────────────────────────────────────┐
|
| 23 |
+
│ https://huggingface.co/spaces/<user>/microclimate-x │
|
| 24 |
+
│ │
|
| 25 |
+
│ Docker image (this repo's Dockerfile) │
|
| 26 |
+
│ ├─ FastAPI @ :8000 (declared via README.md `app_port`) │
|
| 27 |
+
│ ├─ /app/frontend/ ── mounted at /app/ │
|
| 28 |
+
│ ├─ /app/models/ ── rf_model.pkl baked in │
|
| 29 |
+
│ └─ /tmp/cache.sqlite3 (ephemeral — fine for demo) │
|
| 30 |
+
│ │
|
| 31 |
+
│ ↓ outbound HTTP │
|
| 32 |
+
│ ├─ api.open-meteo.com (weather, ERA5) │
|
| 33 |
+
│ └─ api.opentopodata.org/srtm30m (DEM) │
|
| 34 |
+
└──────────────────────────────────────────────────────────────┘
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
The frontend talks to its own origin via relative `/api/…` URLs, so no CORS
|
| 38 |
+
fiddling and no front-end edits are needed.
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## 3-step deploy
|
| 43 |
+
|
| 44 |
+
### Step 1 — Create the Space (web UI, one minute)
|
| 45 |
+
|
| 46 |
+
1. Go to <https://huggingface.co/new-space>
|
| 47 |
+
2. Fill in:
|
| 48 |
+
* **Owner**: your HF account
|
| 49 |
+
* **Space name**: `microclimate-x`
|
| 50 |
+
* **License**: MIT
|
| 51 |
+
* **Space SDK**: **Docker** → "Blank" template
|
| 52 |
+
* **Hardware**: CPU basic (free)
|
| 53 |
+
* **Visibility**: Public
|
| 54 |
+
3. Click **Create Space**. You'll land on an empty repo page.
|
| 55 |
+
|
| 56 |
+
### Step 2 — Authenticate the CLI (one minute)
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
# Install once
|
| 60 |
+
pip install -U "huggingface_hub[cli]"
|
| 61 |
+
|
| 62 |
+
# Get a token at https://huggingface.co/settings/tokens
|
| 63 |
+
# - Type: Write
|
| 64 |
+
# - Scope: read + write to the new Space
|
| 65 |
+
huggingface-cli login
|
| 66 |
+
# (paste the token when prompted)
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### Step 3 — Push (one command)
|
| 70 |
+
|
| 71 |
+
From the repo root:
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
./scripts/deploy_hf.sh <hf-username>/microclimate-x
|
| 75 |
+
# e.g.
|
| 76 |
+
./scripts/deploy_hf.sh KyoukoLi/microclimate-x
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
The script uploads:
|
| 80 |
+
|
| 81 |
+
* `backend/`, `frontend/`, `scripts/`, `models/`, `docs/`
|
| 82 |
+
* `Dockerfile`, `requirements.txt`, `README.md`, `LICENSE`, `pyproject.toml`
|
| 83 |
+
|
| 84 |
+
and skips local-only junk (`data/`, `figures/`, `tests/`, `.venv/`, SQLite caches, …).
|
| 85 |
+
|
| 86 |
+
HF then runs the same `Dockerfile` you use locally. Build takes ≈ 3–5 min the first time
|
| 87 |
+
(the 217 MB model upload dominates) and < 1 min for subsequent deploys.
|
| 88 |
+
|
| 89 |
+
When the Space's status badge turns green ("Running"), the URL
|
| 90 |
+
`https://huggingface.co/spaces/<user>/microclimate-x` is live. Send that to your supervisor.
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## Updating the demo after code changes
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
git commit -am "fix: …"
|
| 98 |
+
./scripts/deploy_hf.sh KyoukoLi/microclimate-x
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
The script diffs against the remote, so only changed files are re-uploaded.
|
| 102 |
+
A code-only change (no model retrain) is a < 10 s push.
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## Troubleshooting
|
| 107 |
+
|
| 108 |
+
| Symptom | Likely cause | Fix |
|
| 109 |
+
|---|---|---|
|
| 110 |
+
| Build log: `unable to open database file` | `MICROCLIMATEX_DB` points to a read-only path on HF | Dockerfile already sets it to `/tmp/cache.sqlite3`. Make sure your Space doesn't override it under **Settings → Variables and secrets**. |
|
| 111 |
+
| Build log: `port 7860 expected, got 8000` | HF didn't parse the `app_port` frontmatter | Confirm `README.md` starts with the YAML block including `app_port: 8000`. |
|
| 112 |
+
| App loads but every request → 502 | Outbound calls to Open-Meteo / Open Topo Data hit HF's egress timeout | Increase `httpx.AsyncClient(timeout=…)` in `backend/main.py` (currently 15 s). |
|
| 113 |
+
| ML predictor banner says "heuristic" | `models/rf_model.pkl` wasn't uploaded | Re-run `./scripts/deploy_hf.sh`; the script prompts before continuing without the model. |
|
| 114 |
+
| LFS quota exceeded | Free HF accounts get 5 GB LFS — we use ~220 MB | Delete old commits' LFS objects under **Files & versions → Settings**. |
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## Optional: GitHub Actions auto-sync
|
| 119 |
+
|
| 120 |
+
If you'd rather have `git push origin main` auto-deploy to HF, add this workflow:
|
| 121 |
+
|
| 122 |
+
```yaml
|
| 123 |
+
# .github/workflows/sync-to-hf.yml
|
| 124 |
+
name: Sync to Hugging Face Space
|
| 125 |
+
on:
|
| 126 |
+
push:
|
| 127 |
+
branches: [main]
|
| 128 |
+
jobs:
|
| 129 |
+
sync:
|
| 130 |
+
runs-on: ubuntu-latest
|
| 131 |
+
steps:
|
| 132 |
+
- uses: actions/checkout@v4
|
| 133 |
+
with: { fetch-depth: 0 }
|
| 134 |
+
- uses: actions/setup-python@v5
|
| 135 |
+
with: { python-version: "3.12" }
|
| 136 |
+
- run: pip install -U "huggingface_hub[cli]"
|
| 137 |
+
- run: |
|
| 138 |
+
echo "${{ secrets.HF_TOKEN }}" | huggingface-cli login --token "$(cat -)"
|
| 139 |
+
./scripts/deploy_hf.sh KyoukoLi/microclimate-x
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
Add `HF_TOKEN` to the GitHub repo's secrets. Now every push to `main`
|
| 143 |
+
re-deploys the Space.
|
docs/MEETING_CHEAT_SHEET.html
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>Supervisor Meeting Cheat Sheet — MicroClimate-X</title>
|
| 6 |
+
<style>
|
| 7 |
+
/* ============================================================
|
| 8 |
+
Print-optimised A4 cheat sheet — open in browser, ⌘+P → PDF
|
| 9 |
+
============================================================ */
|
| 10 |
+
:root {
|
| 11 |
+
--ink: #0b0d12;
|
| 12 |
+
--ink-soft: #353a44;
|
| 13 |
+
--muted: #6b7280;
|
| 14 |
+
--brand: #2563eb;
|
| 15 |
+
--brand-soft: #dbeafe;
|
| 16 |
+
--accent: #b91c1c;
|
| 17 |
+
--accent-soft: #fee2e2;
|
| 18 |
+
--ok: #166534;
|
| 19 |
+
--ok-soft: #dcfce7;
|
| 20 |
+
--warn: #b45309;
|
| 21 |
+
--warn-soft: #fef3c7;
|
| 22 |
+
--grid: #e5e7eb;
|
| 23 |
+
--bg: #ffffff;
|
| 24 |
+
--code-bg: #f3f4f6;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
* { box-sizing: border-box; }
|
| 28 |
+
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); }
|
| 29 |
+
body {
|
| 30 |
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
| 31 |
+
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
| 32 |
+
system-ui, sans-serif;
|
| 33 |
+
font-size: 11pt;
|
| 34 |
+
line-height: 1.45;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* A4 page sizing */
|
| 38 |
+
@page { size: A4; margin: 12mm 14mm; }
|
| 39 |
+
|
| 40 |
+
main { max-width: 200mm; margin: 0 auto; padding: 14mm 14mm; }
|
| 41 |
+
|
| 42 |
+
/* Headings */
|
| 43 |
+
h1 {
|
| 44 |
+
font-size: 22pt; margin: 0 0 4mm 0;
|
| 45 |
+
border-bottom: 3px solid var(--brand); padding-bottom: 3mm;
|
| 46 |
+
page-break-after: avoid;
|
| 47 |
+
}
|
| 48 |
+
h1 .zh { display: block; font-size: 13pt; color: var(--muted); font-weight: 500; margin-top: 1mm; }
|
| 49 |
+
h2 {
|
| 50 |
+
font-size: 14pt; margin: 9mm 0 3mm 0;
|
| 51 |
+
color: var(--brand);
|
| 52 |
+
border-left: 4px solid var(--brand); padding: 1mm 0 1mm 3mm;
|
| 53 |
+
page-break-after: avoid;
|
| 54 |
+
}
|
| 55 |
+
h2 .zh { display: block; font-size: 10pt; color: var(--muted); margin-top: 0.5mm; font-weight: 500; }
|
| 56 |
+
h3 {
|
| 57 |
+
font-size: 11.5pt; margin: 5mm 0 2mm 0; color: var(--ink-soft);
|
| 58 |
+
page-break-after: avoid;
|
| 59 |
+
}
|
| 60 |
+
h4 { font-size: 10.5pt; margin: 3mm 0 1mm 0; color: var(--accent); }
|
| 61 |
+
|
| 62 |
+
/* Paragraphs / lists */
|
| 63 |
+
p, li { margin: 1mm 0; }
|
| 64 |
+
ul, ol { padding-left: 5mm; }
|
| 65 |
+
ul li { margin-bottom: 1mm; }
|
| 66 |
+
|
| 67 |
+
/* Quote / supervisor verbatim */
|
| 68 |
+
.quote {
|
| 69 |
+
background: var(--warn-soft);
|
| 70 |
+
border-left: 3px solid var(--warn);
|
| 71 |
+
padding: 2mm 3mm; margin: 2mm 0;
|
| 72 |
+
font-style: italic; font-size: 10pt;
|
| 73 |
+
}
|
| 74 |
+
.quote::before { content: "🎙️ "; font-style: normal; }
|
| 75 |
+
|
| 76 |
+
/* Bilingual two-column table */
|
| 77 |
+
table.bilingual, table.steps, table.tabs, table {
|
| 78 |
+
border-collapse: collapse; width: 100%; margin: 2mm 0 3mm 0;
|
| 79 |
+
font-size: 10pt;
|
| 80 |
+
}
|
| 81 |
+
table.bilingual td, table.steps td, table.tabs td, table th, table td {
|
| 82 |
+
padding: 1.5mm 2.5mm; vertical-align: top;
|
| 83 |
+
border: 1px solid var(--grid);
|
| 84 |
+
}
|
| 85 |
+
table th {
|
| 86 |
+
background: #f9fafb; font-weight: 600; text-align: left;
|
| 87 |
+
color: var(--ink-soft);
|
| 88 |
+
}
|
| 89 |
+
table.bilingual td.en { width: 50%; }
|
| 90 |
+
table.bilingual td.zh { width: 50%; background: #fafbfc; }
|
| 91 |
+
|
| 92 |
+
/* Inline callouts */
|
| 93 |
+
.callout {
|
| 94 |
+
margin: 2mm 0; padding: 2mm 3mm;
|
| 95 |
+
border-left: 3px solid; border-radius: 1mm;
|
| 96 |
+
font-size: 10pt;
|
| 97 |
+
}
|
| 98 |
+
.callout.warn { background: var(--accent-soft); border-color: var(--accent); }
|
| 99 |
+
.callout.ok { background: var(--ok-soft); border-color: var(--ok); }
|
| 100 |
+
.callout.tip { background: var(--brand-soft); border-color: var(--brand); }
|
| 101 |
+
|
| 102 |
+
.callout-title { font-weight: 700; margin-bottom: 1mm; }
|
| 103 |
+
|
| 104 |
+
/* Code */
|
| 105 |
+
code, pre, kbd {
|
| 106 |
+
font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
| 107 |
+
font-size: 9.5pt;
|
| 108 |
+
}
|
| 109 |
+
code { background: var(--code-bg); padding: 0.3mm 1mm; border-radius: 1mm; }
|
| 110 |
+
pre {
|
| 111 |
+
background: var(--code-bg); padding: 3mm; border-radius: 2mm;
|
| 112 |
+
overflow-x: auto; margin: 2mm 0;
|
| 113 |
+
border: 1px solid var(--grid);
|
| 114 |
+
}
|
| 115 |
+
pre code { background: transparent; padding: 0; }
|
| 116 |
+
|
| 117 |
+
/* Step grids */
|
| 118 |
+
.step {
|
| 119 |
+
display: flex; gap: 3mm;
|
| 120 |
+
margin: 2mm 0;
|
| 121 |
+
align-items: flex-start;
|
| 122 |
+
}
|
| 123 |
+
.step .num {
|
| 124 |
+
flex: 0 0 8mm; width: 8mm; height: 8mm; border-radius: 50%;
|
| 125 |
+
background: var(--brand); color: white; font-weight: 700;
|
| 126 |
+
display: flex; align-items: center; justify-content: center;
|
| 127 |
+
font-size: 11pt;
|
| 128 |
+
}
|
| 129 |
+
.step .body { flex: 1; }
|
| 130 |
+
|
| 131 |
+
/* Demo blocks */
|
| 132 |
+
.demo {
|
| 133 |
+
background: #f0f9ff;
|
| 134 |
+
border: 1px solid #bae6fd;
|
| 135 |
+
border-radius: 2mm;
|
| 136 |
+
padding: 3mm;
|
| 137 |
+
margin: 3mm 0;
|
| 138 |
+
}
|
| 139 |
+
.demo .demo-title { font-weight: 700; color: #075985; margin-bottom: 1mm; }
|
| 140 |
+
|
| 141 |
+
/* Checklist */
|
| 142 |
+
.check { font-family: "SF Mono", Menlo, monospace; font-size: 9.5pt; line-height: 1.7; }
|
| 143 |
+
.check .box { display: inline-block; width: 4mm; }
|
| 144 |
+
|
| 145 |
+
/* Page break helpers */
|
| 146 |
+
.pb { page-break-before: always; }
|
| 147 |
+
.nobreak { page-break-inside: avoid; }
|
| 148 |
+
|
| 149 |
+
/* Footer */
|
| 150 |
+
footer {
|
| 151 |
+
margin-top: 12mm; padding-top: 4mm;
|
| 152 |
+
border-top: 1px solid var(--grid);
|
| 153 |
+
color: var(--muted); font-size: 9pt; text-align: center;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* Print refinements */
|
| 157 |
+
@media print {
|
| 158 |
+
body { font-size: 10pt; }
|
| 159 |
+
h2 { font-size: 13pt; }
|
| 160 |
+
.no-print { display: none; }
|
| 161 |
+
a { color: var(--ink); text-decoration: none; }
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* Toolbar (screen only) */
|
| 165 |
+
.toolbar {
|
| 166 |
+
position: sticky; top: 0; z-index: 100;
|
| 167 |
+
background: var(--brand); color: white;
|
| 168 |
+
padding: 2mm 4mm; display: flex; justify-content: space-between;
|
| 169 |
+
align-items: center; font-size: 10pt;
|
| 170 |
+
}
|
| 171 |
+
.toolbar button {
|
| 172 |
+
background: white; color: var(--brand); border: 0;
|
| 173 |
+
padding: 1.5mm 4mm; border-radius: 1mm; font-weight: 600;
|
| 174 |
+
cursor: pointer; font-size: 10pt;
|
| 175 |
+
}
|
| 176 |
+
.toolbar button:hover { background: #f3f4f6; }
|
| 177 |
+
|
| 178 |
+
/* Section spacing on cover */
|
| 179 |
+
.cover-meta {
|
| 180 |
+
display: flex; gap: 4mm; flex-wrap: wrap;
|
| 181 |
+
margin: 3mm 0;
|
| 182 |
+
color: var(--muted); font-size: 9.5pt;
|
| 183 |
+
}
|
| 184 |
+
.cover-meta span {
|
| 185 |
+
background: var(--code-bg); padding: 0.5mm 2mm; border-radius: 1mm;
|
| 186 |
+
}
|
| 187 |
+
</style>
|
| 188 |
+
</head>
|
| 189 |
+
<body>
|
| 190 |
+
|
| 191 |
+
<div class="toolbar no-print">
|
| 192 |
+
<strong>Supervisor Meeting Cheat Sheet · MicroClimate-X</strong>
|
| 193 |
+
<button onclick="window.print()">🖨 Print / Save as PDF</button>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<main>
|
| 197 |
+
|
| 198 |
+
<h1>Supervisor Meeting Cheat Sheet
|
| 199 |
+
<span class="zh">导师开会一页通 — MicroClimate-X 答辩准备</span>
|
| 200 |
+
</h1>
|
| 201 |
+
|
| 202 |
+
<div class="cover-meta">
|
| 203 |
+
<span>📅 2026-05-11</span>
|
| 204 |
+
<span>🎓 UKM FYP</span>
|
| 205 |
+
<span>🏛️ KyoukoLi/microclimate-x</span>
|
| 206 |
+
<span>✅ CI passing · 97% coverage · 70 tests</span>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<div class="callout tip">
|
| 210 |
+
<div class="callout-title">How to use this cheat sheet · 怎么用这份小抄</div>
|
| 211 |
+
Keep this open on screen during the meeting. Don't read it aloud — glance at the relevant section when needed. Every key sentence is provided in both English and Chinese so you can default to whichever the supervisor speaks at that moment.
|
| 212 |
+
<br><br>
|
| 213 |
+
开会时打开在屏幕上做兜底。<strong>不要照念</strong>——需要时扫一眼对应小节。所有关键句子都给了中英对照,老师用什么语言你就用什么语言。
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
<!-- ===== Section 0: pre-meeting prep ===== -->
|
| 217 |
+
<h2>0 · Before the meeting (10 min before)
|
| 218 |
+
<span class="zh">会前 10 分钟准备</span>
|
| 219 |
+
</h2>
|
| 220 |
+
|
| 221 |
+
<p>Run these in a terminal, in order. <strong>Do not skip any.</strong><br>
|
| 222 |
+
在终端按顺序执行,<strong>一条都不能少</strong>:</p>
|
| 223 |
+
|
| 224 |
+
<pre><code>cd ~/Projects/microclimate-x
|
| 225 |
+
|
| 226 |
+
# 1. Pull latest + verify clean working tree
|
| 227 |
+
git pull && git status # should print "working tree clean"
|
| 228 |
+
|
| 229 |
+
# 2. Start the backend (leave running)
|
| 230 |
+
make run # uvicorn boots on http://localhost:8000
|
| 231 |
+
|
| 232 |
+
# 3. In a NEW terminal: verify API is alive + model is loaded
|
| 233 |
+
curl -s http://localhost:8000/api/health | python3 -m json.tool
|
| 234 |
+
# expect: "status": "ok", "ml_loaded": true</code></pre>
|
| 235 |
+
|
| 236 |
+
<h3>Browser tabs — open in this exact order / 浏览器按顺序开标签页</h3>
|
| 237 |
+
|
| 238 |
+
<table class="tabs">
|
| 239 |
+
<tr><th>#</th><th>URL</th><th>Purpose</th></tr>
|
| 240 |
+
<tr><td>1</td><td><code>file:///…/docs/MEETING_CHEAT_SHEET.html</code></td><td>This cheat sheet (safety net)</td></tr>
|
| 241 |
+
<tr><td>2</td><td><code>github.com/KyoukoLi/microclimate-x</code></td><td>Green CI badge</td></tr>
|
| 242 |
+
<tr><td>3</td><td><code>docs/dataset.md</code></td><td>For Concern #1 + #2</td></tr>
|
| 243 |
+
<tr><td>4</td><td><code>figures/01_roc_curve.png</code></td><td>Concern #4 — ML metrics</td></tr>
|
| 244 |
+
<tr><td>5</td><td><code>figures/03_calibration_curve.png</code></td><td>Calibration</td></tr>
|
| 245 |
+
<tr><td>6</td><td><code>figures/04_threshold_sweep.png</code></td><td>F2 threshold</td></tr>
|
| 246 |
+
<tr><td>7</td><td><code>figures/05_feature_importance.png</code></td><td>What model learned</td></tr>
|
| 247 |
+
<tr><td>8</td><td><code>docs/architecture.md</code></td><td>Rule engine deep-dive</td></tr>
|
| 248 |
+
<tr><td>9</td><td><code>http://localhost:8000/app/</code></td><td><strong>THE APP — OPEN LAST</strong></td></tr>
|
| 249 |
+
<tr><td>10</td><td><code>models/MODEL_CARD.md</code></td><td>Q&A backup</td></tr>
|
| 250 |
+
</table>
|
| 251 |
+
|
| 252 |
+
<div class="callout warn">
|
| 253 |
+
<strong>🚨 Tab 9 must be opened LAST.</strong> If you accidentally show the app first, the supervisor will instantly remember last meeting's complaint ("app is last") and you lose credibility before you've said a word.<br>
|
| 254 |
+
<strong>🚨 标签 9(app)一定要最后打开。</strong>不小心先打开 app,老师会立刻想起上次 "app is last" 的批评——还没开口就掉分。
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
<!-- ===== Section 1: Opening ===== -->
|
| 258 |
+
<h2>1 · Opening (30 seconds)
|
| 259 |
+
<span class="zh">开场 30 秒</span>
|
| 260 |
+
</h2>
|
| 261 |
+
|
| 262 |
+
<table class="bilingual">
|
| 263 |
+
<tr>
|
| 264 |
+
<td class="en">"Sir, since our last meeting I have addressed every point of your feedback. May I walk you through them in the correct order — <strong>dataset first, then model, then app</strong> — as you instructed?"</td>
|
| 265 |
+
<td class="zh">"老师,按您上次反馈,我已经把每一条都改了。我按您要求的顺序——<strong>先 dataset,再 model,最后才是 app</strong>——给您过一遍可以吗?"</td>
|
| 266 |
+
</tr>
|
| 267 |
+
</table>
|
| 268 |
+
|
| 269 |
+
<div class="callout ok">
|
| 270 |
+
<strong>Why this works · 为什么有效</strong>: it directly quotes his words back to him. Watch him relax immediately.<br>
|
| 271 |
+
直接复述了他自己的话——看着他立刻放松。
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<!-- ===== Section 2: Concern #1 ===== -->
|
| 275 |
+
<h2 class="pb">2 · Concern #1 — "Y is missing"
|
| 276 |
+
<span class="zh">反馈一 · Y 列缺失</span>
|
| 277 |
+
</h2>
|
| 278 |
+
|
| 279 |
+
<div class="quote">"Y is missing. I don't have the output variable. If you don't have target, you cannot train a machine learning model."</div>
|
| 280 |
+
|
| 281 |
+
<h3>On screen → Tab 3 (<code>docs/dataset.md</code>) → §5 Target label derivation</h3>
|
| 282 |
+
|
| 283 |
+
<pre><code class="python">df['is_rain_event'] = (df['precipitation'].shift(-1) > 0.1).astype(int)</code></pre>
|
| 284 |
+
|
| 285 |
+
<table class="bilingual">
|
| 286 |
+
<tr>
|
| 287 |
+
<td class="en">"Sir, you were right — the raw Open-Meteo CSV has no Y column. I have engineered the target explicitly. The variable is <code>is_rain_event</code>: <strong>1 if precipitation in the next hour exceeds 0.1 mm, else 0</strong>."</td>
|
| 288 |
+
<td class="zh">"老师您说得对,原始 CSV 没有 Y 列。我现在显式构造了目标变量 <code>is_rain_event</code>——<strong>下一小时降雨量 > 0.1 mm 则为 1,否则为 0</strong>。"</td>
|
| 289 |
+
</tr>
|
| 290 |
+
<tr>
|
| 291 |
+
<td class="en">"Three things: <strong>(1)</strong> <code>.shift(-1)</code> uses <strong>future</strong> rain as label — features at hour t predict outcome at t+1h, so no temporal data leakage."</td>
|
| 292 |
+
<td class="zh">"三个要点:<strong>(1)</strong> <code>.shift(-1)</code> 表示用<strong>下一小时</strong>的降雨作标签,特征是 t 时刻、预测 t+1 小时——<strong>无时间泄漏</strong>。"</td>
|
| 293 |
+
</tr>
|
| 294 |
+
<tr>
|
| 295 |
+
<td class="en"><strong>(2)</strong> "0.1 mm matches the <strong>WMO definition of trace precipitation</strong> — not an arbitrary choice."</td>
|
| 296 |
+
<td class="zh"><strong>(2)</strong> "0.1 mm 这个阈值不是我随便定的,对应 <strong>WMO 微量降水标准</strong>。"</td>
|
| 297 |
+
</tr>
|
| 298 |
+
<tr>
|
| 299 |
+
<td class="en"><strong>(3)</strong> "It is <strong>binary classification</strong>, not regression, because the downstream user decision is binary — go or no-go."</td>
|
| 300 |
+
<td class="zh"><strong>(3)</strong> "是<strong>二分类</strong>不是回归,因为下游用户决策本身就是二元的——去 / 不去。"</td>
|
| 301 |
+
</tr>
|
| 302 |
+
</table>
|
| 303 |
+
|
| 304 |
+
<!-- ===== Section 3: Concern #2 ===== -->
|
| 305 |
+
<h2>3 · Concern #2 — "Features don't match Excel"
|
| 306 |
+
<span class="zh">反馈二 · 文档特征和 CSV 列名对不上</span>
|
| 307 |
+
</h2>
|
| 308 |
+
|
| 309 |
+
<div class="quote">"The features that you presented here, not... not mentioned in the Excel. So, it must be matched."</div>
|
| 310 |
+
|
| 311 |
+
<h3>On screen → stay on Tab 3 → scroll <em>up</em> to §4 Schema</h3>
|
| 312 |
+
|
| 313 |
+
<table class="bilingual">
|
| 314 |
+
<tr>
|
| 315 |
+
<td class="en">"Sir, that was also fair. I have rewritten the dataset specification so the documentation lists <strong>exactly the same column names</strong> as the CSV. One-to-one mapping in §4."</td>
|
| 316 |
+
<td class="zh">"老师,这条您也说得对。我已经重写了数据集文档——文档列出的就是 CSV 里的<strong>真实列名</strong>,一一对应,就在第 4 节。"</td>
|
| 317 |
+
</tr>
|
| 318 |
+
<tr>
|
| 319 |
+
<td class="en">"Every row is one CSV column. The 'role' column says whether it is a feature (<strong>X</strong>), the target (<strong>Y</strong>), or metadata."</td>
|
| 320 |
+
<td class="zh">"表里每一行就是 CSV 一列,role 列写明它是 feature(<strong>X</strong>)、target(<strong>Y</strong>)还是 metadata。"</td>
|
| 321 |
+
</tr>
|
| 322 |
+
</table>
|
| 323 |
+
|
| 324 |
+
<!-- ===== Section 4: Concern #3 ===== -->
|
| 325 |
+
<h2>4 · Concern #3 — "Study the data source"
|
| 326 |
+
<span class="zh">反馈三 · 研究数据源本身</span>
|
| 327 |
+
</h2>
|
| 328 |
+
|
| 329 |
+
<div class="quote">"Please study the link. What is the purpose of the dataset? What is design for? What is the output variable?"</div>
|
| 330 |
+
|
| 331 |
+
<h3>On screen → stay on Tab 3 → scroll up to §1-3</h3>
|
| 332 |
+
|
| 333 |
+
<table class="bilingual">
|
| 334 |
+
<tr>
|
| 335 |
+
<td class="en">"I read Open-Meteo's documentation carefully. The dataset is the <strong>ERA5 reanalysis archive</strong> — ECMWF's gold-standard hourly reanalysis."</td>
|
| 336 |
+
<td class="zh">"我把 Open-Meteo 文档仔细读了。我用的是 <strong>ERA5 再分析数据</strong>,ECMWF 出的金标准同化产品。"</td>
|
| 337 |
+
</tr>
|
| 338 |
+
<tr>
|
| 339 |
+
<td class="en">"It is <strong>not a forecast</strong> — it is a physically-consistent reconstruction of past weather. ECMWF themselves use ERA5 to <strong>validate other forecast models</strong>. That makes it the right dataset for ML training: <strong>reliable ground-truth labels</strong>."</td>
|
| 340 |
+
<td class="zh">"它<strong>不是</strong>预报,是对过去天气的物理一致重建。ECMWF 自己拿 ERA5 去<strong>校验别的预报模型</strong>——所以训练 ML 是合适的,<strong>标签是可靠的 ground truth</strong>。"</td>
|
| 341 |
+
</tr>
|
| 342 |
+
<tr>
|
| 343 |
+
<td class="en"><strong>Spatial</strong>: 5 Malaysian mountain sites — Genting, Cameron, Fraser's Hill, Klang Valley, Kinabalu — elevations 100 m to 1865 m, terrain from valley to slope.</td>
|
| 344 |
+
<td class="zh"><strong>空间</strong>:5 个马来西亚山地点位——云顶、金马仑、福隆港、巴生谷、神山——海拔 100 m – 1865 m,地形从山谷到山坡。</td>
|
| 345 |
+
</tr>
|
| 346 |
+
<tr>
|
| 347 |
+
<td class="en"><strong>Temporal</strong>: 5 years, hourly, 175 315 rows total.</td>
|
| 348 |
+
<td class="zh"><strong>时间</strong>:5 年,每小时一行,总共 175 315 行。</td>
|
| 349 |
+
</tr>
|
| 350 |
+
</table>
|
| 351 |
+
|
| 352 |
+
<!-- ===== Section 5: Concern #4 ===== -->
|
| 353 |
+
<h2 class="pb">5 · Concern #4 — "App is the last"
|
| 354 |
+
<span class="zh">反馈四 · App 最后做(最重要!)</span>
|
| 355 |
+
</h2>
|
| 356 |
+
|
| 357 |
+
<div class="quote">"First identify a dataset. And then train the model. And then predict it. Once everything is finished, you can develop the app. <strong>App is the last.</strong>"</div>
|
| 358 |
+
|
| 359 |
+
<div class="callout warn">
|
| 360 |
+
<strong>🚨 This is the most important section.</strong> Pace yourself — 2-3 min total. <strong>Don't open the app until the end.</strong><br>
|
| 361 |
+
<strong>🚨 这是最重要的一节。</strong>控制节奏,总共 2-3 分钟。<strong>不要提前打开 app。</strong>
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
<div class="step">
|
| 365 |
+
<div class="num">2a</div>
|
| 366 |
+
<div class="body">
|
| 367 |
+
<strong>→ Tab 4 (<code>figures/01_roc_curve.png</code>)</strong>
|
| 368 |
+
<table class="bilingual">
|
| 369 |
+
<tr>
|
| 370 |
+
<td class="en">"Step 2, model training. Test ROC AUC is <strong>0.871</strong> on 35 063 held-out hourly samples. Hold-out is the <strong>last 20 % chronologically</strong>, not random — random splits leak temporal autocorrelation and inflate accuracy by 5-15 pp."</td>
|
| 371 |
+
<td class="zh">"第二步,模型训练。测试集 35 063 行,<strong>ROC AUC = 0.871</strong>。划分用<strong>按时间排序的最后 20%</strong>,不是随机——随机划分会泄漏时间自相关,把准确率虚高 5-15 个百分点。"</td>
|
| 372 |
+
</tr>
|
| 373 |
+
</table>
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
|
| 377 |
+
<div class="step">
|
| 378 |
+
<div class="num">2b</div>
|
| 379 |
+
<div class="body">
|
| 380 |
+
<strong>→ Tab 5 (<code>figures/03_calibration_curve.png</code>)</strong>
|
| 381 |
+
<table class="bilingual">
|
| 382 |
+
<tr>
|
| 383 |
+
<td class="en">"Brier score <strong>0.138</strong> — predicted probabilities are well-calibrated. When the model says 70 %, the actual rate is close to 70 %. No need for Platt scaling or isotonic post-hoc."</td>
|
| 384 |
+
<td class="zh">"Brier 分数 = <strong>0.138</strong>,预测概率<strong>校准良好</strong>——模型说 70% 时实际频率接近 70%。<strong>不需要</strong> Platt scaling 或 isotonic 校准。"</td>
|
| 385 |
+
</tr>
|
| 386 |
+
</table>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
<div class="step">
|
| 391 |
+
<div class="num">2c</div>
|
| 392 |
+
<div class="body">
|
| 393 |
+
<strong>→ Tab 6 (<code>figures/04_threshold_sweep.png</code>)</strong>
|
| 394 |
+
<table class="bilingual">
|
| 395 |
+
<tr>
|
| 396 |
+
<td class="en">"I optimised for <strong>F2 score</strong>, not F1 — this is safety-critical, a missed rain event on a windward slope can cause flash flooding. False negatives are far worse than false positives. F2 weights recall 4× over precision. Optimal τ = <strong>0.20</strong>, F2 = 0.778, <strong>recall 93.4 %</strong>."</td>
|
| 397 |
+
<td class="zh">"我用 <strong>F2 分数</strong>而不是 F1——安全关键场景,<strong>漏报</strong>比误报严重得多。F2 把召回权重设为精度的 4 倍。最优阈值 τ = <strong>0.20</strong>,F2 = 0.778,<strong>召回率 93.4%</strong>。"</td>
|
| 398 |
+
</tr>
|
| 399 |
+
</table>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
|
| 403 |
+
<div class="step">
|
| 404 |
+
<div class="num">2d</div>
|
| 405 |
+
<div class="body">
|
| 406 |
+
<strong>→ Tab 7 (<code>figures/05_feature_importance.png</code>)</strong>
|
| 407 |
+
<table class="bilingual">
|
| 408 |
+
<tr>
|
| 409 |
+
<td class="en">"Top 3 features: previous-hour rain, time-of-day cyclic encoding, 3-hour pressure tendency. These match the meteorology literature — autocorrelation, diurnal cycle, storm precursor. <strong>The model learned physically meaningful signal</strong>."</td>
|
| 410 |
+
<td class="zh">"最重要的 3 个特征:上一小时降水、时间周期编码、3 小时气压变化——<strong>跟气象文献吻合</strong>:自相关、日变化、风暴前兆。<strong>模型学到的是物理上有意义的信号</strong>。"</td>
|
| 411 |
+
</tr>
|
| 412 |
+
</table>
|
| 413 |
+
</div>
|
| 414 |
+
</div>
|
| 415 |
+
|
| 416 |
+
<div class="step">
|
| 417 |
+
<div class="num">3</div>
|
| 418 |
+
<div class="body">
|
| 419 |
+
<strong>→ Tab 9 (<code>http://localhost:8000/app/</code>) — FINALLY the app</strong>
|
| 420 |
+
<table class="bilingual">
|
| 421 |
+
<tr>
|
| 422 |
+
<td class="en">"<strong>Now</strong>, Step 3, the app. FastAPI + Vue using the trained model from Step 2 — not a separate model, not a placeholder. Click any coordinate, the system returns the probability and four hazard sub-scores per proposal §3.7."</td>
|
| 423 |
+
<td class="zh">"<strong>现在</strong>第三步,app。FastAPI + Vue 调用刚才<strong>第二步训好的模型</strong>——不是另一个模型、不是占位符。点地图任意一点,系统返回概率和四个分项灾害评分(按开题 §3.7)。"</td>
|
| 424 |
+
</tr>
|
| 425 |
+
</table>
|
| 426 |
+
</div>
|
| 427 |
+
</div>
|
| 428 |
+
|
| 429 |
+
<div class="demo">
|
| 430 |
+
<div class="demo-title">🇲🇾 Demo A — Genting Highlands (in-distribution)</div>
|
| 431 |
+
<ol>
|
| 432 |
+
<li>Click <strong>🇲🇾 Genting Highlands · slope</strong> in the scenario dropdown (top right)</li>
|
| 433 |
+
<li>Wait ~1 second for the loading spinner</li>
|
| 434 |
+
<li>Point to the <strong>risk gauge</strong> (the main number)</li>
|
| 435 |
+
<li>Point to the <strong>4 mini-gauges</strong> below (rainfall / fog / wind / thunderstorm)</li>
|
| 436 |
+
</ol>
|
| 437 |
+
<table class="bilingual">
|
| 438 |
+
<tr>
|
| 439 |
+
<td class="en">"Genting is 1865 m slope. Model gives moderate rain probability, rule engine detects orographic lift on the windward side, composite reflects both. The 4 mini-gauges decompose risk by hazard type — user knows whether to worry about rain, fog, wind, or thunder specifically."</td>
|
| 440 |
+
<td class="zh">"云顶 1865 m 山坡。模型给出中等降雨概率,规则引擎检测到迎风坡地形抬升,最终评分综合两者。4 个 mini-gauge 把风险按类型拆解——用户清楚该担心降雨、雾、风、还是雷暴。"</td>
|
| 441 |
+
</tr>
|
| 442 |
+
</table>
|
| 443 |
+
</div>
|
| 444 |
+
|
| 445 |
+
<div class="demo">
|
| 446 |
+
<div class="demo-title">🏔️ Demo B — Mt Everest (OUT-OF-DISTRIBUTION STRESS TEST)</div>
|
| 447 |
+
<ol>
|
| 448 |
+
<li>Click <strong>🏔️ Mt Everest · 8 848 m (OOD)</strong> in the dropdown</li>
|
| 449 |
+
<li>Wait for the result</li>
|
| 450 |
+
<li>Point to the <strong>Veto triggers</strong> section (red box)</li>
|
| 451 |
+
</ol>
|
| 452 |
+
<table class="bilingual">
|
| 453 |
+
<tr>
|
| 454 |
+
<td class="en">"<strong>This is the critical test.</strong> The model was trained only on Malaysian mountains — it has never seen anything above 2000 m. A pure ML system would give a low probability here and falsely return 'safe'. <strong>A hiker could die.</strong>"</td>
|
| 455 |
+
<td class="zh">"<strong>这是关键测试</strong>。模型只在马来西亚山地训练过——从未见过 2000 m 以上的地点。<strong>纯 ML 系统</strong>会给出低概率然后错误地返回"安全"——<strong>登山者可能因此遇难</strong>。"</td>
|
| 456 |
+
</tr>
|
| 457 |
+
<tr>
|
| 458 |
+
<td class="en">"But the hybrid architecture intervenes: the Veto cascade fires three overrides — altitude > 3500 m triggers hypoxia veto, temperature ≤ −5 °C triggers frostbite veto, wind ≥ 40 km/h triggers gale veto. Composite is <strong>forced to 100 = Danger</strong>, regardless of the ML output. <strong>This is exactly the OOD safety net the rule engine provides.</strong>"</td>
|
| 459 |
+
<td class="zh">"但混合架构介入了:<strong>Veto 级联触发了三个否决</strong>——海拔 > 3500 m(缺氧)、温度 ≤ −5°C(冻伤)、风速 ≥ 40 km/h(大风)。无论 ML 输出什么,综合评分<strong>被强制设为 100 = Danger</strong>。<strong>这就是规则引擎对 OOD 输入的安全网作用</strong>。"</td>
|
| 460 |
+
</tr>
|
| 461 |
+
</table>
|
| 462 |
+
<div class="callout ok" style="margin-top: 2mm;">
|
| 463 |
+
🎯 <strong>The Everest demo is your strongest defensive argument.</strong> Pre-tested in <code>tests/test_rule_engine.py::test_mt_everest_veto_hypoxia</code>.<br>
|
| 464 |
+
🎯 <strong>珠峰演示是你最强的辩护点</strong>。有单元测试覆盖(<code>test_mt_everest_veto_hypoxia</code>)。
|
| 465 |
+
</div>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
<!-- ===== Section 6: Concern #5 ===== -->
|
| 469 |
+
<h2 class="pb">6 · Concern #5 — "Regression or classification?"
|
| 470 |
+
<span class="zh">反馈五 · 回归还是分类</span>
|
| 471 |
+
</h2>
|
| 472 |
+
|
| 473 |
+
<div class="quote">"I don't think this is a classification problem because there is no class label. So I think this is a regression problem."</div>
|
| 474 |
+
|
| 475 |
+
<table class="bilingual">
|
| 476 |
+
<tr>
|
| 477 |
+
<td class="en">"Sir, when you first looked at the raw CSV, no class label existed — regression seemed the only option. I considered both. I chose <strong>binary classification</strong> for three reasons:"</td>
|
| 478 |
+
<td class="zh">"老师,您当时看 CSV 没有 class label,看上去像 regression。我两个都考虑过,最后选了<strong>二分类</strong>,三个理由:"</td>
|
| 479 |
+
</tr>
|
| 480 |
+
<tr>
|
| 481 |
+
<td class="en"><strong>(1)</strong> "Downstream decision is binary — go outside or don't. Regressing mm of rain would still need a threshold to convert to go/no-go — I would have to pick the threshold anyway."</td>
|
| 482 |
+
<td class="zh"><strong>(1)</strong> "下游决策本身就是二元——出门 vs 不出门。即使回归预测毫米数,最后也要拿阈值转成 go/no-go——<strong>那个阈值反正要选</strong>。"</td>
|
| 483 |
+
</tr>
|
| 484 |
+
<tr>
|
| 485 |
+
<td class="en"><strong>(2)</strong> "Classification lets me optimise <strong>F2 score</strong> directly — the right metric for safety-critical recall. I cannot directly optimise F2 on a regression target."</td>
|
| 486 |
+
<td class="zh"><strong>(2)</strong> "做分类才能直接优化 <strong>F2 分数</strong>——安全关键场景下召回比精度更重要,<strong>这个指标只在分类任务下有意义</strong>。"</td>
|
| 487 |
+
</tr>
|
| 488 |
+
<tr>
|
| 489 |
+
<td class="en"><strong>(3)</strong> "But I still expose the <strong>raw probability</strong> in the API response — any downstream component that needs a continuous score (e.g. the rule engine's rainfall sub-scorer) can still use it. <strong>Best of both worlds.</strong>"</td>
|
| 490 |
+
<td class="zh"><strong>(3)</strong> "但 API 还是把<strong>原始概率</strong>暴露出来——下游需要连续分数的组��(例如规则引擎的降雨子评分器)照样能用。<strong>两全其美。</strong>"</td>
|
| 491 |
+
</tr>
|
| 492 |
+
</table>
|
| 493 |
+
|
| 494 |
+
<!-- ===== Section 7: Q&A ===== -->
|
| 495 |
+
<h2>7 · Anticipated Q&A
|
| 496 |
+
<span class="zh">老师可能追问</span>
|
| 497 |
+
</h2>
|
| 498 |
+
|
| 499 |
+
<h3>Q1 — "Why Random Forest and not deep learning / LSTM?" / 为什么不是深度学习?</h3>
|
| 500 |
+
<table class="bilingual">
|
| 501 |
+
<tr><td class="en">"Three reasons. <strong>(1)</strong> Interpretability — feature importance lets me defend predictions. Essential for safety-critical. Neural net is a black box."</td><td class="zh">"三个理由:<strong>(1)</strong> <strong>可解释性</strong>——feature importance 让我能为每个预测辩护,安全关键应用必须,神经网络是黑盒。"</td></tr>
|
| 502 |
+
<tr><td class="en"><strong>(2)</strong> "Data efficiency — with 175 K samples, RF reaches state-of-the-art. LSTM would need an order of magnitude more data to outperform it."</td><td class="zh"><strong>(2)</strong> "<strong>数据效率</strong>——17 万样本下 RF 已经 SOTA,LSTM 需要至少 10 倍数据才能超过它。"</td></tr>
|
| 503 |
+
<tr><td class="en"><strong>(3)</strong> "Inference latency — RF inference is sub-millisecond, our FastAPI+cache architecture depends on it. LSTM would be 10× slower and need GPU at inference."</td><td class="zh"><strong>(3)</strong> "<strong>推理延迟</strong>——RF 推理 < 1 ms,FastAPI+缓存架构依赖这一点;LSTM 至少慢 10 倍且推理时需要 GPU。"</td></tr>
|
| 504 |
+
</table>
|
| 505 |
+
|
| 506 |
+
<h3>Q2 — "How do you handle out-of-distribution input?" / 分布外输入怎么处理?</h3>
|
| 507 |
+
<div class="callout tip"><strong>→ Just show the Mt Everest demo from §5.</strong> That IS the answer. Don't theorise — let the system speak.<br>
|
| 508 |
+
<strong>→ 直接展示第 5 节的珠峰 demo</strong>。那就是答案。不要讲理论——让系统说话。</div>
|
| 509 |
+
|
| 510 |
+
<h3>Q3 — "What is the rule engine's contribution? Could you just use ML alone?" / 规则引擎的贡献?只用 ML 不行吗?</h3>
|
| 511 |
+
<table class="bilingual">
|
| 512 |
+
<tr><td class="en">"Pure ML is statistical — learns averages. But terrain in complex mountains amplifies precipitation locally by <strong>orders of magnitude</strong> (Roe 2005, Annual Rev Earth & Planetary Sciences)."</td><td class="zh">"纯 ML 是统计性的——学的是平均值。但复杂山地的地形把降水<strong>局部放大几个数量级</strong>(Roe 2005, Annual Rev Earth & Planetary Sciences)。"</td></tr>
|
| 513 |
+
<tr><td class="en">"R1 in our decision table captures exactly this: when macro rain probability is low <strong>but</strong> wind impinges on a windward slope with falling pressure, hidden rain risk emerges. ML would say 'safe'; rule engine fires R1 and warns."</td><td class="zh">"决策表 R1 抓的就是这点:宏观降雨概率低、<strong>但</strong>风正对迎风坡且气压下降时——<strong>存在隐藏的降雨风险</strong>。ML 会说"安全";规则引擎触发 R1 警告。"</td></tr>
|
| 514 |
+
<tr><td class="en">"This is the <strong>Neuro-Symbolic AI</strong> paradigm — learn what is learnable, hand-code what is physical."</td><td class="zh">"这就是 <strong>Neuro-Symbolic AI</strong> 范式——能学的让 ML 学,物理规律手工编码。"</td></tr>
|
| 515 |
+
</table>
|
| 516 |
+
|
| 517 |
+
<h3>Q4 — "Cross-validation? Overfitting check?" / 交叉验证?过拟合?</h3>
|
| 518 |
+
<table class="bilingual">
|
| 519 |
+
<tr><td class="en">"Yes, Sir. <strong>Time-series 5-fold CV</strong> on the training portion — not random K-fold (would leak temporal info)."</td><td class="zh">"做了老师,<strong>时间序列 5 折交叉验证</strong>——不是随机 K 折(会泄漏时间信息)。"</td></tr>
|
| 520 |
+
<tr><td class="en">"Fold AUCs range 0.828 to 0.908, mean ≈ 0.858 — close to held-out test AUC 0.871. <strong>Confirms no overfitting to a single temporal slice.</strong>"</td><td class="zh">"各折 AUC 0.828–0.908,均值约 0.858——跟独立测试集 AUC 0.871 非常接近。<strong>没有对某个时间段过拟合</strong>。"</td></tr>
|
| 521 |
+
<tr><td class="en">"All in <code>models/training_report.json</code> and the model card."</td><td class="zh">"全部在 <code>models/training_report.json</code> 和 model card 里。"</td></tr>
|
| 522 |
+
</table>
|
| 523 |
+
|
| 524 |
+
<h3>Q5 — "Real-world validation plan?" / 真实世界怎么验证?</h3>
|
| 525 |
+
<table class="bilingual">
|
| 526 |
+
<tr><td class="en">"Chapter 5: two-pronged. <strong>(1) Hindcast validation</strong> — replay against publicly documented Malaysian floods/landslides from NaDMA archives; check if system would have produced Warning/Danger at the right time."</td><td class="zh">"Chapter 5 两条腿走路:<strong>(1) 历史事件回放</strong>——用 NaDMA 公开的马来西亚洪水/滑坡事件,看系统在事件发生时是否会给出 Warning 或 Danger。"</td></tr>
|
| 527 |
+
<tr><td class="en"><strong>(2) User study</strong> — small panel of mountain hikers compare system's recommendations to their own field judgment over one month. <strong>Both are standard practice in operational meteorology.</strong></td><td class="zh"><strong>(2) 用��研究</strong>——找一小批登山者,一个月内对比系统建议和他们自己的判断。<strong>两种方法都是业务气象学界标准做法</strong>。</td></tr>
|
| 528 |
+
</table>
|
| 529 |
+
|
| 530 |
+
<h3>Q6 — "Risk levels Safe/Caution/Warning/Danger?" / 四个等级怎么定?</h3>
|
| 531 |
+
<table class="bilingual">
|
| 532 |
+
<tr><td class="en">"Thresholds 30 / 55 / 80 on 0-100 composite. Calibrated so the <strong>mean output across training data</strong> falls in mid-Caution — system uses full dynamic range. Each level maps to a different recommended action in bilingual advice."</td><td class="zh">"阈值 0-100 综合分上的 30 / 55 / 80。校准依据:<strong>训练集平均输出</strong>正好落在 Caution 区间中部——系统能用满整个动态范围。每个等级对应不同的双语建议行动。"</td></tr>
|
| 533 |
+
</table>
|
| 534 |
+
|
| 535 |
+
<h3>Q7 — "What if model or API fails in production?" / 生产环境挂了怎么办?</h3>
|
| 536 |
+
<table class="bilingual">
|
| 537 |
+
<tr><td class="en">"<strong>Three layers of graceful degradation.</strong> (1) Model load fails → physics-motivated heuristic. (2) Internal exception → typed <code>ErrorResponse</code> JSON. (3) Rule engine's Veto cascade runs <strong>independently</strong> of ML — even if ML returns garbage, safety thresholds still fire."</td><td class="zh">"<strong>三层降级:</strong>(1) 模型加载失败→<strong>物理启发式</strong>。(2) 内部异常→<strong>类型化的 <code>ErrorResponse</code> JSON</strong>。(3) <strong>规则引擎 Veto 级联独立于 ML</strong>——即使 ML 返回乱码,安全阈值仍触发。"</td></tr>
|
| 538 |
+
</table>
|
| 539 |
+
|
| 540 |
+
<!-- ===== Section 8: Closing ===== -->
|
| 541 |
+
<h2 class="pb">8 · Closing (30 seconds)
|
| 542 |
+
<span class="zh">收尾 30 秒</span>
|
| 543 |
+
</h2>
|
| 544 |
+
|
| 545 |
+
<table class="bilingual">
|
| 546 |
+
<tr>
|
| 547 |
+
<td class="en">"Sir, to summarise: I have addressed every point of your feedback. The missing Y is now derived. Documentation matches the data. Model is trained and evaluated <strong>before</strong> the app. Choice of classification over regression is justified by the safety-critical nature of the application."</td>
|
| 548 |
+
<td class="zh">"老师,总结一下:您每条反馈我都已经回应——Y 已经构造好、文档跟数据完全对齐、模型在 app <strong>之前</strong>就训好并评估过、分类而不是回归是因为应用本身就是安全关键。"</td>
|
| 549 |
+
</tr>
|
| 550 |
+
<tr>
|
| 551 |
+
<td class="en">"Code is on GitHub at <code>KyoukoLi/microclimate-x</code>, CI passing, 97 % test coverage, published model card. <strong>May I have your guidance on the next priorities for Chapter 5?</strong>"</td>
|
| 552 |
+
<td class="zh">"代码在 GitHub <code>KyoukoLi/microclimate-x</code>,CI 全过、测试覆盖率 97%、有完整的 model card。<strong>请问 Chapter 5 接下来您建议我重点做哪部分?</strong>"</td>
|
| 553 |
+
</tr>
|
| 554 |
+
</table>
|
| 555 |
+
|
| 556 |
+
<!-- ===== Section 9: Psychology ===== -->
|
| 557 |
+
<h2>9 · Psychological reminders
|
| 558 |
+
<span class="zh">心理建设 · 老师真正在意什么</span>
|
| 559 |
+
</h2>
|
| 560 |
+
|
| 561 |
+
<div class="step">
|
| 562 |
+
<div class="num">1</div>
|
| 563 |
+
<div class="body">
|
| 564 |
+
<strong>Did you LISTEN to him? / 你听进去他的话了吗?</strong><br>
|
| 565 |
+
He asked "Do you understand my English?" multiple times. Reassure him by <strong>quoting his exact words back</strong> ("as you instructed: dataset first, then model, then app").<br>
|
| 566 |
+
他反复问 "Understand my English?" 用<strong>复述他原话</strong>让他放心。
|
| 567 |
+
</div>
|
| 568 |
+
</div>
|
| 569 |
+
|
| 570 |
+
<div class="step">
|
| 571 |
+
<div class="num">2</div>
|
| 572 |
+
<div class="body">
|
| 573 |
+
<strong>Do you understand basic ML? / 你懂 ML 基础吗?</strong><br>
|
| 574 |
+
He explained X/Y, rows/columns, "if-then is the target" — patiently, like a tutor. <strong>Don't open with hybrid / neuro-symbolic / TPI / CAPE.</strong> Start with: dataset, target, feature, train, predict. <strong>Earn the right</strong> to use fancy vocabulary by first speaking his language.<br>
|
| 575 |
+
<strong>不要上来就抛 hybrid、neuro-symbolic、TPI、CAPE。</strong>先用他的词汇:dataset、target、feature、train、predict。<strong>先证明你懂基础</strong>再升级。
|
| 576 |
+
</div>
|
| 577 |
+
</div>
|
| 578 |
+
|
| 579 |
+
<div class="step">
|
| 580 |
+
<div class="num">3</div>
|
| 581 |
+
<div class="body">
|
| 582 |
+
<strong>Did you follow his process? / 你按他的流程做了吗?</strong><br>
|
| 583 |
+
"App is the last" — he said it <strong>three times</strong>. The visual order in which you open tabs IS the answer. <strong>No app until the very end.</strong><br>
|
| 584 |
+
"app is the last" 他说了三次。<strong>你打开标签页的顺序就是答案</strong>。<strong>绝对不要提前打开 app。</strong>
|
| 585 |
+
</div>
|
| 586 |
+
</div>
|
| 587 |
+
|
| 588 |
+
<h3>Defensive lines if you get stuck / 答不出来时的兜底话术</h3>
|
| 589 |
+
<table>
|
| 590 |
+
<tr><th>Situation</th><th>EN</th><th>ZH</th></tr>
|
| 591 |
+
<tr>
|
| 592 |
+
<td>Don't know answer</td>
|
| 593 |
+
<td>"That is a good question, Sir. I haven't fully worked out the answer yet — may I prepare a written response by next meeting?"</td>
|
| 594 |
+
<td>"老师这是个好问题,我还没完全想清楚——能否下次开会前给您一份书面回复?"</td>
|
| 595 |
+
</tr>
|
| 596 |
+
<tr>
|
| 597 |
+
<td>He challenges a threshold</td>
|
| 598 |
+
<td>"Sir, the threshold is documented in <code>docs/thresholds.md</code> with the academic citation. Let me open it."</td>
|
| 599 |
+
<td>"老师,这个阈值的学术引用在 <code>docs/thresholds.md</code> 里,我打开给您看。"</td>
|
| 600 |
+
</tr>
|
| 601 |
+
<tr>
|
| 602 |
+
<td>"This doesn't match what I expected"</td>
|
| 603 |
+
<td>"Yes Sir — that is exactly what I want to confirm with you. Could you describe what you expected, so I can align?"</td>
|
| 604 |
+
<td>"老师<strong>这正是我想跟您确认的点</strong>——能否说说您预期的样子?我好对齐。"</td>
|
| 605 |
+
</tr>
|
| 606 |
+
</table>
|
| 607 |
+
|
| 608 |
+
<!-- ===== Section 10: Backup ===== -->
|
| 609 |
+
<h2>10 · Backup plan / 设备出问题的备份方案</h2>
|
| 610 |
+
|
| 611 |
+
<table>
|
| 612 |
+
<tr><th>Problem</th><th>Fallback</th><th>中文</th></tr>
|
| 613 |
+
<tr><td>WiFi down</td><td>Synthetic dataset works offline — <code>make synth</code> already ran</td><td>合成数据集已经跑过,本地能演</td></tr>
|
| 614 |
+
<tr><td><code>make run</code> fails</td><td>Show GitHub repo with green CI badge — same artefacts visible there</td><td>直接给 GitHub repo 看 CI 绿勾,artefact 一样能看</td></tr>
|
| 615 |
+
<tr><td>Demo doesn't load</td><td>Use cached responses — recent results in <code>cache.sqlite3</code></td><td>用缓存的结果——最近查询都在 <code>cache.sqlite3</code> 里</td></tr>
|
| 616 |
+
<tr><td>Browser crashes</td><td>Open this cheat sheet on your phone — every key number is here</td><td>手机打开这份 cheat sheet——所有关键数字都在</td></tr>
|
| 617 |
+
</table>
|
| 618 |
+
|
| 619 |
+
<!-- ===== Section 11: Pre-flight ===== -->
|
| 620 |
+
<h2>11 · Pre-flight checklist (60 seconds before)
|
| 621 |
+
<span class="zh">起飞前最后 60 秒自检</span>
|
| 622 |
+
</h2>
|
| 623 |
+
|
| 624 |
+
<div class="check">
|
| 625 |
+
<div><span class="box">☐</span> Laptop ≥ 80 % battery, charger in bag / 笔记本电池 ≥ 80%,充电器在包里</div>
|
| 626 |
+
<div><span class="box">☐</span> <code>make run</code> is running in a terminal (don't close it!) / <code>make run</code> 在另一个终端跑着(不要关!)</div>
|
| 627 |
+
<div><span class="box">☐</span> <code>/api/health</code> returns <code>ml_loaded: true</code> / <code>/api/health</code> 返回 <code>ml_loaded: true</code></div>
|
| 628 |
+
<div><span class="box">☐</span> All 10 browser tabs open in correct order (app is LAST) / 10 个标签页按顺序开好(app 在最后)</div>
|
| 629 |
+
<div><span class="box">☐</span> This cheat sheet open on screen — but NOT to be read word-for-word / 这份 cheat sheet 开着,但不要照念</div>
|
| 630 |
+
<div><span class="box">☐</span> Phone on silent / 手机静音</div>
|
| 631 |
+
<div><span class="box">☐</span> Deep breath. You have done the work. / 深呼吸。你已经做完了所有该做的工作。</div>
|
| 632 |
+
</div>
|
| 633 |
+
|
| 634 |
+
<footer>
|
| 635 |
+
Generated 2026-05-11 · MicroClimate-X · KyoukoLi/microclimate-x ·
|
| 636 |
+
CI passing · 97 % coverage · 70 tests
|
| 637 |
+
<br>
|
| 638 |
+
此页为 2026-05-11 UKM 毕业设计 MicroClimate-X 导师答辩准备文档
|
| 639 |
+
</footer>
|
| 640 |
+
|
| 641 |
+
</main>
|
| 642 |
+
|
| 643 |
+
</body>
|
| 644 |
+
</html>
|
docs/MEETING_CHEAT_SHEET.md
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 📋 Supervisor Meeting Cheat Sheet
|
| 2 |
+
# 📋 导师开会一页通
|
| 3 |
+
|
| 4 |
+
> **Open this on your laptop during the meeting.** Print it if you prefer paper.
|
| 5 |
+
> Everything you need is in this single document.
|
| 6 |
+
>
|
| 7 |
+
> **开会时电脑屏幕开着这一页就够。** 想要纸质版直接打印。
|
| 8 |
+
> 所有内容都在这一份文档里。
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## 🔧 0. Before the meeting (10 minutes before)
|
| 13 |
+
## 🔧 0. 会前 10 分钟准备
|
| 14 |
+
|
| 15 |
+
Run these in a terminal, in order. **Do not skip any.**
|
| 16 |
+
在终端按顺序执行,**一条都不能少**:
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
cd ~/Projects/microclimate-x
|
| 20 |
+
|
| 21 |
+
# 1. Pull latest + verify clean working tree
|
| 22 |
+
git pull && git status # should print "working tree clean"
|
| 23 |
+
|
| 24 |
+
# 2. Start the backend (leave running in this terminal)
|
| 25 |
+
make run # uvicorn boots on http://localhost:8000
|
| 26 |
+
|
| 27 |
+
# 3. In a NEW terminal: verify the API is alive + model is loaded
|
| 28 |
+
curl -s http://localhost:8000/api/health | python3 -m json.tool
|
| 29 |
+
# expect: "status": "ok", "ml_loaded": true
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
If `ml_loaded` is **false**, run `make train` first — but this should already be done.
|
| 33 |
+
如果 `ml_loaded` 显示 **false**,先跑 `make train` —— 但理论上之前已经训好了。
|
| 34 |
+
|
| 35 |
+
### Browser tabs to open in this exact order / 浏览器按顺序开好这几个标签页
|
| 36 |
+
|
| 37 |
+
| # | URL | Purpose |
|
| 38 |
+
|---|---|---|
|
| 39 |
+
| 1 | `file:///…/microclimate-x/docs/MEETING_CHEAT_SHEET.md` (this file) | Your safety net |
|
| 40 |
+
| 2 | `https://github.com/KyoukoLi/microclimate-x` | Show CI green badge |
|
| 41 |
+
| 3 | `file:///…/microclimate-x/docs/dataset.md` | For Concern #1 + #2 |
|
| 42 |
+
| 4 | `file:///…/microclimate-x/figures/01_roc_curve.png` | For Concern #4 step 2 |
|
| 43 |
+
| 5 | `file:///…/microclimate-x/figures/03_calibration_curve.png` | |
|
| 44 |
+
| 6 | `file:///…/microclimate-x/figures/04_threshold_sweep.png` | |
|
| 45 |
+
| 7 | `file:///…/microclimate-x/figures/05_feature_importance.png` | |
|
| 46 |
+
| 8 | `file:///…/microclimate-x/docs/architecture.md` | For rule engine section |
|
| 47 |
+
| 9 | `http://localhost:8000/app/` | **The actual app — OPEN LAST** |
|
| 48 |
+
| 10 | `file:///…/microclimate-x/models/MODEL_CARD.md` | Q&A backup |
|
| 49 |
+
|
| 50 |
+
🚨 **Tab 9 (the app) MUST be opened LAST.** If you accidentally show the app first the supervisor will remember last meeting's complaint ("app is last") and you lose credibility.
|
| 51 |
+
|
| 52 |
+
🚨 **标签 9(app)一定要最后打开。** 不小心先打开 app 老师就会立刻想起上次 "app is last" 的批评。
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
## 🎬 1. Opening (30 seconds)
|
| 57 |
+
## 🎬 1. 开场(30 秒)
|
| 58 |
+
|
| 59 |
+
> **EN**: "Sir, since our last meeting I have addressed every point of your feedback. May I walk you through them in the correct order — **dataset first, then model, then app** — as you instructed?"
|
| 60 |
+
>
|
| 61 |
+
> **ZH**: "老师,按您上次反馈,我已经把每一条都改了。我按您要求的顺序——**先 dataset,再 model,最后才是 app**——给您过一遍可以吗?"
|
| 62 |
+
|
| 63 |
+
**Why this works**: directly quotes his words back to him. He relaxes immediately.
|
| 64 |
+
**为什么有效**:直接复述了他自己的话,他会立刻放松。
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 📊 2. Concern #1 — "Y is missing"
|
| 69 |
+
## 📊 2. 反馈一 —— Y 列缺失
|
| 70 |
+
|
| 71 |
+
**His original words / 老师原话**: *"Y is missing. I don't have the output variable. If you don't have target, you cannot train a machine learning model."*
|
| 72 |
+
|
| 73 |
+
### What to do on screen / 屏幕操作
|
| 74 |
+
|
| 75 |
+
1. Switch to Tab 3 (`docs/dataset.md`)
|
| 76 |
+
2. Scroll to **§5 Target label derivation**
|
| 77 |
+
3. Point to the highlighted code line:
|
| 78 |
+
|
| 79 |
+
```python
|
| 80 |
+
df['is_rain_event'] = (df['precipitation'].shift(-1) > 0.1).astype(int)
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### What to say / 怎么说
|
| 84 |
+
|
| 85 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 86 |
+
|---|---|
|
| 87 |
+
| "Sir, you were right — the raw Open-Meteo CSV has no Y column. I have engineered the target explicitly. The variable is called `is_rain_event` and it is defined as **1 if the precipitation in the next hour is greater than 0.1 mm, else 0**." | "老师您说得对,原始 CSV 没有 Y 列。我现在已经显式构造了目标变量 **`is_rain_event`**——**下一小时降雨量 > 0.1 mm 则为 1,否则为 0**。" |
|
| 88 |
+
| "Three things to notice. First, `.shift(-1)` means I use **future** rain as the label — features at hour t predict outcome at t+1h, so there is no temporal data leakage." | "三个要点:(1) `.shift(-1)` 表示用**下一小时**的降雨作标签,特征是 t 时刻、预测的是 t+1 小时——**无时间泄漏**。" |
|
| 89 |
+
| "Second, the 0.1 mm threshold matches the **WMO definition of trace precipitation** — it is not an arbitrary choice." | "(2) 0.1 mm 这个阈值不是我随便定的,对应 **WMO 微量降水标准**。" |
|
| 90 |
+
| "Third, it is **binary classification**, not regression, because the downstream user decision is binary — go or no-go." | "(3) 是**二分类**不是回归,因为下游用户决策本身就是二元的——去 / 不去。" |
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## 📊 3. Concern #2 — "Features in document don't match Excel"
|
| 95 |
+
## 📊 3. 反馈二 —— 文档里的特征和 CSV 列名对不上
|
| 96 |
+
|
| 97 |
+
**His original words / 老师原话**: *"The features that you presented here, not... not mentioned in the Excel. So, it must be matched."*
|
| 98 |
+
|
| 99 |
+
### What to do on screen / 屏幕操作
|
| 100 |
+
|
| 101 |
+
Stay on Tab 3 (`docs/dataset.md`). Scroll **up** to **§4 Schema**. Show the column table.
|
| 102 |
+
|
| 103 |
+
### What to say / 怎么说
|
| 104 |
+
|
| 105 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 106 |
+
|---|---|
|
| 107 |
+
| "Sir, that was also a fair point. I have rewritten the dataset specification so the documentation lists **exactly the same column names** that appear in the CSV. There is a one-to-one mapping right here in §4." | "老师,这条您也说得对。我已经重写了数据集文档,文档里列出的就是 CSV 里的**真实列名**,一一对应,就在第 4 节这里。" |
|
| 108 |
+
| "Every row in this table is one column in the actual CSV. The 'role' column says whether it is a feature (**X**), the target (**Y**), or just metadata." | "表里每一行就是 CSV 里的一列,role 列写明了它是 feature(**X**)、target(**Y**)还是 metadata。" |
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## 📊 4. Concern #3 — "Study the data source"
|
| 113 |
+
## 📊 4. 反馈三 —— 研究数据源本身
|
| 114 |
+
|
| 115 |
+
**His original words / 老师原话**: *"Please study the link. What is the purpose of the dataset? What is design for? What is the output variable?"*
|
| 116 |
+
|
| 117 |
+
### What to do on screen / 屏幕操作
|
| 118 |
+
|
| 119 |
+
Stay on Tab 3 (`docs/dataset.md`). Scroll back **up** to **§1-3** (Source / Spatial coverage / Temporal coverage).
|
| 120 |
+
|
| 121 |
+
### What to say / 怎么说
|
| 122 |
+
|
| 123 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 124 |
+
|---|---|
|
| 125 |
+
| "I read the Open-Meteo API documentation carefully. The dataset I use is the **ERA5 reanalysis archive**, which is ECMWF's gold-standard hourly reanalysis." | "我把 Open-Meteo 文档仔细读了。我用的是 **ERA5 再分析数据**,是 ECMWF 出的金标准同化产品。" |
|
| 126 |
+
| "It is *not* a forecast — it is a physically-consistent reconstruction of past weather. ECMWF themselves use ERA5 to **validate other forecast models**. That is why it is the right dataset for training ML: the labels are reliable ground truth." | "它**不是**预报,是对过去天气的物理一致的重建。ECMWF 自己用 ERA5 去**校验别的预报模型**——所以拿来训练 ML 是合适的,**标签是可靠的 ground truth**。" |
|
| 127 |
+
| "Spatial coverage: 5 Malaysian mountain sites — Genting, Cameron, Fraser's Hill, Klang Valley, Kinabalu — chosen to span elevations from 100 m to 1865 m and terrain from valley to slope." | "空间覆盖 5 个马来西亚山地点位——云顶、金马仑、福隆港、巴生谷、神山——海拔 100 m 到 1865 m,地形从山谷到山坡都有。" |
|
| 128 |
+
| "Temporal coverage: 5 years, hourly, 175 315 rows total." | "时间范围 5 年,每小时一行,总共 175 315 行。" |
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## 📊 5. Concern #4 — "App is the last"
|
| 133 |
+
## 📊 5. 反馈四 —— App 放在最后做
|
| 134 |
+
|
| 135 |
+
**His original words / 老师原话**: *"First, identify a dataset. And then train the model. And then predict it. Once everything is finished, you can develop the app. App is the last."*
|
| 136 |
+
|
| 137 |
+
🚨 **This is the most important section.** Pace yourself — about 2-3 minutes total. **Don't open the app until the very end.**
|
| 138 |
+
🚨 **这是最重要的一节。** 控制节奏,总共大约 2-3 分钟。**不要提前打开 app。**
|
| 139 |
+
|
| 140 |
+
### Step-by-step on-screen demo / 逐步演示
|
| 141 |
+
|
| 142 |
+
#### Step 2a — ROC curve / 第二步 a:ROC 曲线
|
| 143 |
+
→ Switch to **Tab 4** (`figures/01_roc_curve.png`)
|
| 144 |
+
|
| 145 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 146 |
+
|---|---|
|
| 147 |
+
| "Step 2, model training. Test ROC AUC is **0.871** on 35 063 held-out hourly samples. The hold-out is the **last 20 % chronologically**, not a random split — random splits leak temporal autocorrelation and would inflate accuracy unrealistically by 5-15 percentage points." | "第二步,模型训练。测试集 35 063 行,**ROC AUC = 0.871**。划分用的是**按时间排序的最后 20%**,不是随机划分——随机划分会泄漏时间自相关,把准确率虚高 5-15 个百分点。" |
|
| 148 |
+
|
| 149 |
+
#### Step 2b — Calibration / 第二步 b:校准度
|
| 150 |
+
→ Switch to **Tab 5** (`figures/03_calibration_curve.png`)
|
| 151 |
+
|
| 152 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 153 |
+
|---|---|
|
| 154 |
+
| "Brier score is **0.138**, which means the predicted probabilities are well-calibrated — when the model says 70 % chance of rain, the actual rate is close to 70 %. So I do not need post-hoc calibration like Platt scaling or isotonic regression." | "Brier 分数 = **0.138**,说明预测概率**校准良好**——模型说 70% 概率时,实际频率接近 70%。**不需要额外做** Platt scaling 或 isotonic 校准。" |
|
| 155 |
+
|
| 156 |
+
#### Step 2c — Threshold choice / 第二步 c:阈值选择
|
| 157 |
+
→ Switch to **Tab 6** (`figures/04_threshold_sweep.png`)
|
| 158 |
+
|
| 159 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 160 |
+
|---|---|
|
| 161 |
+
| "I optimised the decision threshold for **F2 score**, not F1, because in this safety-critical application a missed rain event on a windward slope can lead to flash flooding — false negatives are much worse than false positives." | "我用 **F2 分数**而不是 F1 来选最优阈值——因为这是安全关键应用,**漏报**比误报严重得多。" |
|
| 162 |
+
| "F2 weights recall four times more than precision. The optimal threshold is τ = **0.20**, giving F2 = 0.778 and **93.4 % recall**." | "F2 把召回率的权重设为精度的 4 倍。最优阈值是 τ = **0.20**,F2 = 0.778,**召回率 93.4%**。" |
|
| 163 |
+
|
| 164 |
+
#### Step 2d — What the model learned / 第二步 d:模型学到了什么
|
| 165 |
+
→ Switch to **Tab 7** (`figures/05_feature_importance.png`)
|
| 166 |
+
|
| 167 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 168 |
+
|---|---|
|
| 169 |
+
| "Top three features: previous hour's rain, time-of-day cyclic encoding, and 3-hour pressure tendency. These match the meteorological literature — autocorrelation, diurnal cycle, and storm precursor. So the model has learned something physically meaningful." | "模型最看重的 3 个特征:上一小时降水、时间周期编码、3 小时气压变化。**跟气象文献吻合**——自相关、日变化、风暴前兆。模型学到的是**物理上有意义的信号**。" |
|
| 170 |
+
|
| 171 |
+
#### Step 3 — The app (last) / 第三步:App(最后)
|
| 172 |
+
→ Switch to **Tab 9** (`http://localhost:8000/app/`)
|
| 173 |
+
|
| 174 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 175 |
+
|---|---|
|
| 176 |
+
| "**Now**, Step 3, the app. This is FastAPI plus Vue using the trained model from Step 2 — not a separate model, not a placeholder. When I click any coordinate, the system returns the probability and the four hazard sub-scores per proposal §3.7." | "**现在**第三步,app。这是 FastAPI + Vue 调用刚才**第二步训好的模型**——不是另一个模型、也不是占位符。点地图任意一点,系统返回概率和四个分项灾害评分(按开题 §3.7)。" |
|
| 177 |
+
|
| 178 |
+
### Demo scenario A — Genting Highlands (familiar territory)
|
| 179 |
+
### Demo 场景 A —— 云顶高原(训练数据内)
|
| 180 |
+
|
| 181 |
+
1. Click the **🇲🇾 Genting Highlands · slope** option in the scenario dropdown (top right)
|
| 182 |
+
2. Wait ~1 second for the loading spinner to finish
|
| 183 |
+
3. Point to the **risk gauge** (main number)
|
| 184 |
+
4. Point to the **4 mini-gauges** below (rainfall / fog / wind / thunderstorm)
|
| 185 |
+
|
| 186 |
+
| Say in EN | 用中文说 |
|
| 187 |
+
|---|---|
|
| 188 |
+
| "Genting is a slope at 1865 m. The model gives a moderate rain probability, the rule engine picks up orographic lift on the windward side, and the composite score reflects both. The 4 mini-gauges decompose the risk by hazard type so the user knows whether to worry about rain, fog, wind, or thunder specifically." | "云顶是 1865 m 的山坡,模型给出中等降雨概率,规则引擎检测到迎风坡的地形抬升,最终评分综合两者。4 个 mini-gauge 把风险按类型拆解——用户能看出该担心降雨、雾、风、还是雷暴。" |
|
| 189 |
+
|
| 190 |
+
### Demo scenario B — Mt Everest (out-of-distribution stress test)
|
| 191 |
+
### Demo 场景 B —— 珠穆朗玛峰(分布外压力测试)
|
| 192 |
+
|
| 193 |
+
1. Click the **🏔️ Mt Everest · 8 848 m (OOD)** option in the dropdown
|
| 194 |
+
2. Wait for the result
|
| 195 |
+
3. Point to the **Veto triggers** section (blinking red box)
|
| 196 |
+
|
| 197 |
+
| Say in EN | 用中文说 |
|
| 198 |
+
|---|---|
|
| 199 |
+
| "This is the critical test. The model was trained only on Malaysian mountains — it has never seen anything above 2000 m. A pure ML system would give a low probability here and falsely return 'safe', and a hiker could die." | "**这是关键测试**。模型只在马来西亚山地训练过——从未见过 2000 m 以上的地点。**纯 ML 系统**会给出低概率然后错误地返回"安全"——登山者可能因此遇难。" |
|
| 200 |
+
| "But the hybrid architecture intervenes: the Veto cascade fires three independent overrides — altitude > 3500 m triggers hypoxia veto, temperature ≤ −5 °C triggers frostbite veto, wind ≥ 40 km/h triggers gale veto. The composite is forced to 100 = Danger, regardless of the ML output. This is exactly the OOD safety net the rule engine provides." | "但混合架构介入了:**Veto 级联触发了三个独立否决**——海拔 > 3500 m(缺氧)、温度 ≤ −5°C(冻伤)、风速 ≥ 40 km/h(大风)。无论 ML 输出什么,综合评分被强制设为 100 = Danger。**这就是规则引擎对 OOD 输入的安全网作用**。" |
|
| 201 |
+
|
| 202 |
+
🎯 **The Mt Everest demo is your strongest defensive argument.** It's also pre-tested — see `tests/test_rule_engine.py::test_mt_everest_veto_hypoxia`.
|
| 203 |
+
🎯 **珠峰演示是你最强的辩护点**。也是有单元测试覆盖的——见 `test_mt_everest_veto_hypoxia`。
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## 📊 6. Concern #5 — "Regression or classification?"
|
| 208 |
+
## 📊 6. 反馈五 —— 回归还是分类?
|
| 209 |
+
|
| 210 |
+
**His original words / 老师原话**: *"I don't think this is a classification problem because there is no class label. So I think this is a regression problem."*
|
| 211 |
+
|
| 212 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 213 |
+
|---|---|
|
| 214 |
+
| "Sir, when you first looked at the raw CSV, there was no class label, so regression looked like the only option. I considered both. I chose **binary classification** for three reasons:" | "老师,您当时看 CSV 没有 class label,所以看上去像 regression。我两个都考虑过,最后选了**二分类**,三个理由:" |
|
| 215 |
+
| **(1)** "The downstream decision is binary — go outside or don't. Regressing on mm of rain would still need a threshold to convert to a go/no-go output, so I would have to pick the threshold anyway." | **(1)** "下游决策本身就是二元的——出门 vs 不出门。即使做回归预测降雨毫米数,最后也要拿一个阈值转成 go/no-go,**那个阈值反正要选**。" |
|
| 216 |
+
| **(2)** "Classification lets me optimise **F2 score** directly, which is the right metric for safety-critical recall. I cannot directly optimise F2 on a regression target." | **(2)** "做分类才能直接优化 **F2 分数**——安全关键场景下召回比精度更重要,**这个指标只在分类任务下有意义**。" |
|
| 217 |
+
| **(3)** "But I still expose the **raw probability** in the API response, so any downstream component that needs a continuous score — for example the rule engine's rainfall sub-scorer — can still use it. So I keep the best of both worlds." | **(3)** "但 API 还是把**原始概率**暴露出来了,下游需要连续分数的组件(比如规则引擎的降雨子评分器)照样能用。**两全其美**。" |
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## 🛡️ 7. Anticipated follow-up Q&A
|
| 222 |
+
## 🛡️ 7. 老师可能追问的问题
|
| 223 |
+
|
| 224 |
+
### Q1 — "Why Random Forest and not deep learning / LSTM?"
|
| 225 |
+
### Q1 ——为什么选 Random Forest 而不是深度学习 / LSTM?
|
| 226 |
+
|
| 227 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 228 |
+
|---|---|
|
| 229 |
+
| "Three reasons. First, **interpretability** — feature importance lets me defend why the model predicts what it predicts. Essential for safety-critical applications. A neural net is a black box." | "三个理由:(1) **可解释性**——feature importance 让我能为每个预测**辩护**,安全关键应用必须有这一点,神经网络是黑盒。" |
|
| 230 |
+
| "Second, **data efficiency** — with 175 K samples, Random Forest reaches state-of-the-art performance. LSTM would need an order of magnitude more data to outperform it." | "(2) **数据效率**——17 万样本下 RF 已经达到 SOTA,LSTM 需要至少 10 倍数据才能超过它。" |
|
| 231 |
+
| "Third, **inference latency** — RF inference is sub-millisecond, which the FastAPI plus cache architecture depends on. LSTM would be at least 10× slower and require GPU at inference time." | "(3) **推理延迟**——RF 推理 < 1 ms,FastAPI + 缓存架构依赖这一点;LSTM 至少慢 10 倍且推理时需要 GPU。" |
|
| 232 |
+
|
| 233 |
+
### Q2 — "How do you handle out-of-distribution input?"
|
| 234 |
+
### Q2 ——分布外输入怎么处理?
|
| 235 |
+
|
| 236 |
+
**Just show the Mt Everest demo from Section 5 — that IS the answer.**
|
| 237 |
+
**直接展示第 5 节的珠峰 demo —— 那就是答案。**
|
| 238 |
+
|
| 239 |
+
### Q3 — "What is the contribution of the topographic rule engine? Could you just use ML alone?"
|
| 240 |
+
### Q3 ——地形规则引擎的贡献是什么?只用 ML 不行吗?
|
| 241 |
+
|
| 242 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 243 |
+
|---|---|
|
| 244 |
+
| "Pure ML is statistical — it learns averages. But terrain in complex mountainous regions amplifies precipitation locally by **orders of magnitude**, see Roe 2005, *Annual Review of Earth & Planetary Sciences*." | "纯 ML 是统计性的——它学的是平均值。但复杂山地的地形会把降水**局部放大几个数量级**(Roe 2005, Annual Review of Earth & Planetary Sciences)。" |
|
| 245 |
+
| "The R1 rule in our decision table captures exactly this: when macro rain probability is low **but** the wind impinges on a windward slope with falling pressure, hidden rain risk emerges. The ML model would say 'safe' here; the rule engine fires R1 and warns the user." | "我们决策表的 R1 规则抓住的正是这一点:宏观降雨概率低、但风正对迎风坡且气压下降时——**存在隐藏的降雨风险**。ML 在这种情况下会说"安全";规则引擎会触发 R1 警告用户。" |
|
| 246 |
+
| "This is the **Neuro-Symbolic AI** paradigm — learn what is learnable, hand-code what is physical." | "这就是 **Neuro-Symbolic AI** 范式——能学的让 ML 学,物理规律手工编码。" |
|
| 247 |
+
|
| 248 |
+
### Q4 — "Did you do cross-validation? Did you check for overfitting?"
|
| 249 |
+
### Q4 ——做过交叉验证吗?检查过过拟合吗?
|
| 250 |
+
|
| 251 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 252 |
+
|---|---|
|
| 253 |
+
| "Yes Sir, **time-series cross-validation** with 5 folds on the training portion — not random K-fold, which would leak temporal information." | "做了老师,**时间序列交叉验证**,5 折,**不是**随机 K 折——随机划分会泄漏时间信息。" |
|
| 254 |
+
| "The fold AUCs range from 0.828 to 0.908, mean approximately 0.858 — very close to the held-out test AUC of 0.871. This consistency confirms the model is not overfitting to a single temporal slice." | "各折 AUC 在 0.828 到 0.908 之间,均值约 0.858——跟独立测试集 AUC 0.871 非常接近。**说明模型没有对某个时间段过拟合**。" |
|
| 255 |
+
| "All fold metrics are in `models/training_report.json` and the model card." | "所有指标都在 `models/training_report.json` 和 model card 里。" |
|
| 256 |
+
|
| 257 |
+
### Q5 — "How will you validate this in the real world?"
|
| 258 |
+
### Q5 ——你怎么在真实世界验证这套系统?
|
| 259 |
+
|
| 260 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 261 |
+
|---|---|
|
| 262 |
+
| "Two-pronged plan for Chapter 5 evaluation. First, **hindcast validation** — I will replay the system against publicly documented Malaysian flood and landslide events from NaDMA archives and check whether the system would have produced a Warning or Danger verdict at the right time." | "Chapter 5 评估两条腿走路:(1) **历史事件回放**——用 NaDMA 公开记录的马来西亚洪水/滑坡事件,看系统在事件发生时是否会给出 Warning 或 Danger。" |
|
| 263 |
+
| "Second, **user study** — a small panel of mountain hikers will compare the system's recommendations against their own field judgment over a one-month period. Both methodologies follow standard practice in operational meteorology." | "(2) **用户研究**——找一小批登山者,一个月内对比系统建议和他们自己的判断。**两种方法都是业务气象学界的标准做法**。" |
|
| 264 |
+
|
| 265 |
+
### Q6 — "What about the four risk levels — Safe, Caution, Warning, Danger?"
|
| 266 |
+
### Q6 ——四个风险等级(Safe / Caution / Warning / Danger)是怎么定的?
|
| 267 |
+
|
| 268 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 269 |
+
|---|---|
|
| 270 |
+
| "The thresholds are 30 / 55 / 80 on the 0-100 composite score. They are calibrated so that the **mean output across all training data** falls in the middle of the Caution band — that way the system uses its full dynamic range. Each level maps to a different recommended action in the bilingual advice." | "阈值是 0-100 综合分上的 30 / 55 / 80。校准依据:**训练集平均输出**正好落在 Caution 区间中部——这样系统能用满整个动态范围。每个等级对应不同的双语建议行动。" |
|
| 271 |
+
|
| 272 |
+
### Q7 — "What if the API or model fails in production?"
|
| 273 |
+
### Q7 ——生产环境 API 或模型挂了怎么办?
|
| 274 |
+
|
| 275 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 276 |
+
|---|---|
|
| 277 |
+
| "Three layers of graceful degradation. First, if the trained model fails to load, the engine falls back to a physics-motivated heuristic. Second, every internal exception is caught and surfaced as a typed `ErrorResponse` JSON document. Third, the rule engine's Veto cascade runs **independently** of the ML model — even if ML returns garbage, the safety thresholds still fire." | "三层降级:(1) 模型加载失败时回退到**物理启发式**。(2) 所有内部异常被捕获并返回**类型化的 `ErrorResponse` JSON**。(3) **规则引擎的 Veto 级联独立于 ML 模型**——即使 ML 返回乱码,安全阈值仍然会触发。" |
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
## 🎬 8. Closing (30 seconds)
|
| 282 |
+
## 🎬 8. 收尾(30 秒)
|
| 283 |
+
|
| 284 |
+
| 🇬🇧 EN | 🇨🇳 ZH |
|
| 285 |
+
|---|---|
|
| 286 |
+
| "Sir, to summarise: I have addressed every point of your feedback. The missing Y is now derived. The documentation matches the data. The model is trained and evaluated **before** the app. And the choice of classification over regression is justified by the safety-critical nature of the application." | "老师,总结一下:您每条反馈我都已经回应——Y 已经构造好、文档跟数据完全对齐、模型在 app **之前**就训好并评估过、分类而不是回归是因为应用本身就是安全关键。" |
|
| 287 |
+
| "The code is on GitHub at `KyoukoLi/microclimate-x` with CI passing, 97 % test coverage, and a published model card. May I have your guidance on the next priorities for Chapter 5?" | "代码在 GitHub `KyoukoLi/microclimate-x`,CI 全过、测试覆盖率 97%、有完整的 model card。请问 **Chapter 5 接下来您建议我重点做哪部分**?" |
|
| 288 |
+
|
| 289 |
+
---
|
| 290 |
+
|
| 291 |
+
## 🧠 9. Psychological reminders
|
| 292 |
+
## 🧠 9. 心理建设
|
| 293 |
+
|
| 294 |
+
From the meeting recordings, the supervisor cares about three things above all else:
|
| 295 |
+
从录音里可以听出来,老师**最在意三件事**:
|
| 296 |
+
|
| 297 |
+
1. **Did you LISTEN to him?** — He asked "Do you understand my English?" multiple times. Reassure him by **quoting his exact words back** ("as you instructed: dataset first, then model, then app").
|
| 298 |
+
**你听进去他的话了吗?** —— 他反复问 "Understand my English?" 用**复述他原话**让他放心。
|
| 299 |
+
|
| 300 |
+
2. **Do you understand basic ML?** — He explained X/Y, rows/columns, "if-then is the target" — patiently, like a tutor. Don't open with hybrid / neuro-symbolic / TPI / CAPE. Start with: dataset, target, feature, train, predict. **Earn the right** to use fancier vocabulary by first speaking his language.
|
| 301 |
+
**你懂 ML 基础吗?** —— 不要上来就抛 hybrid、neuro-symbolic、TPI、CAPE。**先用他的词汇**:dataset、target、feature、train、predict。**先证明你懂基础**再升级。
|
| 302 |
+
|
| 303 |
+
3. **Did you follow his process?** — "App is the last" three times. The visual order in which you open tabs IS the answer. **No app until the very end.**
|
| 304 |
+
**你按他的流程做了吗?** —— "app is the last" 他说了三次。**你打开标签页的顺序就是答案**。**绝对不要提前打开 app**。
|
| 305 |
+
|
| 306 |
+
### Defensive lines if you get stuck / 答不出来时的兜底话术
|
| 307 |
+
|
| 308 |
+
| Situation | Say (EN) | 说(ZH) |
|
| 309 |
+
|---|---|---|
|
| 310 |
+
| Don't know the answer | "That is a good question, Sir. I haven't fully worked out the answer yet — may I prepare a written response by next meeting?" | "老师这是个好问题,我还没完全想清楚——能否下次开会前��您一份书面回复?" |
|
| 311 |
+
| He challenges a threshold | "Sir, the threshold is documented in `docs/thresholds.md` with the academic citation. Let me open it." | "老师,这个阈值的学术引用在 `docs/thresholds.md` 里,我打开给您看。" |
|
| 312 |
+
| He says "this doesn't match what I expected" | "Yes Sir — that is exactly what I want to confirm with you. Could you describe what you expected so I can align?" | "老师**这正是我想跟您确认的点**——能否说说您预期的样子?我好对齐。" |
|
| 313 |
+
|
| 314 |
+
---
|
| 315 |
+
|
| 316 |
+
## ⚙️ 10. Backup plan if technical issues
|
| 317 |
+
## ⚙️ 10. 设备出问题的备份方案
|
| 318 |
+
|
| 319 |
+
| Problem | Fallback |
|
| 320 |
+
|---|---|
|
| 321 |
+
| WiFi / network down | The synthetic dataset works offline — `make synth` already ran |
|
| 322 |
+
| `make run` fails | Show the GitHub repo with CI green badge instead — the same artefacts are visible there |
|
| 323 |
+
| Demo doesn't load (open-meteo / open-topo-data API blocked) | Use the cached responses — recent results survive in `cache.sqlite3` |
|
| 324 |
+
| Browser crashes | Open this cheat sheet on your phone — every key number / sentence is here |
|
| 325 |
+
| 网络挂了 | 合成数据集已经跑过,本地能演 |
|
| 326 |
+
| `make run` 起不来 | 直接给 GitHub repo 看 CI 绿勾,artefact 一样能看到 |
|
| 327 |
+
| Demo 加载失败 | 用缓存的结果——最近查询都在 `cache.sqlite3` 里 |
|
| 328 |
+
| 浏览器崩了 | 手机打开这份 cheat sheet —— 所有关键数字和句子都在里面 |
|
| 329 |
+
|
| 330 |
+
---
|
| 331 |
+
|
| 332 |
+
## 📐 11. Final pre-flight checklist (do this 60 seconds before walking in)
|
| 333 |
+
## 📐 11. 起飞前最后 60 秒自检
|
| 334 |
+
|
| 335 |
+
```
|
| 336 |
+
☐ Laptop ≥ 80% battery, charger in bag
|
| 337 |
+
☐ make run is running in a terminal (don't close it!)
|
| 338 |
+
☐ http://localhost:8000/api/health returns ml_loaded: true
|
| 339 |
+
☐ All 10 browser tabs open in the order above (#9 — the app — is LAST in the tab bar)
|
| 340 |
+
☐ This cheat sheet open on screen, but NOT to be read word-for-word
|
| 341 |
+
☐ Phone on silent
|
| 342 |
+
☐ Deep breath. You have done the work.
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
```
|
| 346 |
+
☐ 笔记本电池 ≥ 80%,充电器在包里
|
| 347 |
+
☐ make run 在另一个终端跑着(不要关掉!)
|
| 348 |
+
☐ http://localhost:8000/api/health 返回 ml_loaded: true
|
| 349 |
+
☐ 10 个浏览器标签页按上面顺序开好(第 9 个 app 在标签栏最后)
|
| 350 |
+
☐ 这份 cheat sheet 开着,但不要照念
|
| 351 |
+
☐ 手机静音
|
| 352 |
+
☐ 深呼吸。你已经做完了所有该做的工作。
|
| 353 |
+
```
|
| 354 |
+
|
| 355 |
+
---
|
| 356 |
+
|
| 357 |
+
## 📎 Cross-references / 相关文档索引
|
| 358 |
+
|
| 359 |
+
| Topic | File |
|
| 360 |
+
|---|---|
|
| 361 |
+
| Detailed dataset spec | [`docs/dataset.md`](dataset.md) |
|
| 362 |
+
| Architecture deep-dive | [`docs/architecture.md`](architecture.md) |
|
| 363 |
+
| Threshold citations | [`docs/thresholds.md`](thresholds.md) |
|
| 364 |
+
| Pipeline order ASCII chart | [`docs/pipeline_order.md`](pipeline_order.md) |
|
| 365 |
+
| Model card | [`../models/MODEL_CARD.md`](../models/MODEL_CARD.md) |
|
| 366 |
+
| Full thesis-defence brief | [`supervisor_meeting_brief.md`](supervisor_meeting_brief.md) |
|
| 367 |
+
| Evaluation summary JSON | [`../figures/evaluation_summary.json`](../figures/evaluation_summary.json) |
|
| 368 |
+
|
| 369 |
+
---
|
| 370 |
+
|
| 371 |
+
> *Generated 2026-05-11 for the MicroClimate-X final-year-project supervisor meeting at UKM.
|
| 372 |
+
> 此页为 2026-05-11 UKM 毕业设计 MicroClimate-X 导师答辩准备文档。*
|
docs/architecture.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture / 架构
|
| 2 |
+
|
| 3 |
+
## Request flow / 请求流程
|
| 4 |
+
|
| 5 |
+
```
|
| 6 |
+
┌──────────────┐ 1. click(lat,lon) ┌──────────────────────────────┐
|
| 7 |
+
│ Browser │ ─────────────────► │ FastAPI /api/predict │
|
| 8 |
+
│ Vue3 + Map │ │ │
|
| 9 |
+
└──────────────┘ ◄───────────────── │ ┌─────────────────────────┐ │
|
| 10 |
+
6. JSON response │ │ Cache lookup │ │
|
| 11 |
+
│ │ (WAL SQLite, 60-600s) │ │
|
| 12 |
+
│ └────────┬────────────────┘ │
|
| 13 |
+
│ │ miss │
|
| 14 |
+
│ ▼ │
|
| 15 |
+
│ ┌─────────────────────────┐ │
|
| 16 |
+
│ │ 2. Parallel fetch │ │
|
| 17 |
+
│ │ - Open-Meteo (weather) │ │
|
| 18 |
+
│ │ - Open-Topo-Data (DEM) │ │
|
| 19 |
+
│ └────────┬────────────────┘ │
|
| 20 |
+
│ ▼ │
|
| 21 |
+
│ ┌─────────────────────────┐ │
|
| 22 |
+
│ │ 3. Engine A — RandomFor │ │
|
| 23 |
+
│ │ predict_proba → P │ │
|
| 24 |
+
│ └────────┬────────────────┘ │
|
| 25 |
+
│ ▼ │
|
| 26 |
+
│ ┌─────────────────────────┐ │
|
| 27 |
+
│ │ 4. Engine B — Rules │ │
|
| 28 |
+
│ │ ┌───────────────────┐ │ │
|
| 29 |
+
│ │ │ P4.3 four hazard │ │ │
|
| 30 |
+
│ │ │ sub-scorers │ │ │
|
| 31 |
+
│ │ └─────────┬─────────┘ │ │
|
| 32 |
+
│ │ ┌───────────────────┐ │ │
|
| 33 |
+
│ │ │ §3.7.2 decision │ │ │
|
| 34 |
+
│ │ │ table R1-R4 │ │ │
|
| 35 |
+
│ │ └─────────┬─────────┘ │ │
|
| 36 |
+
│ │ ┌───────────────────┐ │ │
|
| 37 |
+
│ │ │ Veto cascade │ │ │
|
| 38 |
+
│ │ └─────────┬─────────┘ │ │
|
| 39 |
+
│ │ ┌───────────────────┐ │ │
|
| 40 |
+
│ │ │ P4.4 activity- │ │ │
|
| 41 |
+
│ │ │ weighted composite│ │ │
|
| 42 |
+
│ │ └─────────┬─────────┘ │ │
|
| 43 |
+
│ │ Bilingual advice │ │
|
| 44 |
+
│ └────────┬───────────────┘ │
|
| 45 |
+
│ ▼ │
|
| 46 |
+
│ ┌─────────────────────────┐ │
|
| 47 |
+
│ │ 5. Cache + audit log │ │
|
| 48 |
+
│ │ risk-adaptive TTL │ │
|
| 49 |
+
│ └────────┬────────────────┘ │
|
| 50 |
+
│ ▼ │
|
| 51 |
+
│ response JSON │
|
| 52 |
+
└──────────────────────────────┘
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## Why "Hybrid"? / 为什么是混合架构?
|
| 56 |
+
|
| 57 |
+
**Failure mode of pure ML**: feed Mt Everest coordinates → trained on tropical Malaysian mountains → predicts ~0 % rain → ignores -30 °C, 80 km/h winds, 8800 m hypoxia → returns "Safe". A hiker dies.
|
| 58 |
+
|
| 59 |
+
**Mitigation**: the Rule Engine is the **safety net**. It encodes physical / medical thresholds that are *true everywhere*, not learned from data. ML provides nuanced in-distribution probability; rules provide bounded out-of-distribution guarantees.
|
| 60 |
+
|
| 61 |
+
This split — learnable component + symbolic component — is the **Neuro-Symbolic AI** paradigm (Garcez & Lamb, 2020).
|
| 62 |
+
|
| 63 |
+
## Engine B internals (D5 proposal §3.7 — P4)
|
| 64 |
+
|
| 65 |
+
Engine B is structured in **one-to-one correspondence** with sub-process §3.7 of the proposal so the thesis chapter can quote line numbers directly:
|
| 66 |
+
|
| 67 |
+
| Proposal section | Code artefact | What it does |
|
| 68 |
+
|---|---|---|
|
| 69 |
+
| **P4.1** Load Dynamic Risk Rules | `backend/config.py` — `DECISION_TABLE_3_7_2`, `ACTIVITY_WEIGHTS`, all `PENALTY_*` / threshold constants | Single source of truth for every threshold, weight, and rule, each annotated with the citation it is derived from. |
|
| 70 |
+
| **P4.2** Fetch User Context | `?activity={hiker,driver,construction,general}` query parameter, plumbed to `evaluate(activity=…)` | Captures who the user is so weights can be applied later. |
|
| 71 |
+
| **P4.3** Evaluate Environmental Risks | Four `score_*_risk()` functions in `rule_engine.py`: rainfall, fog, wind gust, thunderstorm | Each returns a 0-100 sub-score using ML probability + weather + terrain inputs. |
|
| 72 |
+
| **§3.7.2 Table 4.2** Decision Table | `apply_decision_table_3_7_2()` | Returns which of R1-R4 fire (hidden rain on windward slope; no amplification on leeward; heavy downpour incoming; normal rain). Emits an `[table]` line in the XAI log per match. |
|
| 73 |
+
| **Veto cascade** | `_collect_veto_triggers()` | Life-safety overrides (altitude hypoxia, extreme cold, gale wind, high CAPE, low visibility, valley flash-flood, orographic-lift storm). When any fires, composite is capped at 100 and a `Danger` verdict is returned regardless of ML probability. |
|
| 74 |
+
| **P4.4** Activity-Specific Weighting | `apply_activity_weighting()` + `ACTIVITY_WEIGHTS` matrix | Weights per (activity × hazard) pair (e.g. driver weights fog 1.5×, construction weights wind 1.5×). |
|
| 75 |
+
| **P4.5** Composite Risk Score | Same function | Composite = 0.80 · max(weighted sub-scores) + 0.20 · mean(rest). Dominant hazard wins; secondary hazards lift the score modestly. |
|
| 76 |
+
| **P4.6** Actionable Advice | `_normal_advice()` / `_veto_advice()` | Bilingual EN/ZH narrative mentioning the dominant hazard, the terrain, and the activity. |
|
| 77 |
+
|
| 78 |
+
### Why "dominant-hazard composite" instead of a plain weighted sum?
|
| 79 |
+
|
| 80 |
+
A naive arithmetic mean dilutes the dominant hazard — a thunderstorm sub-score of 90 averaged with three sub-scores of 10 would yield only 30, which understates real danger. The dominant-hazard formula gives the **single worst hazard for that user** 80 % of the weight; the remaining 20 % captures the compounding effect when multiple hazards are simultaneously elevated. Per-hazard scores are clipped to 100 before aggregation so a weight > 1 cannot push a single sub-score past saturation.
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
## Module responsibilities
|
| 84 |
+
|
| 85 |
+
| Module | Responsibility |
|
| 86 |
+
|---|---|
|
| 87 |
+
| `backend/main.py` | FastAPI app + lifespan (model load, DB init, HTTP client) |
|
| 88 |
+
| `backend/ml_engine.py` | Load joblib RF, run `predict_proba`; heuristic fallback when no model artefact |
|
| 89 |
+
| `backend/rule_engine.py` | Veto cascade + additive scoring + bilingual advice + XAI log |
|
| 90 |
+
| `backend/terrain.py` | 3×3 DEM fetch, slope/aspect/TPI, orographic-uplift dot product |
|
| 91 |
+
| `backend/cache.py` | WAL-SQLite grid cache, risk-adaptive TTL, inference audit log |
|
| 92 |
+
| `backend/config.py` | Single source of truth for thresholds + academic citations |
|
| 93 |
+
| `backend/schemas.py` | Pydantic v2 request/response contract |
|
| 94 |
+
| `scripts/1_download_dataset.py` | Open-Meteo + Open-Topo-Data ingestion (5 Malaysian sites, 5 years) |
|
| 95 |
+
| `scripts/2_preprocess.py` | Feature engineering + `is_rain_event` label derivation |
|
| 96 |
+
| `scripts/3_train_model.py` | Random Forest + time-based CV + classification report + feature importance |
|
| 97 |
+
| `frontend/index.html` | Single-file Vue3 SPA: Leaflet map, gauge, XAI log, EN/ZH toggle |
|
| 98 |
+
|
| 99 |
+
## Concurrency model
|
| 100 |
+
|
| 101 |
+
* FastAPI is single-event-loop async. All blocking I/O (SQLite) is wrapped in `asyncio.to_thread` so it never stalls the loop.
|
| 102 |
+
* SQLite is opened in **WAL** mode (`PRAGMA journal_mode=WAL`) so readers don't block on writers.
|
| 103 |
+
* `httpx.AsyncClient` is shared across the app via `app.state.http`, instantiated in lifespan.
|
| 104 |
+
* External calls use exponential-backoff retries (`tenacity`) and 15 s timeouts.
|
| 105 |
+
|
| 106 |
+
## Cache strategy
|
| 107 |
+
|
| 108 |
+
A naive fixed TTL is unsafe — a 10-minute-stale "Safe" verdict during a developing storm can kill someone. We use **risk-adaptive TTL**:
|
| 109 |
+
|
| 110 |
+
| Risk score / Veto | TTL |
|
| 111 |
+
|---|---|
|
| 112 |
+
| Any Veto fired, or score ≥ 70 | **60 s** |
|
| 113 |
+
| Score 40-70 | 300 s |
|
| 114 |
+
| Score < 40 | 600 s |
|
| 115 |
+
|
| 116 |
+
Grid key quantises (lat, lon) to ~1.1 km cells (`GRID_RESOLUTION_DEG = 0.01`).
|
docs/dataset.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dataset Specification
|
| 2 |
+
# 数据集说明
|
| 3 |
+
|
| 4 |
+
> The exact dataset structure that the supervisor approved at the 4/15 review.
|
| 5 |
+
> 4 月 15 日导师 review 后确认的数据集结构。
|
| 6 |
+
|
| 7 |
+
## 1. Source / 数据来源
|
| 8 |
+
|
| 9 |
+
| Component | Source | URL |
|
| 10 |
+
|---|---|---|
|
| 11 |
+
| Hourly weather | Open-Meteo Historical Weather API (ECMWF ERA5 reanalysis) | https://open-meteo.com/en/docs/historical-weather-api |
|
| 12 |
+
| Elevation | Open-Topo-Data (SRTM 30 m DEM) | https://www.opentopodata.org/datasets/srtm/ |
|
| 13 |
+
|
| 14 |
+
ERA5 is the gold-standard reanalysis dataset in academic meteorology, providing physically-consistent hourly values from 1940 to present.
|
| 15 |
+
|
| 16 |
+
## 2. Spatial coverage / 空间覆盖
|
| 17 |
+
|
| 18 |
+
Five Malaysian mountain locations, chosen to span a range of elevations and terrain types:
|
| 19 |
+
|
| 20 |
+
| Site | Latitude | Longitude | Approx. elev. | Terrain |
|
| 21 |
+
|---|---|---|---|---|
|
| 22 |
+
| Genting Highlands | 3.4225 | 101.7935 | ~1865 m | Slope |
|
| 23 |
+
| Cameron Highlands | 4.4694 | 101.3776 | ~1500 m | Highland plateau |
|
| 24 |
+
| Fraser's Hill | 3.7256 | 101.7378 | ~1300 m | Slope |
|
| 25 |
+
| Klang Valley | 3.0738 | 101.5183 | ~100 m | Valley floor |
|
| 26 |
+
| Mt Kinabalu (base) | 6.0535 | 116.5586 | ~1800 m | Mountain |
|
| 27 |
+
|
| 28 |
+
## 3. Temporal coverage / 时间范围
|
| 29 |
+
|
| 30 |
+
**2020-01-01 → 2023-12-31**, hourly resolution (one row per hour per site).
|
| 31 |
+
|
| 32 |
+
Expected sample count: 5 sites × 4 years × 365.25 days × 24 hours ≈ **175 320 rows**.
|
| 33 |
+
|
| 34 |
+
## 4. Schema / 列结构
|
| 35 |
+
|
| 36 |
+
| Position | Column | Type | Role | Description |
|
| 37 |
+
|---|---|---|---|---|
|
| 38 |
+
| 0 | `site` | str | meta | Site name |
|
| 39 |
+
| 1 | `latitude` | float | meta | WGS84 |
|
| 40 |
+
| 2 | `longitude` | float | meta | WGS84 |
|
| 41 |
+
| 3 | `elevation_m` | float | **X** | DEM-derived altitude (static per site) |
|
| 42 |
+
| 4 | `time` | datetime | meta | Hourly UTC+8 (Asia/Kuala_Lumpur) |
|
| 43 |
+
| 5 | `temperature_c` | float | **X** | 2 m air temperature |
|
| 44 |
+
| 6 | `humidity_pct` | float | **X** | Relative humidity 0-100 |
|
| 45 |
+
| 7 | `precipitation` | float | (raw) | mm in past hour — used to derive Y |
|
| 46 |
+
| 8 | `wind_speed_kmh` | float | **X** | 10 m wind speed |
|
| 47 |
+
| 9 | `wind_direction_deg` | float | **X** | Direction FROM which wind blows, 0-360° |
|
| 48 |
+
| 10 | `wind_u` | float | **X** | u = speed · sin(dir) |
|
| 49 |
+
| 11 | `wind_v` | float | **X** | v = speed · cos(dir) |
|
| 50 |
+
| 12 | `pressure_hpa` | float | **X** | Surface pressure |
|
| 51 |
+
| 13 | `pressure_change_3h`| float | **X** | Δp over preceding 3 h (storm precursor) |
|
| 52 |
+
| 14 | `dew_point_c` | float | **X** | 2 m dew-point |
|
| 53 |
+
| 15 | `dew_point_depression` | float | **X** | T − T_dew (saturation proxy) |
|
| 54 |
+
| 16 | `cloud_cover_pct` | float | **X** | Total cloud cover 0-100 |
|
| 55 |
+
| 17 | `cape_jkg` | float | **X** | Convective Available Potential Energy |
|
| 56 |
+
| 18 | `precipitation_lag_1h` | float | **X** | Previous hour's precipitation |
|
| 57 |
+
| 19 | `hour_sin`, `hour_cos` | float | **X** | Cyclic encoding of hour-of-day |
|
| 58 |
+
| 20 | `month_sin`, `month_cos` | float | **X** | Cyclic encoding of month (captures monsoon) |
|
| 59 |
+
| 21 | **`is_rain_event`** | **int {0,1}** | **Y** | **1 if `precipitation(t+1h) > 0.1 mm` else 0** |
|
| 60 |
+
|
| 61 |
+
## 5. Target label derivation / 目标标签的衍生
|
| 62 |
+
|
| 63 |
+
This is **THE** column that earlier supervisor feedback flagged as missing in the raw CSV. It is engineered explicitly in `scripts/2_preprocess.py`:
|
| 64 |
+
|
| 65 |
+
```python
|
| 66 |
+
df['is_rain_event'] = (df['precipitation'].shift(-1) > 0.1).astype(int)
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Three things the panel should notice:
|
| 70 |
+
|
| 71 |
+
1. **`.shift(-1)` means future**: features at time `t` are paired with the rain outcome at `t+1h`. The model never sees future data as input — this prevents temporal data leakage.
|
| 72 |
+
2. **0.1 mm threshold**: this matches the **WMO definition of trace precipitation** — i.e. it is *not* an arbitrary cutoff.
|
| 73 |
+
3. **Binary**, not amount-of-rain. The pipeline could be extended to a regression task; we deliberately model classification because the downstream user decision is binary ("go / no-go").
|
| 74 |
+
|
| 75 |
+
## 6. Train / test split / 划分策略
|
| 76 |
+
|
| 77 |
+
**Time-based**, not random. The last 20 % of each site's chronological data is reserved as the hold-out test set; the remaining 80 % goes to a 5-fold `TimeSeriesSplit` cross-validation. Random splits would leak temporal autocorrelation and inflate accuracy by 5-15 percentage points.
|
| 78 |
+
|
| 79 |
+
## 7. Class balance / 类别分布
|
| 80 |
+
|
| 81 |
+
Empirically in tropical Malaysia, `is_rain_event = 1` holds in approximately 20-30 % of hours (more in monsoon months, less in dry season). We pass `class_weight='balanced'` to the Random Forest to prevent it from collapsing to a trivial "always predict no-rain" classifier.
|
| 82 |
+
|
| 83 |
+
## 8. Reproducibility / 可复现性
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
# Real ERA5 path (preferred)
|
| 87 |
+
python scripts/1_download_dataset.py # ~5-10 min, network-bound
|
| 88 |
+
python scripts/2_preprocess.py # < 30 s
|
| 89 |
+
python scripts/3_train_model.py # ~30-90 s on a modern laptop
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
All scripts are idempotent — re-running them does not duplicate data or re-download files that already exist locally.
|
| 93 |
+
|
| 94 |
+
## 9. Offline / synthetic-data fallback / 离线合成数据回退
|
| 95 |
+
|
| 96 |
+
For environments without network access (e.g. exam labs, restricted classroom networks) we ship `scripts/1b_synth_dataset.py`, a deterministic physics-informed synthetic generator (seed = 42, see file header for the meteorological assumptions encoded).
|
| 97 |
+
|
| 98 |
+
The synthetic dataset:
|
| 99 |
+
- has the **identical schema** as the Open-Meteo download,
|
| 100 |
+
- preserves Malaysia's bimodal monsoon seasonality, tropical diurnal cycle, lapse rate, hydrostatic pressure decay, and zero-inflated rain distribution,
|
| 101 |
+
- yields a comparable class balance (~26 % positive),
|
| 102 |
+
- lets the **entire pipeline + frontend + tests** be exercised without any external network calls.
|
| 103 |
+
|
| 104 |
+
It is **not** a substitute for real ERA5 data in the final thesis evaluation. The recommended workflow once network is restored is:
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
rm data/raw_*.csv data/processed.csv # clear synthetic data
|
| 108 |
+
python scripts/1_download_dataset.py # fetch real ERA5 via Open-Meteo
|
| 109 |
+
python scripts/2_preprocess.py
|
| 110 |
+
python scripts/3_train_model.py # retrain on real data
|
| 111 |
+
```
|
docs/pipeline_order.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project pipeline order — "App is the last"
|
| 2 |
+
# 项目流程顺序 —— "App 放在最后"
|
| 3 |
+
|
| 4 |
+
> Direct response to supervisor feedback 4/15: "First identify a dataset.
|
| 5 |
+
> And then train the model. And then predict it. Once everything is
|
| 6 |
+
> finished, you can develop the app. App is the last."
|
| 7 |
+
>
|
| 8 |
+
> 4/15 导师反馈直接回应:先 dataset,再 model,再 predict,最后才是 app。
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## Current state (May 2026) / 当前状态(2026 年 5 月)
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
┌──────────────────────────────────────────────────────────────────────┐
|
| 16 |
+
│ STEP 1 — DATASET ✅ DONE │
|
| 17 |
+
│ ──────────────────────────────────────────── │
|
| 18 |
+
│ Source : Open-Meteo Historical Archive (ECMWF ERA5) │
|
| 19 |
+
│ Coverage : 5 Malaysian mountain sites, 5 years hourly │
|
| 20 |
+
│ Rows : 175 315 │
|
| 21 |
+
│ Target Y : is_rain_event ∈ {0, 1} (next-hour rain > 0.1 mm) │
|
| 22 |
+
│ Code : scripts/{1_download, 1b_synth, 2_preprocess}.py │
|
| 23 |
+
│ Documentation: docs/dataset.md │
|
| 24 |
+
└──────────────────────────────────────────────────────────────────────┘
|
| 25 |
+
│
|
| 26 |
+
▼
|
| 27 |
+
┌──────────────────────────────────────────────────────────────────────┐
|
| 28 |
+
│ STEP 2 — MODEL TRAINING ✅ DONE │
|
| 29 |
+
│ ──────────────────────────────────────────── │
|
| 30 |
+
│ Algorithm : Random Forest, class_weight='balanced' │
|
| 31 |
+
│ Split : Time-based, last 20% chronological holdout │
|
| 32 |
+
│ CV : 5-fold TimeSeriesSplit on training portion │
|
| 33 |
+
│ Test results : ROC AUC 0.871 · PR AP 0.750 · Brier 0.138 │
|
| 34 |
+
│ Operating pt : τ = 0.20 → F2 = 0.778, Recall = 0.934 │
|
| 35 |
+
│ Code : scripts/3_train_model.py │
|
| 36 |
+
│ Documentation: models/MODEL_CARD.md │
|
| 37 |
+
└──────────────────────────────────────────────────────────────────────┘
|
| 38 |
+
│
|
| 39 |
+
▼
|
| 40 |
+
┌──────────────────────────────────────────────────────────────────────┐
|
| 41 |
+
│ STEP 3 — MODEL EVALUATION ✅ DONE │
|
| 42 |
+
│ ──────────────────────────────────────────── │
|
| 43 |
+
│ Figures : 6 publication-quality PNGs in figures/ │
|
| 44 |
+
│ 01_roc_curve.png · ROC + AUC │
|
| 45 |
+
│ 02_pr_curve.png · Precision-Recall + AP │
|
| 46 |
+
│ 03_calibration_curve.png · Reliability + Brier │
|
| 47 |
+
│ 04_threshold_sweep.png · F1/F2/Precision/Recall vs threshold │
|
| 48 |
+
│ 05_feature_importance.png· Top-20 features │
|
| 49 |
+
│ 06_confusion_matrix.png · CM at F2-optimal threshold │
|
| 50 |
+
│ Summary : figures/evaluation_summary.json │
|
| 51 |
+
│ Code : scripts/4_evaluate_model.py │
|
| 52 |
+
└──────────────────────────────────────────────────────────────────────┘
|
| 53 |
+
│
|
| 54 |
+
▼
|
| 55 |
+
┌──────────────────────────────────────────────────────────────────────┐
|
| 56 |
+
│ STEP 4 — RULE ENGINE (D5 proposal §3.7 P4.1-P4.6) ✅ DONE │
|
| 57 |
+
│ ──────────────────────────────────────────── │
|
| 58 |
+
│ P4.1 Load dynamic risk rules → backend/config.py │
|
| 59 |
+
│ P4.2 Fetch user context → ?activity= query parameter │
|
| 60 |
+
│ P4.3 Evaluate environmental → 4 score_*_risk() functions │
|
| 61 |
+
│ risks (rainfall, fog, wind gust, thunderstorm) │
|
| 62 |
+
│ §3.7.2 Decision table R1-R4 → apply_decision_table_3_7_2() │
|
| 63 |
+
│ Veto cascade → _collect_veto_triggers() │
|
| 64 |
+
│ P4.4 Activity weighting → apply_activity_weighting() │
|
| 65 |
+
│ P4.5 Composite risk score → dominant-hazard + secondary │
|
| 66 |
+
│ P4.6 Actionable advice → _normal_advice / _veto_advice │
|
| 67 |
+
│ Code : backend/rule_engine.py │
|
| 68 |
+
│ Documentation: docs/architecture.md, docs/thresholds.md │
|
| 69 |
+
└──────────────────────────────────────────────────────────────────────┘
|
| 70 |
+
│
|
| 71 |
+
▼
|
| 72 |
+
┌──────────────────────────────────────────────────────────────────────┐
|
| 73 |
+
│ STEP 5 — APP (LAST, as instructed) ✅ DONE │
|
| 74 |
+
│ ──────────────────────────────────────────── │
|
| 75 |
+
│ Backend : FastAPI + uvicorn — wraps trained model from Step 2 │
|
| 76 |
+
│ + rule engine from Step 4 │
|
| 77 |
+
│ Frontend : Vue 3 SPA — bilingual EN/ZH, 4 mini-gauges, │
|
| 78 |
+
│ R1-R4 indicators, demo scenarios, error toasts │
|
| 79 |
+
│ Container : Multi-stage Dockerfile + docker-compose.yml │
|
| 80 |
+
│ Tests : 70 tests, 97% backend coverage │
|
| 81 |
+
│ CI : .github/workflows/ci.yml │
|
| 82 |
+
└──────────────────────────────────────────────────────────────────────┘
|
| 83 |
+
│
|
| 84 |
+
▼
|
| 85 |
+
┌──────────────────────────────────────────────────────────────────────┐
|
| 86 |
+
│ STEP 6 — EVALUATION FOR THESIS CHAPTER 5 🔄 PLAN │
|
| 87 |
+
│ ──────────────────────────────────────────── │
|
| 88 |
+
│ 6a · Hindcast validation against NaDMA flood / landslide archives │
|
| 89 |
+
│ 6b · Small user study with mountain hikers (1-month panel) │
|
| 90 |
+
│ 6c · Comparative ablation: RF only vs Rule only vs Hybrid │
|
| 91 |
+
│ 6d · Threshold sensitivity analysis (τ ∈ {0.10, 0.15, 0.20, 0.25}) │
|
| 92 |
+
└──────────────────────────────────────────────────────────────────────┘
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
## Reading order for the supervisor / 给导师过的阅读顺序
|
| 96 |
+
|
| 97 |
+
When walking the supervisor through the project, **strictly follow Steps 1 → 5**:
|
| 98 |
+
|
| 99 |
+
| # | Open this | Spend |
|
| 100 |
+
|---|---|---|
|
| 101 |
+
| 1 | `docs/dataset.md` §4 schema, §5 Y derivation | 60 s |
|
| 102 |
+
| 2 | `figures/01_roc_curve.png` + `figures/03_calibration_curve.png` | 30 s |
|
| 103 |
+
| 3 | `figures/04_threshold_sweep.png` + `figures/05_feature_importance.png` | 60 s |
|
| 104 |
+
| 4 | `docs/architecture.md` §"Engine B internals" — show P4.1→P4.6 mapping | 60 s |
|
| 105 |
+
| 5 | `frontend/index.html` running locally — demo with the Genting & Everest scenarios | 60-90 s |
|
| 106 |
+
|
| 107 |
+
Total ≈ 5 minutes before any Q&A. App is opened **last** as agreed.
|
| 108 |
+
|
| 109 |
+
按这个顺序给导师过,**严格按 1→5**,整体大概 5 分钟过完再进入 Q&A。**app 一定放最后开**,跟导师上次说的完全一致。
|
docs/progress_update_brief.html
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>Progress-Update Brief — MicroClimate-X</title>
|
| 6 |
+
<style>
|
| 7 |
+
/* ============================================================
|
| 8 |
+
Print-optimised A4 progress-update brief
|
| 9 |
+
Open in browser → ⌘+P → Save as PDF, or read on screen
|
| 10 |
+
============================================================ */
|
| 11 |
+
:root {
|
| 12 |
+
--ink: #0b0d12;
|
| 13 |
+
--ink-soft: #353a44;
|
| 14 |
+
--muted: #6b7280;
|
| 15 |
+
--brand: #2563eb;
|
| 16 |
+
--brand-soft: #dbeafe;
|
| 17 |
+
--accent: #b91c1c;
|
| 18 |
+
--accent-soft: #fee2e2;
|
| 19 |
+
--ok: #166534;
|
| 20 |
+
--ok-soft: #dcfce7;
|
| 21 |
+
--warn: #b45309;
|
| 22 |
+
--warn-soft: #fef3c7;
|
| 23 |
+
--grid: #e5e7eb;
|
| 24 |
+
--bg: #ffffff;
|
| 25 |
+
--code-bg: #f3f4f6;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
* { box-sizing: border-box; }
|
| 29 |
+
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); }
|
| 30 |
+
body {
|
| 31 |
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
| 32 |
+
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
| 33 |
+
system-ui, sans-serif;
|
| 34 |
+
font-size: 11pt;
|
| 35 |
+
line-height: 1.45;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
@page { size: A4; margin: 12mm 14mm; }
|
| 39 |
+
main { max-width: 200mm; margin: 0 auto; padding: 14mm 14mm; }
|
| 40 |
+
|
| 41 |
+
/* Headings */
|
| 42 |
+
h1 {
|
| 43 |
+
font-size: 22pt; margin: 0 0 4mm 0;
|
| 44 |
+
border-bottom: 3px solid var(--brand); padding-bottom: 3mm;
|
| 45 |
+
page-break-after: avoid;
|
| 46 |
+
}
|
| 47 |
+
h1 .zh { display: block; font-size: 13pt; color: var(--muted); font-weight: 500; margin-top: 1mm; }
|
| 48 |
+
h2 {
|
| 49 |
+
font-size: 14pt; margin: 9mm 0 3mm 0;
|
| 50 |
+
color: var(--brand);
|
| 51 |
+
border-left: 4px solid var(--brand); padding: 1mm 0 1mm 3mm;
|
| 52 |
+
page-break-after: avoid;
|
| 53 |
+
}
|
| 54 |
+
h2 .zh { display: block; font-size: 10pt; color: var(--muted); margin-top: 0.5mm; font-weight: 500; }
|
| 55 |
+
h3 {
|
| 56 |
+
font-size: 11.5pt; margin: 5mm 0 2mm 0; color: var(--ink-soft);
|
| 57 |
+
page-break-after: avoid;
|
| 58 |
+
}
|
| 59 |
+
h4 { font-size: 10.5pt; margin: 3mm 0 1mm 0; color: var(--accent); }
|
| 60 |
+
|
| 61 |
+
p, li { margin: 1mm 0; }
|
| 62 |
+
ul, ol { padding-left: 5mm; }
|
| 63 |
+
ul li { margin-bottom: 1mm; }
|
| 64 |
+
|
| 65 |
+
/* Quote / supervisor verbatim */
|
| 66 |
+
.quote {
|
| 67 |
+
background: var(--warn-soft);
|
| 68 |
+
border-left: 3px solid var(--warn);
|
| 69 |
+
padding: 2mm 3mm; margin: 2mm 0;
|
| 70 |
+
font-style: italic; font-size: 10pt;
|
| 71 |
+
}
|
| 72 |
+
.quote::before { content: "🎙️ "; font-style: normal; }
|
| 73 |
+
|
| 74 |
+
/* Tables */
|
| 75 |
+
table.bilingual, table.steps, table.tabs, table.plan, table {
|
| 76 |
+
border-collapse: collapse; width: 100%; margin: 2mm 0 3mm 0;
|
| 77 |
+
font-size: 10pt;
|
| 78 |
+
}
|
| 79 |
+
table.bilingual td, table.steps td, table.tabs td, table.plan td,
|
| 80 |
+
table th, table td {
|
| 81 |
+
padding: 1.5mm 2.5mm; vertical-align: top;
|
| 82 |
+
border: 1px solid var(--grid);
|
| 83 |
+
}
|
| 84 |
+
table th {
|
| 85 |
+
background: #f9fafb; font-weight: 600; text-align: left;
|
| 86 |
+
color: var(--ink-soft);
|
| 87 |
+
}
|
| 88 |
+
table.bilingual td.en { width: 50%; }
|
| 89 |
+
table.bilingual td.zh { width: 50%; background: #fafbfc; }
|
| 90 |
+
table.plan td.estimate { width: 14%; text-align: center; color: var(--brand); font-weight: 600; }
|
| 91 |
+
|
| 92 |
+
/* Inline callouts */
|
| 93 |
+
.callout {
|
| 94 |
+
margin: 2mm 0; padding: 2mm 3mm;
|
| 95 |
+
border-left: 3px solid; border-radius: 1mm;
|
| 96 |
+
font-size: 10pt;
|
| 97 |
+
}
|
| 98 |
+
.callout.warn { background: var(--accent-soft); border-color: var(--accent); }
|
| 99 |
+
.callout.ok { background: var(--ok-soft); border-color: var(--ok); }
|
| 100 |
+
.callout.tip { background: var(--brand-soft); border-color: var(--brand); }
|
| 101 |
+
.callout-title { font-weight: 700; margin-bottom: 1mm; }
|
| 102 |
+
|
| 103 |
+
/* Code */
|
| 104 |
+
code, pre, kbd {
|
| 105 |
+
font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
| 106 |
+
font-size: 9.5pt;
|
| 107 |
+
}
|
| 108 |
+
code { background: var(--code-bg); padding: 0.3mm 1mm; border-radius: 1mm; }
|
| 109 |
+
pre {
|
| 110 |
+
background: var(--code-bg); padding: 3mm; border-radius: 2mm;
|
| 111 |
+
overflow-x: auto; margin: 2mm 0;
|
| 112 |
+
border: 1px solid var(--grid);
|
| 113 |
+
}
|
| 114 |
+
pre code { background: transparent; padding: 0; }
|
| 115 |
+
|
| 116 |
+
/* Step indicators */
|
| 117 |
+
.step {
|
| 118 |
+
display: flex; gap: 3mm;
|
| 119 |
+
margin: 2mm 0;
|
| 120 |
+
align-items: flex-start;
|
| 121 |
+
}
|
| 122 |
+
.step .num {
|
| 123 |
+
flex: 0 0 8mm; width: 8mm; height: 8mm; border-radius: 50%;
|
| 124 |
+
background: var(--brand); color: white; font-weight: 700;
|
| 125 |
+
display: flex; align-items: center; justify-content: center;
|
| 126 |
+
font-size: 11pt;
|
| 127 |
+
}
|
| 128 |
+
.step .body { flex: 1; }
|
| 129 |
+
|
| 130 |
+
/* Demo / decision blocks */
|
| 131 |
+
.demo {
|
| 132 |
+
background: #f0f9ff;
|
| 133 |
+
border: 1px solid #bae6fd;
|
| 134 |
+
border-radius: 2mm;
|
| 135 |
+
padding: 3mm;
|
| 136 |
+
margin: 3mm 0;
|
| 137 |
+
}
|
| 138 |
+
.demo .demo-title { font-weight: 700; color: #075985; margin-bottom: 1mm; }
|
| 139 |
+
|
| 140 |
+
.decision {
|
| 141 |
+
background: #fefce8;
|
| 142 |
+
border: 1px solid #fde047;
|
| 143 |
+
border-radius: 2mm;
|
| 144 |
+
padding: 3mm;
|
| 145 |
+
margin: 2mm 0;
|
| 146 |
+
}
|
| 147 |
+
.decision .decision-title {
|
| 148 |
+
font-weight: 700; color: #854d0e; margin-bottom: 1mm;
|
| 149 |
+
text-transform: uppercase; letter-spacing: 0.5pt; font-size: 9pt;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/* Status pill */
|
| 153 |
+
.pill {
|
| 154 |
+
display: inline-block; padding: 0.2mm 1.5mm;
|
| 155 |
+
border-radius: 4mm; font-size: 8.5pt; font-weight: 600;
|
| 156 |
+
vertical-align: middle;
|
| 157 |
+
}
|
| 158 |
+
.pill.done { background: var(--ok-soft); color: var(--ok); }
|
| 159 |
+
.pill.plan { background: var(--brand-soft); color: var(--brand); }
|
| 160 |
+
.pill.risk { background: var(--accent-soft); color: var(--accent); }
|
| 161 |
+
|
| 162 |
+
/* Checklist */
|
| 163 |
+
.check { font-family: "SF Mono", Menlo, monospace; font-size: 9.5pt; line-height: 1.7; }
|
| 164 |
+
.check .box { display: inline-block; width: 4mm; }
|
| 165 |
+
|
| 166 |
+
/* Page break helpers */
|
| 167 |
+
.pb { page-break-before: always; }
|
| 168 |
+
.nobreak { page-break-inside: avoid; }
|
| 169 |
+
|
| 170 |
+
/* Footer */
|
| 171 |
+
footer {
|
| 172 |
+
margin-top: 12mm; padding-top: 4mm;
|
| 173 |
+
border-top: 1px solid var(--grid);
|
| 174 |
+
color: var(--muted); font-size: 9pt; text-align: center;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* Print refinements */
|
| 178 |
+
@media print {
|
| 179 |
+
body { font-size: 10pt; }
|
| 180 |
+
h2 { font-size: 13pt; }
|
| 181 |
+
.no-print { display: none; }
|
| 182 |
+
a { color: var(--ink); text-decoration: none; }
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
/* Toolbar (screen only) */
|
| 186 |
+
.toolbar {
|
| 187 |
+
position: sticky; top: 0; z-index: 100;
|
| 188 |
+
background: var(--brand); color: white;
|
| 189 |
+
padding: 2mm 4mm; display: flex; justify-content: space-between;
|
| 190 |
+
align-items: center; font-size: 10pt;
|
| 191 |
+
}
|
| 192 |
+
.toolbar button {
|
| 193 |
+
background: white; color: var(--brand); border: 0;
|
| 194 |
+
padding: 1.5mm 4mm; border-radius: 1mm; font-weight: 600;
|
| 195 |
+
cursor: pointer; font-size: 10pt;
|
| 196 |
+
}
|
| 197 |
+
.toolbar button:hover { background: #f3f4f6; }
|
| 198 |
+
|
| 199 |
+
/* Cover meta strip */
|
| 200 |
+
.cover-meta {
|
| 201 |
+
display: flex; gap: 4mm; flex-wrap: wrap;
|
| 202 |
+
margin: 3mm 0;
|
| 203 |
+
color: var(--muted); font-size: 9.5pt;
|
| 204 |
+
}
|
| 205 |
+
.cover-meta span {
|
| 206 |
+
background: var(--code-bg); padding: 0.5mm 2mm; border-radius: 1mm;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* Timeline strip in §0.2 */
|
| 210 |
+
table.timeline td.block { width: 8%; text-align: center; font-weight: 700; color: var(--brand); }
|
| 211 |
+
table.timeline td.time { width: 18%; font-family: "SF Mono", Menlo, monospace; color: var(--muted); }
|
| 212 |
+
</style>
|
| 213 |
+
</head>
|
| 214 |
+
<body>
|
| 215 |
+
|
| 216 |
+
<div class="toolbar no-print">
|
| 217 |
+
<strong>Progress-Update Brief · MicroClimate-X</strong>
|
| 218 |
+
<button onclick="window.print()">🖨 Print / Save as PDF</button>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<main>
|
| 222 |
+
|
| 223 |
+
<h1>Supervisor Progress-Update Brief
|
| 224 |
+
<span class="zh">导师进度汇报双语逐字稿 — MicroClimate-X</span>
|
| 225 |
+
</h1>
|
| 226 |
+
|
| 227 |
+
<div class="cover-meta">
|
| 228 |
+
<span>📅 2026-05-13</span>
|
| 229 |
+
<span>🎓 UKM FYP</span>
|
| 230 |
+
<span>🏛️ KyoukoLi/microclimate-x</span>
|
| 231 |
+
<span>🚀 v1.0.0 shipped 2026-05-11</span>
|
| 232 |
+
<span>✅ 70 tests · 97% coverage</span>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
<div class="callout tip">
|
| 236 |
+
<div class="callout-title">How to use this brief · 怎么用这份汇报稿</div>
|
| 237 |
+
Follow-up meeting after the v1.0.0 hardening pass on 2026-05-11. Walk-through order is unchanged: <strong>dataset → model → app → next steps</strong>. Open this file on screen during the meeting; <strong>do not read word-for-word</strong>.<br><br>
|
| 238 |
+
紧接 2026-05-11 v1.0.0 强化提交之后的<strong>进度汇报</strong>会议。顺序一律不变:<strong>dataset → model → app → 下一步</strong>。开会时屏幕上打开本文档,<strong>不要照念</strong>,当兜底用即可。
|
| 239 |
+
</div>
|
| 240 |
+
|
| 241 |
+
<!-- ===== Section 0: what you need to do ===== -->
|
| 242 |
+
<h2>0 · What you need to do — three time windows
|
| 243 |
+
<span class="zh">你要做的事 —— 三个时间窗口</span>
|
| 244 |
+
</h2>
|
| 245 |
+
|
| 246 |
+
<h3>0.1 · Before the meeting (T-15 min) / 会前 15 分钟</h3>
|
| 247 |
+
|
| 248 |
+
<table class="check">
|
| 249 |
+
<tr><th style="width:6%">☐</th><th>English</th><th>中文</th></tr>
|
| 250 |
+
<tr><td>☐</td><td>Charge laptop ≥ 80 %; charger in bag.</td><td>笔记本充满 ≥ 80%,充电器带上。</td></tr>
|
| 251 |
+
<tr><td>☐</td><td><code>cd ~/Projects/microclimate-x && git pull && git status</code> — must print "working tree clean".</td><td>拉最新代码,确认 working tree clean。</td></tr>
|
| 252 |
+
<tr><td>☐</td><td><code>make run</code> in <strong>terminal A</strong> (leave it running).</td><td>终端 A 起后端,<strong>不要关</strong>。</td></tr>
|
| 253 |
+
<tr><td>☐</td><td><code>curl -s http://localhost:8000/api/health | python3 -m json.tool</code> in <strong>terminal B</strong> — verify <code>"ml_loaded": true</code>.</td><td>终端 B 验证健康检查,<code>ml_loaded</code> 必须为 <code>true</code>。</td></tr>
|
| 254 |
+
<tr><td>☐</td><td>Open the 10 browser tabs in the order from <code>MEETING_CHEAT_SHEET.md</code> §0 — <strong>app tab is last</strong>.</td><td>按 cheat-sheet §0 顺序开 10 个标签页,<strong>app 标签放最后</strong>。</td></tr>
|
| 255 |
+
<tr><td>☐</td><td>This file (<code>progress_update_brief.html</code>) open on a separate screen / phone.</td><td>把本文档单独开在副屏或手机上。</td></tr>
|
| 256 |
+
<tr><td>☐</td><td>Phone on silent. Deep breath.</td><td>手机静音,深呼吸。</td></tr>
|
| 257 |
+
</table>
|
| 258 |
+
|
| 259 |
+
<h3>0.2 · During the meeting (≈ 8 minutes) / 会中 ≈ 8 分钟</h3>
|
| 260 |
+
|
| 261 |
+
<table class="timeline">
|
| 262 |
+
<tr><th>Block</th><th>EN heading</th><th>中文标题</th><th>Time</th></tr>
|
| 263 |
+
<tr><td class="block">1</td><td>Opening 30 s</td><td>开场 30 秒</td><td class="time">0:00 → 0:30</td></tr>
|
| 264 |
+
<tr><td class="block">2</td><td>What changed since last meeting</td><td>自上次会以来的进展</td><td class="time">0:30 → 2:00</td></tr>
|
| 265 |
+
<tr><td class="block">3</td><td>Live demo — dataset → model → app</td><td>现场演示(顺序不变)</td><td class="time">2:00 → 5:00</td></tr>
|
| 266 |
+
<tr><td class="block">4</td><td>Next steps for Chapter 5</td><td>Chapter 5 下一步</td><td class="time">5:00 → 6:30</td></tr>
|
| 267 |
+
<tr><td class="block">5</td><td>Asks + closing</td><td>请示 + 收尾</td><td class="time">6:30 → 8:00</td></tr>
|
| 268 |
+
</table>
|
| 269 |
+
|
| 270 |
+
<h3>0.3 · After the meeting (T+24 h) / 会后 24 小时内</h3>
|
| 271 |
+
|
| 272 |
+
<table class="check">
|
| 273 |
+
<tr><th style="width:6%">☐</th><th>English</th><th>中文</th></tr>
|
| 274 |
+
<tr><td>☐</td><td>Write meeting minutes — capture every supervisor decision in <code>docs/meeting_log_<date>.md</code>.</td><td>写会议纪要,把老师每条决定记到 <code>docs/meeting_log_<日期>.md</code>。</td></tr>
|
| 275 |
+
<tr><td>☐</td><td>Open one GitHub issue per agreed action item (label: <code>chapter-5</code>).</td><td>每个 action item 在 GitHub 开一个 issue,打 <code>chapter-5</code> 标签。</td></tr>
|
| 276 |
+
<tr><td>☐</td><td>Email a 3-bullet summary back to the supervisor for written confirmation.</td><td>给老师发 3 条要点的总结邮件,留<strong>书面确认</strong>。</td></tr>
|
| 277 |
+
<tr><td>☐</td><td>Update <code>README.md</code> §9 Roadmap — tick boxes that were signed off.</td><td>更新 <code>README.md</code> 第 9 节 Roadmap,把通过的项打勾。</td></tr>
|
| 278 |
+
<tr><td>☐</td><td>Tag a new release if scope was confirmed (<code>git tag v1.1.0-rc.1</code>).</td><td>如果范围确认了,打个新 tag (<code>v1.1.0-rc.1</code>)。</td></tr>
|
| 279 |
+
</table>
|
| 280 |
+
|
| 281 |
+
<!-- ===== Section 1: Opening ===== -->
|
| 282 |
+
<h2 class="pb">1 · Opening (30 seconds)
|
| 283 |
+
<span class="zh">开场 30 秒</span>
|
| 284 |
+
</h2>
|
| 285 |
+
|
| 286 |
+
<table class="bilingual">
|
| 287 |
+
<tr>
|
| 288 |
+
<td class="en">"Sir, thank you for your time. Following up on our last session, I've completed a production-grade hardening pass — version 1.0.0 — and the full pipeline is now reproducible end-to-end. May I walk you through what's new in the same order as before — <strong>dataset, then model, then app</strong> — and finish with my proposed plan for Chapter 5?"</td>
|
| 289 |
+
<td class="zh">"老师感谢您抽时间。接着上次的内容,我做完了 <strong>v1.0.0 工程化强化</strong>,整条流水线现在可以<strong>端到端复现</strong>。我按上次的顺序——<strong>dataset、model、app</strong>——给您过一遍新的进展,最后讲我对 Chapter 5 的下一步计划,可以吗?"</td>
|
| 290 |
+
</tr>
|
| 291 |
+
</table>
|
| 292 |
+
|
| 293 |
+
<div class="callout ok">
|
| 294 |
+
<strong>Why this opening · 为什么这样开场</strong>: (a) restates the supervisor's preferred process order without him asking, (b) signals you've made forward progress (not just polish), (c) ends with an explicit ask for direction on Chapter 5 — which is what <em>he</em> wants to talk about.<br>
|
| 295 |
+
(a) 不用他提就主动按他的流程顺序;(b) 强调是<strong>前进了</strong>而不是只在抛光;(c) 用对 Chapter 5 的请示收尾,<strong>这正是他想聊的话题</strong>。
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
<!-- ===== Section 2: progress recap ===== -->
|
| 299 |
+
<h2>2 · What changed since the last meeting
|
| 300 |
+
<span class="zh">自上次会议以来的进展</span>
|
| 301 |
+
</h2>
|
| 302 |
+
|
| 303 |
+
<p style="color: var(--muted); font-size: 9.5pt;">
|
| 304 |
+
≈ 90 seconds. Stay on the GitHub repo tab — point to the commit history, the green CI badge, the v1.0.0 release.<br>
|
| 305 |
+
≈ 90 秒。停在 GitHub repo 标签页,指给老师看 commit 历史、CI 绿勾、v1.0.0 release。
|
| 306 |
+
</p>
|
| 307 |
+
|
| 308 |
+
<table class="bilingual">
|
| 309 |
+
<tr><th style="width:18%">Area / 模块</th><th>English</th><th>中文</th></tr>
|
| 310 |
+
<tr>
|
| 311 |
+
<td><strong>Backend hardening</strong><br><span style="color:var(--muted);font-size:9pt">后端强化</span></td>
|
| 312 |
+
<td>"I added a request-ID middleware, a typed <code>ErrorResponse</code> contract so no bare HTML 500s leak, structured logging, and an enriched <code>/api/health</code> exposing uptime, cache stats, and the loaded ML feature schema."</td>
|
| 313 |
+
<td>"后端我加了 <strong>request-ID 中间件</strong>、<strong>类型化错误协议</strong> <code>ErrorResponse</code>(不再泄漏裸 HTML 500)、结构化日志、以及<strong>升级版 <code>/api/health</code></strong>(暴露 uptime、缓存统计、ML 特征 schema)。"</td>
|
| 314 |
+
</tr>
|
| 315 |
+
<tr>
|
| 316 |
+
<td><strong>ML pipeline</strong><br><span style="color:var(--muted);font-size:9pt">ML 流水线</span></td>
|
| 317 |
+
<td>"I shipped <code>scripts/4_evaluate_model.py</code> which produces six publication-quality figures plus a machine-readable <code>evaluation_summary.json</code>. I also wrote a HuggingFace-style <code>MODEL_CARD.md</code> covering intended use, training data, metrics, limitations, and ethical considerations."</td>
|
| 318 |
+
<td>"ML 流水线加了<strong>评估脚本</strong> <code>scripts/4_evaluate_model.py</code>,自动出 6 张论文级别图 + 一份 <code>evaluation_summary.json</code>。还写了 HuggingFace 风格的 <strong>MODEL_CARD.md</strong>,覆盖用途、训练数据、指标、局限���伦理考量。"</td>
|
| 319 |
+
</tr>
|
| 320 |
+
<tr>
|
| 321 |
+
<td><strong>Tests + CI</strong><br><span style="color:var(--muted);font-size:9pt">测试 + CI</span></td>
|
| 322 |
+
<td>"Total tests went from 19 to <strong>70</strong>, backend coverage is <strong>97 %</strong>. CI runs on Python 3.9 / 3.11 / 3.12 plus a Docker image-build smoke test."</td>
|
| 323 |
+
<td>"测试数从 19 涨到 <strong>70</strong>,<strong>后端覆盖率 97%</strong>。CI 跑 Python 3.9/3.11/3.12 矩阵,外加 Docker 镜像构建烟测。"</td>
|
| 324 |
+
</tr>
|
| 325 |
+
<tr>
|
| 326 |
+
<td><strong>Dev-ex</strong><br><span style="color:var(--muted);font-size:9pt">开发体验</span></td>
|
| 327 |
+
<td>"Multi-stage Dockerfile, docker-compose, Makefile single-word recipes, pre-commit hooks. The whole project is now <code>docker compose up --build</code> away from a clean machine."</td>
|
| 328 |
+
<td>"多阶段 Dockerfile + compose + Makefile 单词命令 + pre-commit hooks。<strong>新机器一句 <code>docker compose up --build</code> 就能跑起来</strong>。"</td>
|
| 329 |
+
</tr>
|
| 330 |
+
<tr>
|
| 331 |
+
<td><strong>Documentation</strong><br><span style="color:var(--muted);font-size:9pt">文档</span></td>
|
| 332 |
+
<td>"Three new docs — <code>architecture.md</code>, <code>thresholds.md</code> with citations for every Veto threshold, and <code>pipeline_order.md</code> which explicitly enforces the dataset → model → app order you asked for."</td>
|
| 333 |
+
<td>"三份新文档——<code>architecture.md</code>、<code>thresholds.md</code>(每个 Veto 阈值都附学术引用)、以及 <code>pipeline_order.md</code>(<strong>显式按您要求的 dataset→model→app 顺序写死</strong>)。"</td>
|
| 334 |
+
</tr>
|
| 335 |
+
</table>
|
| 336 |
+
|
| 337 |
+
<div class="callout tip">
|
| 338 |
+
<strong>Artefact to show · 展示物</strong>: GitHub commit history page; the green CI badge on the README; <code>CHANGELOG.md</code> v1.0.0 entry.<br>
|
| 339 |
+
GitHub commit 历史页;README 上的 CI 绿勾;<code>CHANGELOG.md</code> 中 v1.0.0 那一段。
|
| 340 |
+
</div>
|
| 341 |
+
|
| 342 |
+
<!-- ===== Section 3: live demo ===== -->
|
| 343 |
+
<h2 class="pb">3 · Live demo — dataset → model → app
|
| 344 |
+
<span class="zh">现场演示(顺序不变)</span>
|
| 345 |
+
</h2>
|
| 346 |
+
|
| 347 |
+
<p style="color: var(--muted); font-size: 9.5pt;">
|
| 348 |
+
≈ 3 minutes. Same order as the 5/11 dry-run script — no surprises for the supervisor.<br>
|
| 349 |
+
≈ 3 分钟。跟 5/11 的脚本完全一样的顺序,<strong>老师不会被打乱节奏</strong>。
|
| 350 |
+
</p>
|
| 351 |
+
|
| 352 |
+
<div class="step">
|
| 353 |
+
<div class="num">1</div>
|
| 354 |
+
<div class="body">
|
| 355 |
+
<h3>Dataset (Tab <code>docs/dataset.md</code>) — 30 s</h3>
|
| 356 |
+
<table class="bilingual">
|
| 357 |
+
<tr>
|
| 358 |
+
<td class="en">"Same dataset as last time — ERA5 reanalysis, 5 Malaysian mountain sites, 175 315 hourly rows. The Y column <code>is_rain_event</code> is derived in one line and documented in §5. <strong>No change here</strong>, just confirming the foundation is unchanged."</td>
|
| 359 |
+
<td class="zh">"数据集跟上次一样——ERA5 再分析、马来西亚 5 个山地点位、17.5 万行小时数据。Y 列 <code>is_rain_event</code> 一行代码构造,文档在 §5。<strong>这里没有变</strong>,只是确认地基没动。"</td>
|
| 360 |
+
</tr>
|
| 361 |
+
</table>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
+
<div class="step">
|
| 366 |
+
<div class="num">2</div>
|
| 367 |
+
<div class="body">
|
| 368 |
+
<h3>Model (Tabs <code>01_roc</code> → <code>03_calibration</code> → <code>04_threshold</code> → <code>05_feature_importance</code>) — 90 s</h3>
|
| 369 |
+
<table class="bilingual">
|
| 370 |
+
<tr>
|
| 371 |
+
<td class="en">"Same model as last time — Random Forest, time-based split, τ = 0.20. Test ROC AUC <strong>0.871</strong>, PR AP <strong>0.750</strong>, Brier <strong>0.138</strong>, recall <strong>93.4 %</strong>. <strong>What's new is the 6 figures plus the model card</strong> — every number you see here is reproducible from <code>make evaluate</code>."</td>
|
| 372 |
+
<td class="zh">"模型跟上次一样——RF、时间序列切分、τ = 0.20。测试 AUC <strong>0.871</strong>、PR AP <strong>0.750</strong>、Brier <strong>0.138</strong>、召回率 <strong>93.4%</strong>。<strong>新东西</strong>是 6 张图 + model card——上面任何一个数字都可以用 <code>make evaluate</code> 复现。"</td>
|
| 373 |
+
</tr>
|
| 374 |
+
</table>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
|
| 378 |
+
<div class="step">
|
| 379 |
+
<div class="num">3</div>
|
| 380 |
+
<div class="body">
|
| 381 |
+
<h3>App (Tab <code>http://localhost:8000/app/</code>) — 60-90 s</h3>
|
| 382 |
+
<table class="bilingual">
|
| 383 |
+
<tr>
|
| 384 |
+
<td class="en">"Step 3, the app — opened <strong>last</strong> as agreed. Two demo scenarios. First, <strong>Genting Highlands</strong> — a slope at 1865 m inside the training distribution. The model gives a moderate rain probability; the rule engine picks up orographic lift; the four mini-gauges decompose the risk by hazard type."</td>
|
| 385 |
+
<td class="zh">"第三步 app——按约定<strong>最后才开</strong>。两个 demo 场景。第一个<strong>云顶高原</strong>——1865 m 的山坡,<strong>在训练分布之内</strong>。模型给中等降雨概率,规则引擎检测到地形抬升,四个 mini-gauge 把风险按灾害类型拆解。"</td>
|
| 386 |
+
</tr>
|
| 387 |
+
<tr>
|
| 388 |
+
<td class="en">"Second, <strong>Mt Everest</strong> — completely out of distribution. The model alone would say 'safe'. The Veto cascade fires three independent overrides — hypoxia, frostbite, gale — and the composite is forced to Danger. There's a unit test for exactly this: <code>test_mt_everest_veto_hypoxia</code>."</td>
|
| 389 |
+
<td class="zh">"第二个<strong>珠峰</strong>——<strong>完全分布外</strong>。光看模型会说"安全",但 Veto 级联触发<strong>三个独立否决</strong>——缺氧、冻伤、大风——综合分被强制设为 Danger。<strong>专门有单元测试覆盖这个场景</strong>:<code>test_mt_everest_veto_hypoxia</code>。"</td>
|
| 390 |
+
</tr>
|
| 391 |
+
</table>
|
| 392 |
+
</div>
|
| 393 |
+
</div>
|
| 394 |
+
|
| 395 |
+
<!-- ===== Section 4: Chapter 5 plan ===== -->
|
| 396 |
+
<h2 class="pb">4 · Next steps for Chapter 5
|
| 397 |
+
<span class="zh">Chapter 5 下一步</span>
|
| 398 |
+
</h2>
|
| 399 |
+
|
| 400 |
+
<div class="callout warn">
|
| 401 |
+
≈ 90 seconds. <strong>This is the section the supervisor will react to most.</strong> Frame each item as a concrete deliverable + estimated time + dependency.<br>
|
| 402 |
+
≈ 90 秒。<strong>老师反应最强烈的就是这一节</strong>。每一项都以"<strong>交付物 + 估时 + 依赖</strong>"形式呈现。
|
| 403 |
+
</div>
|
| 404 |
+
|
| 405 |
+
<h3>4.1 · Proposed Chapter 5 work plan / Chapter 5 工作计划</h3>
|
| 406 |
+
|
| 407 |
+
<table class="plan">
|
| 408 |
+
<tr><th>#</th><th>Deliverable / 交付物</th><th>EN one-liner</th><th>中文一句话</th><th class="estimate">Estimate</th></tr>
|
| 409 |
+
<tr>
|
| 410 |
+
<td><span class="pill plan">5.1</span></td>
|
| 411 |
+
<td><strong>Comparative ablation</strong><br><span style="color:var(--muted);font-size:9pt">对比实验</span></td>
|
| 412 |
+
<td>"Train LogReg + XGBoost on the same features and report ROC / PR / F2 side-by-side with RF — answers 'why RF?' empirically."</td>
|
| 413 |
+
<td>"在同一特征集上训 LogReg + XGBoost,对比 ROC / PR / F2,<strong>用数据回答"为什么选 RF"</strong>。"</td>
|
| 414 |
+
<td class="estimate">1 week</td>
|
| 415 |
+
</tr>
|
| 416 |
+
<tr>
|
| 417 |
+
<td><span class="pill plan">5.2</span></td>
|
| 418 |
+
<td><strong>Hindcast validation</strong><br><span style="color:var(--muted);font-size:9pt">历史事件回放</span></td>
|
| 419 |
+
<td>"Replay 2020-2024 NaDMA-documented Malaysian flood / landslide events and check whether the system would have raised Warning / Danger at the right time. Reports hit-rate, lead-time, false-alarm rate."</td>
|
| 420 |
+
<td>"把 2020-2024 NaDMA 公开的马来西亚洪水/滑坡事件<strong>逐一回放</strong>,看系统能否在事发前给出 Warning/Danger。报告命中率、提前量、误报率。"</td>
|
| 421 |
+
<td class="estimate">2 weeks</td>
|
| 422 |
+
</tr>
|
| 423 |
+
<tr>
|
| 424 |
+
<td><span class="pill plan">5.3</span></td>
|
| 425 |
+
<td><strong>Threshold sensitivity</strong><br><span style="color:var(--muted);font-size:9pt">阈值灵敏度</span></td>
|
| 426 |
+
<td>"Sweep τ ∈ {0.10, 0.15, 0.20, 0.25, 0.30}, plot precision-recall trade-off, and justify the operating point with a cost-of-error analysis."</td>
|
| 427 |
+
<td>"扫 τ ∈ {0.10, 0.15, 0.20, 0.25, 0.30},画精度-召回权衡曲线,用<strong>误差代价分析</strong>为最终选点辩护。"</td>
|
| 428 |
+
<td class="estimate">3 days</td>
|
| 429 |
+
</tr>
|
| 430 |
+
<tr>
|
| 431 |
+
<td><span class="pill plan">5.4</span></td>
|
| 432 |
+
<td><strong>Component ablation</strong><br><span style="color:var(--muted);font-size:9pt">组件消融</span></td>
|
| 433 |
+
<td>"Compare three system variants — RF only / Rule only / Hybrid — on the held-out test set and on the OOD Mt Everest case. Quantifies the rule-engine contribution."</td>
|
| 434 |
+
<td>"对比三个系统变体——<strong>纯 RF / 纯规则 / 混合</strong>——在测试集和 OOD 珠峰场景上的表现。<strong>量化规则引擎的贡献</strong>。"</td>
|
| 435 |
+
<td class="estimate">4 days</td>
|
| 436 |
+
</tr>
|
| 437 |
+
<tr>
|
| 438 |
+
<td><span class="pill risk">5.5</span></td>
|
| 439 |
+
<td><strong>Small user study</strong> <em>(optional)</em><br><span style="color:var(--muted);font-size:9pt">用户研究(可选)</span></td>
|
| 440 |
+
<td>"Recruit 5-8 mountain hikers, run a 4-week panel, log system advice vs. their field judgment. Reports inter-rater agreement (Cohen's κ)."</td>
|
| 441 |
+
<td>"招募 5-8 名登山者,4 周面板研究,记录系统建议 vs 他们现场判断,报告 Cohen's κ 一致性。"</td>
|
| 442 |
+
<td class="estimate">4 weeks</td>
|
| 443 |
+
</tr>
|
| 444 |
+
<tr>
|
| 445 |
+
<td><span class="pill done">5.6</span></td>
|
| 446 |
+
<td><strong>Thesis Chapter 5 draft</strong><br><span style="color:var(--muted);font-size:9pt">章节初稿</span></td>
|
| 447 |
+
<td>"Pull §5.1-5.5 into a single 12-15 page evaluation chapter with all figures, tables, and discussion."</td>
|
| 448 |
+
<td>"把 §5.1-5.5 整合成 12-15 页的评估章节,含全部图表和讨论。"</td>
|
| 449 |
+
<td class="estimate">1 week</td>
|
| 450 |
+
</tr>
|
| 451 |
+
</table>
|
| 452 |
+
|
| 453 |
+
<h3>4.2 · Decision tree to ask the supervisor / 请示决策树</h3>
|
| 454 |
+
|
| 455 |
+
<div class="decision">
|
| 456 |
+
<div class="decision-title">Q1 · Priorities</div>
|
| 457 |
+
<table class="bilingual">
|
| 458 |
+
<tr>
|
| 459 |
+
<td class="en">"Sir, of the five evaluation tracks above, <strong>which two should I prioritise for the next four weeks</strong> before we converge on the Chapter 5 outline?"</td>
|
| 460 |
+
<td class="zh">"老师,上面 5 条评估方向,<strong>未来四周</strong>您建议我重点做哪两条,然后再收敛到 Chapter 5 大纲?"</td>
|
| 461 |
+
</tr>
|
| 462 |
+
</table>
|
| 463 |
+
</div>
|
| 464 |
+
|
| 465 |
+
<div class="decision">
|
| 466 |
+
<div class="decision-title">Q2 · User study yes/no</div>
|
| 467 |
+
<table class="bilingual">
|
| 468 |
+
<tr>
|
| 469 |
+
<td class="en">"Do you want me to include the user study (5.5)? It is the longest item and depends on participant recruitment — I want your call before committing."</td>
|
| 470 |
+
<td class="zh">"<strong>用户研究 (5.5) 您要不要做</strong>?这一条最长、依赖招募——想请您拍板再投入。"</td>
|
| 471 |
+
</tr>
|
| 472 |
+
</table>
|
| 473 |
+
</div>
|
| 474 |
+
|
| 475 |
+
<div class="decision">
|
| 476 |
+
<div class="decision-title">Q3 · Framing of the comparative study</div>
|
| 477 |
+
<table class="bilingual">
|
| 478 |
+
<tr>
|
| 479 |
+
<td class="en">"For the comparative ablation, do you want it framed as <strong>'why RF wins'</strong> (defending current choice) or <strong>'what if XGBoost wins'</strong> (open exploration)? The framing affects how I report inconclusive results."</td>
|
| 480 |
+
<td class="zh">"<strong>对比实验</strong>您希望框成"为什么 RF 胜出"(<strong>捍卫现有选择</strong>)还是"如果 XGBoost 更好怎么办"(<strong>开放探索</strong>)?两种 framing 对<strong>模棱两可结果</strong>的报告方式不同。"</td>
|
| 481 |
+
</tr>
|
| 482 |
+
</table>
|
| 483 |
+
</div>
|
| 484 |
+
|
| 485 |
+
<div class="decision">
|
| 486 |
+
<div class="decision-title">Q4 · Mt Everest weight in the thesis</div>
|
| 487 |
+
<table class="bilingual">
|
| 488 |
+
<tr>
|
| 489 |
+
<td class="en">"Should I treat the Mt Everest OOD test as a <strong>thesis-level contribution</strong> (a stand-alone subsection on safety) or just an <strong>appendix item</strong>?"</td>
|
| 490 |
+
<td class="zh">"<strong>珠峰 OOD 测试</strong>算论文级别的贡献(单独一节讲安全性),还是放附录就够?"</td>
|
| 491 |
+
</tr>
|
| 492 |
+
</table>
|
| 493 |
+
</div>
|
| 494 |
+
|
| 495 |
+
<!-- ===== Section 5: closing ===== -->
|
| 496 |
+
<h2 class="pb">5 · Asks + closing (60 seconds)
|
| 497 |
+
<span class="zh">请示 + 收尾 60 秒</span>
|
| 498 |
+
</h2>
|
| 499 |
+
|
| 500 |
+
<table class="bilingual">
|
| 501 |
+
<tr>
|
| 502 |
+
<td class="en">"Sir, to summarise: since the last meeting I've shipped v1.0.0 — production-grade hardening, 70 tests at 97 % coverage, six evaluation figures, a published model card, full Docker reproducibility. The pipeline order is unchanged from what you asked: <strong>dataset, model, app</strong>. For Chapter 5 I have <strong>five evaluation tracks scoped</strong>; I'd like your guidance on which two to prioritise for the next four weeks."</td>
|
| 503 |
+
<td class="zh">"老师,总结:自上次会议以来交付了 <strong>v1.0.0</strong>——工程化强化、70 个测试 97% 覆盖率、6 张评估图、model card、Docker 全复现。流水线顺序按您要求<strong>没动</strong>:dataset、model、app。Chapter 5 我列了 <strong>5 条评估方向</strong>,<strong>接下来四周您建议我先做哪两条</strong>?"</td>
|
| 504 |
+
</tr>
|
| 505 |
+
<tr>
|
| 506 |
+
<td class="en">"I'll send you a 3-bullet email summary by tomorrow morning so we have <strong>written agreement</strong> on the priorities. Thank you for your time."</td>
|
| 507 |
+
<td class="zh">"明早之前给您发 3 条要点的邮件总结,<strong>留个书面确认</strong>。谢谢老师。"</td>
|
| 508 |
+
</tr>
|
| 509 |
+
</table>
|
| 510 |
+
|
| 511 |
+
<!-- ===== Section 6: defensive Q&A ===== -->
|
| 512 |
+
<h2>6 · Q&A defensive lines (this update only)
|
| 513 |
+
<span class="zh">本次进度汇报的兜底话术</span>
|
| 514 |
+
</h2>
|
| 515 |
+
|
| 516 |
+
<div class="callout tip">
|
| 517 |
+
Anticipated follow-up questions <strong>specific to this progress update</strong>. The classic Q1-Q7 from the 5/11 brief are still live — just don't repeat them here.<br>
|
| 518 |
+
<strong>针对本次进度汇报</strong>可能出现的追问。5/11 那份的经典 Q1-Q7 仍然有效,不重复罗列。
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
<h3>Q-N1 — "Why are you spending time on tests and Docker instead of the thesis?"</h3>
|
| 522 |
+
<h3 style="margin-top:-2mm">Q-N1 ——为什么你在写测试和 Docker 上花时间,不写论文?</h3>
|
| 523 |
+
|
| 524 |
+
<table class="bilingual">
|
| 525 |
+
<tr>
|
| 526 |
+
<td class="en">"Sir, the v1.0.0 hardening was a <strong>one-time investment</strong> to make every Chapter 5 number reproducible by the examiner with a single command. Without it, every evaluation result would be a black box — the examiner could not verify the AUC of 0.871 herself. With <code>make evaluate</code> reproducing all six figures byte-for-byte, the thesis claims become <strong>falsifiable</strong>. From this point on, all my time goes to evaluation and writing."</td>
|
| 527 |
+
<td class="zh">"老师,v1.0.0 的强化是<strong>一次性投资</strong>——为了让评审老师<strong>用一行命令就能复现 Chapter 5 的每一个数字</strong>。没有它,AUC = 0.871 就是黑盒,<strong>评审无法独立验证</strong>。现在 <code>make evaluate</code> 能把 6 张图按字节复现,论文的每个 claim 都<strong>可证伪</strong>。从今天起所有时间都给评估和写作。"</td>
|
| 528 |
+
</tr>
|
| 529 |
+
</table>
|
| 530 |
+
|
| 531 |
+
<h3>Q-N2 — "Why hasn't the model improved since last time?"</h3>
|
| 532 |
+
<h3 style="margin-top:-2mm">Q-N2 ——模型为什么自上次以后没提升?</h3>
|
| 533 |
+
|
| 534 |
+
<table class="bilingual">
|
| 535 |
+
<tr>
|
| 536 |
+
<td class="en">"Two reasons. First, the supervisor's instruction was to <em>consolidate</em> dataset and model before adding more capacity — which is what I did. Second, the bottleneck right now is <strong>not the model</strong> but the <strong>rule engine's coverage of OOD scenarios</strong>, which is a Chapter 5 contribution rather than a hyperparameter tweak. I'd rather report a defensible 0.871 with a calibrated rule engine than chase 0.88 with an unprincipled stack."</td>
|
| 537 |
+
<td class="zh">"两个理由:(1) 您上次的指示是<strong>先把 dataset 和 model 巩固好</strong>再加复杂度——我严格照做了。(2) <strong>当前瓶颈不是模型本身</strong>,而是<strong>规则引擎对 OOD 场景的覆盖</strong>——这是 Chapter 5 的研究贡献,不是调超参。我宁愿报一个<strong>可辩护的 0.871</strong> 加一个校准好的规则引擎,<strong>也不要不讲原理地堆栈到 0.88</strong>。"</td>
|
| 538 |
+
</tr>
|
| 539 |
+
</table>
|
| 540 |
+
|
| 541 |
+
<h3>Q-N3 — "Show me one concrete weakness you have not yet fixed."</h3>
|
| 542 |
+
<h3 style="margin-top:-2mm">Q-N3 ——给我说一个你目前还没修的具体弱点。</h3>
|
| 543 |
+
|
| 544 |
+
<table class="bilingual">
|
| 545 |
+
<tr>
|
| 546 |
+
<td class="en">"Honestly, Sir, the biggest one is <code>cape_jkg</code> — the ERA5 archive returns predominantly zero CAPE for these Malaysian coordinates, which is a <strong>known coverage gap</strong>. The Random Forest learns nothing from it (0 % importance). The rule engine still uses live Open-Meteo CAPE at inference time, so the production output is fine, but the <em>training</em> signal for thunderstorm risk is weaker than I'd like. I plan to address this in §5.4 ablation by quantifying how much it matters."</td>
|
| 547 |
+
<td class="zh">"老实说,老师,最大的弱点是 <code>cape_jkg</code>——ERA5 在这些马来西亚坐标上的 CAPE 几乎全为零(<strong>已知覆盖缺口</strong>),<strong>RF 完全没学到东西</strong>(特征重要性 0%)。规则引擎在推理时用的是 Open-Meteo 实时 CAPE,所以生产输出没问题,但<strong>雷暴风险的训练信号</strong>比我希望的弱。计划在 §5.4 消融实验里<strong>量化它的影响</strong>。"</td>
|
| 548 |
+
</tr>
|
| 549 |
+
</table>
|
| 550 |
+
|
| 551 |
+
<h3>Q-N4 — "When can I see the first draft of Chapter 5?"</h3>
|
| 552 |
+
<h3 style="margin-top:-2mm">Q-N4 ——Chapter 5 初稿什么时候能给我看?</h3>
|
| 553 |
+
|
| 554 |
+
<table class="bilingual">
|
| 555 |
+
<tr>
|
| 556 |
+
<td class="en">"If you sign off on tracks <strong>5.1 + 5.2 + 5.4</strong> today, the data collection finishes in 3 weeks, writing takes 1 week, so you'd have a draft in <strong>4 weeks from today</strong>. If you also want 5.5 (user study), add 4 weeks. <strong>I'll lock the date the moment you confirm the scope.</strong>"</td>
|
| 557 |
+
<td class="zh">"如果今天您拍板 <strong>5.1 + 5.2 + 5.4</strong> 三条,<strong>3 周收数据 + 1 周写作 = 4 周后给您初稿</strong>。如果再加 <strong>5.5(用户研究)</strong>,再加 4 周。<strong>您一确认范围,我立刻锁定交稿日</strong>。"</td>
|
| 558 |
+
</tr>
|
| 559 |
+
</table>
|
| 560 |
+
|
| 561 |
+
<!-- ===== Section 7: pre-flight checklist ===== -->
|
| 562 |
+
<h2 class="pb">7 · Pre-flight checklist (T-60 sec)
|
| 563 |
+
<span class="zh">起飞前 60 秒自检</span>
|
| 564 |
+
</h2>
|
| 565 |
+
|
| 566 |
+
<div class="check">
|
| 567 |
+
<pre><code>☐ Laptop ≥ 80 % battery, charger in bag
|
| 568 |
+
☐ Terminal A: `make run` is running, do not close
|
| 569 |
+
☐ Terminal B: `curl /api/health` returned ml_loaded: true within last 5 min
|
| 570 |
+
☐ 10 browser tabs open in cheat-sheet §0 order — app tab is LAST
|
| 571 |
+
☐ This file open on a separate screen / phone, NOT to be read aloud
|
| 572 |
+
☐ docs/MEETING_CHEAT_SHEET.md open as a fall-back
|
| 573 |
+
☐ models/MODEL_CARD.md open in case any number is challenged
|
| 574 |
+
☐ figures/evaluation_summary.json downloadable on demand
|
| 575 |
+
☐ Phone on silent
|
| 576 |
+
☐ One deep breath. You shipped v1.0.0. You're prepared.</code></pre>
|
| 577 |
+
</div>
|
| 578 |
+
|
| 579 |
+
<div class="check">
|
| 580 |
+
<pre><code>☐ 笔记本电池 ≥ 80%,充电器已带
|
| 581 |
+
☐ 终端 A:`make run` 跑着,不要关
|
| 582 |
+
☐ 终端 B:5 分钟内 `curl /api/health` 返回 ml_loaded: true
|
| 583 |
+
☐ 10 个浏览器标签页按 cheat-sheet §0 顺序开好——app 标签放最后
|
| 584 |
+
☐ 本文档开在副屏 / 手机,不要照念
|
| 585 |
+
☐ docs/MEETING_CHEAT_SHEET.md 开着兜底
|
| 586 |
+
☐ models/MODEL_CARD.md 开着,老师质疑任何数字立刻打开
|
| 587 |
+
☐ figures/evaluation_summary.json 随时可发
|
| 588 |
+
☐ 手机静音
|
| 589 |
+
☐ 深呼吸。v1.0.0 已经交付。你准备好了。</code></pre>
|
| 590 |
+
</div>
|
| 591 |
+
|
| 592 |
+
<!-- ===== Section 8: cross references ===== -->
|
| 593 |
+
<h2>8 · Cross-references
|
| 594 |
+
<span class="zh">相关文档索引</span>
|
| 595 |
+
</h2>
|
| 596 |
+
|
| 597 |
+
<table>
|
| 598 |
+
<tr><th>Topic / 主题</th><th>File / 文件</th></tr>
|
| 599 |
+
<tr><td>Original 5/11 reply to 4/15 feedback</td><td><a href="supervisor_meeting_brief.md"><code>supervisor_meeting_brief.md</code></a></td></tr>
|
| 600 |
+
<tr><td>One-page cheat sheet (tab order, demo script)</td><td><a href="MEETING_CHEAT_SHEET.html"><code>MEETING_CHEAT_SHEET.html</code></a></td></tr>
|
| 601 |
+
<tr><td>Pipeline order ASCII chart</td><td><a href="pipeline_order.md"><code>pipeline_order.md</code></a></td></tr>
|
| 602 |
+
<tr><td>Dataset spec + Y derivation</td><td><a href="dataset.md"><code>dataset.md</code></a></td></tr>
|
| 603 |
+
<tr><td>Architecture deep-dive</td><td><a href="architecture.md"><code>architecture.md</code></a></td></tr>
|
| 604 |
+
<tr><td>Threshold citations</td><td><a href="thresholds.md"><code>thresholds.md</code></a></td></tr>
|
| 605 |
+
<tr><td>Model card</td><td><a href="../models/MODEL_CARD.md"><code>../models/MODEL_CARD.md</code></a></td></tr>
|
| 606 |
+
<tr><td>Evaluation summary JSON</td><td><a href="../figures/evaluation_summary.json"><code>../figures/evaluation_summary.json</code></a></td></tr>
|
| 607 |
+
<tr><td>What changed in v1.0.0</td><td><a href="../CHANGELOG.md"><code>../CHANGELOG.md</code></a></td></tr>
|
| 608 |
+
</table>
|
| 609 |
+
|
| 610 |
+
<footer>
|
| 611 |
+
Generated 2026-05-13 for the MicroClimate-X progress-update meeting at UKM.<br>
|
| 612 |
+
此页为 2026-05-13 UKM 毕业设计 MicroClimate-X 进度汇报准备文档。<br>
|
| 613 |
+
<span style="color: var(--brand);">L.ZH @ UKM · KyoukoLi/microclimate-x</span>
|
| 614 |
+
</footer>
|
| 615 |
+
|
| 616 |
+
</main>
|
| 617 |
+
|
| 618 |
+
</body>
|
| 619 |
+
</html>
|
docs/progress_update_brief.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supervisor Progress-Update Brief — bilingual script
|
| 2 |
+
# 导师进度汇报双语逐字稿
|
| 3 |
+
|
| 4 |
+
> Follow-up meeting after the v1.0.0 hardening pass on 2026-05-11.
|
| 5 |
+
> Walk-through order is unchanged: **dataset → model → app → next steps**.
|
| 6 |
+
> Open this file on screen during the meeting; do not read word-for-word.
|
| 7 |
+
>
|
| 8 |
+
> 紧接 2026-05-11 v1.0.0 强化提交之后的**进度汇报**会议。
|
| 9 |
+
> 顺序一律不变:**dataset → model → app → 下一步**。
|
| 10 |
+
> 开会时屏幕上打开本文档,**不要照念**,当兜底用即可。
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 0. What you need to do — three time windows
|
| 15 |
+
## 0. 你要做的事 —— 三个时间窗口
|
| 16 |
+
|
| 17 |
+
### 0.1 Before the meeting (T-15 min) / 会前 15 分钟
|
| 18 |
+
|
| 19 |
+
| ☐ | English | 中文 |
|
| 20 |
+
|---|---|---|
|
| 21 |
+
| ☐ | Charge laptop ≥ 80 %; charger in bag. | 笔记本充满 ≥ 80%,充电器带上。 |
|
| 22 |
+
| ☐ | `cd ~/Projects/microclimate-x && git pull && git status` — must print "working tree clean". | 拉最新代码,确认 working tree clean。 |
|
| 23 |
+
| ☐ | `make run` in **terminal A** (leave it running). | 终端 A 起后端,**不要关**。 |
|
| 24 |
+
| ☐ | `curl -s http://localhost:8000/api/health \| python3 -m json.tool` in **terminal B** — verify `"ml_loaded": true`. | 终端 B 验证健康检查,`ml_loaded` 必须为 `true`。 |
|
| 25 |
+
| ☐ | Open the 10 browser tabs in the order from `docs/MEETING_CHEAT_SHEET.md` §0 — **app tab is last**. | 按 cheat-sheet §0 顺序开 10 个标签页,**app 标签放最后**。 |
|
| 26 |
+
| ☐ | This file (`docs/progress_update_brief.md`) open on a separate screen / phone. | 把本文档单独开在副屏或手机上。 |
|
| 27 |
+
| ☐ | Phone on silent. Deep breath. | 手机静音,深呼吸。 |
|
| 28 |
+
|
| 29 |
+
### 0.2 During the meeting (≈ 8 minutes) / 会中 ≈ 8 分钟
|
| 30 |
+
|
| 31 |
+
| Block | EN heading | 中文标题 | Time |
|
| 32 |
+
|---|---|---|---|
|
| 33 |
+
| 1 | Opening 30 s | 开场 30 秒 | 0:00 → 0:30 |
|
| 34 |
+
| 2 | What changed since last meeting | 自上次会以来的进展 | 0:30 → 2:00 |
|
| 35 |
+
| 3 | Live demo — dataset → model → app | 现场演示(顺序不变) | 2:00 → 5:00 |
|
| 36 |
+
| 4 | Next steps for Chapter 5 | Chapter 5 下一步 | 5:00 → 6:30 |
|
| 37 |
+
| 5 | Asks + closing | 请示 + 收尾 | 6:30 → 8:00 |
|
| 38 |
+
|
| 39 |
+
### 0.3 After the meeting (T+24 h) / 会后 24 小时内
|
| 40 |
+
|
| 41 |
+
| ☐ | English | 中文 |
|
| 42 |
+
|---|---|---|
|
| 43 |
+
| ☐ | Write meeting minutes — capture every supervisor decision in `docs/meeting_log_<date>.md`. | 写会议纪要,把老师每条决定记到 `docs/meeting_log_<日期>.md`。 |
|
| 44 |
+
| ☐ | Open one GitHub issue per agreed action item (label: `chapter-5`). | 每个 action item 在 GitHub 开一个 issue,打 `chapter-5` 标签。 |
|
| 45 |
+
| ☐ | Email a 3-bullet summary back to the supervisor for written confirmation. | 给老师发 3 条要点的总结邮件,留书面确认。 |
|
| 46 |
+
| ☐ | Update `README.md` §9 Roadmap — tick boxes that were signed off. | 更新 `README.md` 第 9 节 Roadmap,把通过的项打勾。 |
|
| 47 |
+
| ☐ | Tag a new release if scope was confirmed (`git tag v1.1.0-rc.1`). | 如果范围确认了,打个新 tag (`v1.1.0-rc.1`)。 |
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## 1. Opening 30 seconds / 开场 30 秒
|
| 52 |
+
|
| 53 |
+
| English (say this) | 中文(口头要点) |
|
| 54 |
+
|---|---|
|
| 55 |
+
| "Sir, thank you for your time. Following up on our last session, I've completed a production-grade hardening pass — version 1.0.0 — and the full pipeline is now reproducible end-to-end. May I walk you through what's new in the same order as before — dataset, then model, then app — and finish with my proposed plan for Chapter 5?" | "老师感谢您抽时间。接着上次的内容,我做完了**v1.0.0 工程化强化**,整条流水线现在可以**端到端复现**。我按上次的顺序——**dataset、model、app**——给您过一遍新的进展,最后讲我对 Chapter 5 的下一步计划,可以吗?" |
|
| 56 |
+
|
| 57 |
+
**Why this opening**: it (a) restates the supervisor's preferred process order without him asking, (b) signals you've made forward progress not just polish, and (c) ends with an explicit ask for direction on Chapter 5 — which is what *he* wants to talk about.
|
| 58 |
+
|
| 59 |
+
**为什么这样开场**:(a) 不用他提就主动按他的流程顺序;(b) 强调是**前进了**而不是只在抛光;(c) 用对 Chapter 5 的请示收尾,**这正是他想聊的话题**。
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## 2. What changed since the last meeting / 自上次会议以来的进展
|
| 64 |
+
|
| 65 |
+
> ~ 90 seconds. Stay on the GitHub repo tab — point to the commit history,
|
| 66 |
+
> the green CI badge, the v1.0.0 release.
|
| 67 |
+
>
|
| 68 |
+
> ≈ 90 秒。停在 GitHub repo 标签页,指给老师看 commit 历史、CI 绿勾、v1.0.0 release。
|
| 69 |
+
|
| 70 |
+
| Area | English | 中文 |
|
| 71 |
+
|---|---|---|
|
| 72 |
+
| **Backend hardening** | "I added a request-ID middleware, a typed `ErrorResponse` contract so no bare HTML 500s leak, structured logging, and an enriched `/api/health` exposing uptime, cache stats, and the loaded ML feature schema." | "后端我加了 **request-ID 中间件**、**类型化错误协议** `ErrorResponse`(不再泄漏裸 HTML 500)、结构化日志、以及**升级版 `/api/health`**(暴露 uptime、缓��统计、ML 特征 schema)。" |
|
| 73 |
+
| **ML pipeline** | "I shipped `scripts/4_evaluate_model.py` which produces six publication-quality figures plus a machine-readable `evaluation_summary.json`. I also wrote a HuggingFace-style `MODEL_CARD.md` covering intended use, training data, metrics, limitations, and ethical considerations." | "ML 流水线加了 **评估脚本** `scripts/4_evaluate_model.py`,自动出 6 张论文级别图 + 一份 `evaluation_summary.json`。还写了 HuggingFace 风格的 **MODEL_CARD.md**,覆盖用途、训练数据、指标、局限、伦理考量。" |
|
| 74 |
+
| **Tests + CI** | "Total tests went from 19 to **70**, backend coverage is **97 %**. CI runs on Python 3.9 / 3.11 / 3.12 plus a Docker image-build smoke test." | "测试数从 19 涨到 **70**,**后端覆盖率 97%**。CI 跑 Python 3.9/3.11/3.12 矩阵,外加 Docker 镜像构建烟测。" |
|
| 75 |
+
| **Dev-ex** | "Multi-stage Dockerfile, docker-compose, Makefile single-word recipes, pre-commit hooks. The whole project is now `docker compose up --build` away from a clean machine." | "多阶段 Dockerfile + compose + Makefile 单词命令 + pre-commit hooks。**新机器一句 `docker compose up --build` 就能跑起来**。" |
|
| 76 |
+
| **Documentation** | "Three new docs — `architecture.md`, `thresholds.md` with citations for every Veto threshold, and `pipeline_order.md` which explicitly enforces the dataset → model → app order you asked for." | "三份新文档——`architecture.md`、`thresholds.md`(每个 Veto 阈值都附学术引用)、以及 `pipeline_order.md`(**显式按您要求的 dataset→model→app 顺序写死**)。" |
|
| 77 |
+
|
| 78 |
+
**Artefact to show**: the GitHub commit history page; the green CI badge on the README; `CHANGELOG.md` v1.0.0 entry.
|
| 79 |
+
|
| 80 |
+
**展示物**:GitHub commit 历史页;README 上的 CI 绿勾;`CHANGELOG.md` 中 v1.0.0 那一段。
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## 3. Live demo — dataset → model → app / 现场演示(顺序不变)
|
| 85 |
+
|
| 86 |
+
> ~ 3 minutes. Same order as the 5/11 dry-run script — no surprises for the supervisor.
|
| 87 |
+
>
|
| 88 |
+
> ≈ 3 分钟。跟 5/11 的脚本完全一样的顺序,**老师不会被打乱节奏**。
|
| 89 |
+
|
| 90 |
+
### 3.1 Dataset (Tab `docs/dataset.md`) — 30 s
|
| 91 |
+
|
| 92 |
+
| EN | 中文 |
|
| 93 |
+
|---|---|
|
| 94 |
+
| "Same dataset as last time — ERA5 reanalysis, 5 Malaysian mountain sites, 175 315 hourly rows. The Y column `is_rain_event` is derived in one line and documented in §5. No change here, just confirming the foundation is unchanged." | "数据集跟上次一样——ERA5 再分析、马来西亚 5 个山地点位、17.5 万行小时数据。Y 列 `is_rain_event` 一行代码构造,文档在 §5。**这里没有变**,只是确认地基没动。" |
|
| 95 |
+
|
| 96 |
+
### 3.2 Model (Tabs `01_roc_curve.png` → `03_calibration_curve.png` → `04_threshold_sweep.png` → `05_feature_importance.png`) — 90 s
|
| 97 |
+
|
| 98 |
+
| EN | 中文 |
|
| 99 |
+
|---|---|
|
| 100 |
+
| "Same model as last time — Random Forest, time-based split, τ = 0.20. Test ROC AUC **0.871**, PR AP **0.750**, Brier **0.138**, recall **93.4 %**. What's new is the **6 figures plus the model card** — every number you see here is reproducible from `make evaluate`." | "模型跟上次一样——RF、时间序列切分、τ = 0.20。测试 AUC **0.871**、PR AP **0.750**、Brier **0.138**、召回率 **93.4%**。**新东西**是 6 张图 + model card——上面任何一个数字都可以用 `make evaluate` 复现。" |
|
| 101 |
+
|
| 102 |
+
### 3.3 App (Tab `http://localhost:8000/app/`) — 60-90 s
|
| 103 |
+
|
| 104 |
+
| EN | 中文 |
|
| 105 |
+
|---|---|
|
| 106 |
+
| "Step 3, the app — opened **last** as agreed. Two demo scenarios. First, Genting Highlands — a slope at 1865 m inside the training distribution. The model gives a moderate rain probability; the rule engine picks up orographic lift; the four mini-gauges decompose the risk by hazard type." | "第三步 app——按约定**最后才开**。两个 demo 场景。第一个云顶高原——1865 m 的山坡,**在训练分布之内**。模型给中等降雨概率,规则引擎检测到地形抬升,四个 mini-gauge 把风险按灾害类型拆解。" |
|
| 107 |
+
| "Second, Mt Everest — completely out of distribution. The model alone would say 'safe'. The Veto cascade fires three independent overrides — hypoxia, frostbite, gale — and the composite is forced to Danger. There's a unit test for exactly this: `test_mt_everest_veto_hypoxia`." | "第二个珠峰——**完全分布外**。光看模型会说"安全",但 Veto 级联触发**三个独立否决**——缺氧、冻伤、大风——综合分被强制设为 Danger。**专门有单元测试覆盖这个场景**:`test_mt_everest_veto_hypoxia`。" |
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## 4. Next steps for Chapter 5 / Chapter 5 下一步
|
| 112 |
+
|
| 113 |
+
> ~ 90 seconds. **This is the section the supervisor will react to most.**
|
| 114 |
+
> Frame each item as a concrete deliverable + estimated time + dependency.
|
| 115 |
+
>
|
| 116 |
+
> ≈ 90 秒。**老师反应最强烈的就是这一节**。每一项都以"**交付物 + 估时 + 依赖**"形式呈现。
|
| 117 |
+
|
| 118 |
+
### 4.1 Proposed Chapter 5 work plan / Chapter 5 工作计划
|
| 119 |
+
|
| 120 |
+
| # | Deliverable | EN one-liner | 中文一句话 | Estimate |
|
| 121 |
+
|---|---|---|---|---|
|
| 122 |
+
| 5.1 | **Comparative ablation** | "Train LogReg + XGBoost on the same features and report ROC / PR / F2 side-by-side with RF — answers 'why RF?' empirically." | "在同一特征集上训 LogReg + XGBoost,对比 ROC / PR / F2,**用数据回答"为什么选 RF"**。" | 1 week |
|
| 123 |
+
| 5.2 | **Hindcast validation** | "Replay 2020-2024 NaDMA-documented Malaysian flood / landslide events and check whether the system would have raised Warning / Danger at the right time. Reports hit-rate, lead-time, false-alarm rate." | "把 2020-2024 NaDMA 公开的马来西亚洪水/滑坡事件**逐一回放**,看系统能否在事发前给出 Warning/Danger。报告命中率、提前量、误报率。" | 2 weeks |
|
| 124 |
+
| 5.3 | **Threshold sensitivity** | "Sweep τ ∈ {0.10, 0.15, 0.20, 0.25, 0.30}, plot precision-recall trade-off, and justify the operating point with a cost-of-error analysis." | "扫 τ ∈ {0.10, 0.15, 0.20, 0.25, 0.30},画精度-召回权衡曲线,用**误差代价分析**为最终选点辩护。" | 3 days |
|
| 125 |
+
| 5.4 | **Component ablation** | "Compare three system variants — RF only / Rule only / Hybrid — on the held-out test set and on the OOD Mt Everest case. Quantifies the rule-engine contribution." | "对比三个系统变体——**纯 RF / 纯规则 / 混合**——在测试集和 OOD 珠峰场景上的表现。**量化规则引擎的贡献**。" | 4 days |
|
| 126 |
+
| 5.5 | **Small user study** *(optional)* | "Recruit 5-8 mountain hikers, run a 4-week panel, log system advice vs. their field judgment. Reports inter-rater agreement (Cohen's κ)." | "招募 5-8 名登山者,4 周面板研究,记录系统建议 vs 他们现场判断,报告 Cohen's κ 一致性。" | 4 weeks |
|
| 127 |
+
| 5.6 | **Thesis Chapter 5 draft** | "Pull §5.1-5.5 into a single 12-15 page evaluation chapter with all figures, tables, and discussion." | "把 §5.1-5.5 整合成 12-15 页的评估章节,含全部图表和讨论。" | 1 week (after 5.1-5.4) |
|
| 128 |
+
|
| 129 |
+
### 4.2 Decision tree to ask the supervisor / 请示决策树
|
| 130 |
+
|
| 131 |
+
| Question to ask | EN | 中文 |
|
| 132 |
+
|---|---|---|
|
| 133 |
+
| **Q1** | "Sir, of the five evaluation tracks above, which two should I prioritise for the **next four weeks** before we converge on the Chapter 5 outline?" | "老师,上面 5 条评估方向,**未来四周**您建议我重点做哪两条,然后再收敛到 Chapter 5 大纲?" |
|
| 134 |
+
| **Q2** | "Do you want me to include the user study (5.5)? It is the longest item and depends on participant recruitment — I want your call before committing." | "**用户研究 (5.5) 您要不要做**?这一条最长、依赖招募——想请您拍板再投入。" |
|
| 135 |
+
| **Q3** | "For the comparative ablation, do you want the comparison framed as 'why RF wins' (defending current choice) or 'what if XGBoost wins' (open exploration)? The framing affects how I report inconclusive results." | "**对比实验**您希望框成"为什么 RF 胜出"(**捍卫现有选择**)还是"如果 XGBoost 更好怎么办"(**开放探索**)?两种 framing 对**模棱两可结果**的报告方式不同。" |
|
| 136 |
+
| **Q4** | "Should I treat the Mt Everest OOD test as a thesis-level contribution (a stand-alone subsection on safety) or just an appendix item?" | "**珠峰 OOD 测试**算论文级别的贡献(单独一节讲安全性),还是放附录就够?" |
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## 5. Asks + closing 60 seconds / 请示 + 收尾 60 秒
|
| 141 |
+
|
| 142 |
+
| EN (say this) | 中文(口头要点) |
|
| 143 |
+
|---|---|
|
| 144 |
+
| "Sir, to summarise: since the last meeting I've shipped v1.0.0 — production-grade hardening, 70 tests at 97 % coverage, six evaluation figures, a published model card, full Docker reproducibility. The pipeline order is unchanged from what you asked: dataset, model, app. For Chapter 5 I have five evaluation tracks scoped; I'd like your guidance on which two to prioritise for the next four weeks." | "老师,总结:自上次会议以来交付了 **v1.0.0**——工程化强化、70 个测试 97% 覆盖率、6 张评估图、model card、Docker 全复现。流水线顺序按您要求**没动**:dataset、model、app。Chapter 5 我列了 5 条评估方向,**接下来四周您建议我先做哪两条**?" |
|
| 145 |
+
| "I'll send you a 3-bullet email summary by tomorrow morning so we have written agreement on the priorities. Thank you for your time." | "明早之前给您发 3 条要点的邮件总结,**留个书面确认**。谢谢老师。" |
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
## 6. Q&A defensive lines / Q&A 兜底话术
|
| 150 |
+
|
| 151 |
+
> Anticipated follow-up questions from this update specifically (not the
|
| 152 |
+
> classics from the 5/11 brief — those are still live, just don't repeat
|
| 153 |
+
> them here).
|
| 154 |
+
>
|
| 155 |
+
> **针对本次进度汇报**可能出现的追问(5/11 那份的经典 Q1-Q7 仍然有效,
|
| 156 |
+
> 不重复罗列)。
|
| 157 |
+
|
| 158 |
+
### Q-N1 — "Why are you spending time on tests and Docker instead of the thesis?"
|
| 159 |
+
### Q-N1 ——为什么你在写测试和 Docker 上花时间,不写论文?
|
| 160 |
+
|
| 161 |
+
| EN | 中文 |
|
| 162 |
+
|---|---|
|
| 163 |
+
| "Sir, the v1.0.0 hardening was a one-time investment to make every Chapter 5 number reproducible by the examiner with a single command. Without it, every evaluation result would be a black box — the examiner could not verify the AUC of 0.871 herself. With `make evaluate` reproducing all six figures byte-for-byte, the thesis claims become falsifiable. From this point on, all my time goes to evaluation and writing." | "老师,v1.0.0 的强化是**一次性投资**——为了让评审老师**用一行命令就能复现 Chapter 5 的每一个数字**。没有它,AUC = 0.871 就是黑盒,**评审无法独立验证**。现在 `make evaluate` 能把 6 张图按字节复现,论文的每个 claim 都**可证伪**。从今天起所有时间都给评估和写作。" |
|
| 164 |
+
|
| 165 |
+
### Q-N2 — "Why hasn't the model improved since last time?"
|
| 166 |
+
### Q-N2 ——模型为什么自上次以后没提升?
|
| 167 |
+
|
| 168 |
+
| EN | 中文 |
|
| 169 |
+
|---|---|
|
| 170 |
+
| "Two reasons. First, the supervisor's instruction was to *consolidate* dataset and model before adding more capacity — which is what I did. Second, the bottleneck right now is **not the model** but the **rule engine's coverage of OOD scenarios**, which is a Chapter 5 contribution rather than a hyperparameter tweak. I'd rather report a defensible 0.871 with a calibrated rule engine than chase 0.88 with an unprincipled stack." | "两个理由:(1) 您上次的指示是**先把 dataset 和 model 巩固好**再加复杂度——我严格照做了。(2) **当前瓶颈不是模型本身**,而是**规则引擎对 OOD 场景的覆盖**——这是 Chapter 5 的研究贡献,不是调超参。我宁愿报一个**可辩护的 0.871** 加一个校准好的规则引擎,**也不要不讲原理地堆栈到 0.88**。" |
|
| 171 |
+
|
| 172 |
+
### Q-N3 — "Show me one concrete weakness you have not yet fixed."
|
| 173 |
+
### Q-N3 ——给我说一个你目前**还没修**的具体弱点。
|
| 174 |
+
|
| 175 |
+
| EN | 中文 |
|
| 176 |
+
|---|---|
|
| 177 |
+
| "Honestly, Sir, the biggest one is `cape_jkg` — the ERA5 archive returns predominantly zero CAPE for these Malaysian coordinates, which is a known coverage gap. The Random Forest learns nothing from it (0 % importance). The rule engine still uses live Open-Meteo CAPE at inference time, so the production output is fine, but the *training* signal for thunderstorm risk is weaker than I'd like. I plan to address this in §5.4 ablation by quantifying how much it matters." | "老实说,老师,最大的弱点是 **`cape_jkg`**——ERA5 在这些马来西亚坐标上的 CAPE 几乎全为零(**已知覆盖缺口**),**RF 完全没学到东西**(特征重要性 0%)。规则引擎在推理时用的是 Open-Meteo 实时 CAPE,所以生产输出没问题,但**雷暴风险的训练信号**比我希望的弱。计划在 §5.4 消融实验里**量化它的影响**。" |
|
| 178 |
+
|
| 179 |
+
### Q-N4 — "When can I see the first draft of Chapter 5?"
|
| 180 |
+
### Q-N4 ——Chapter 5 初稿什么时候能给我看?
|
| 181 |
+
|
| 182 |
+
| EN | 中文 |
|
| 183 |
+
|---|---|
|
| 184 |
+
| "If you sign off on tracks 5.1 + 5.2 + 5.4 today, the data collection finishes in 3 weeks, writing takes 1 week, so you'd have a draft in **4 weeks from today**. If you also want 5.5 (user study), add 4 weeks. I'll lock the date the moment you confirm the scope." | "如果今天您拍板 **5.1 + 5.2 + 5.4** 三条,**3 周收数据 + 1 周写作 = 4 周后给您初稿**。如果再加 **5.5(用户研究)**,再加 4 周。**您一确认范围,我立刻锁定交稿日**。" |
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
## 7. Materials checklist before walking in / 开会前自检清单
|
| 189 |
+
|
| 190 |
+
```
|
| 191 |
+
☐ Laptop ≥ 80 % battery, charger in bag
|
| 192 |
+
☐ Terminal A: `make run` is running, do not close
|
| 193 |
+
☐ Terminal B: `curl /api/health` returned ml_loaded: true within last 5 min
|
| 194 |
+
☐ 10 browser tabs open in cheat-sheet §0 order — app tab is LAST
|
| 195 |
+
☐ This file open on a separate screen / phone, NOT to be read aloud
|
| 196 |
+
☐ docs/MEETING_CHEAT_SHEET.md open as a fall-back
|
| 197 |
+
☐ models/MODEL_CARD.md open in case any number is challenged
|
| 198 |
+
☐ figures/evaluation_summary.json downloadable on demand
|
| 199 |
+
☐ Phone on silent
|
| 200 |
+
☐ One deep breath. You shipped v1.0.0. You're prepared.
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
```
|
| 204 |
+
☐ 笔记本电池 ≥ 80%,充电器已带
|
| 205 |
+
☐ 终端 A:`make run` 跑着,不要关
|
| 206 |
+
☐ 终端 B:5 分钟内 `curl /api/health` 返回 ml_loaded: true
|
| 207 |
+
☐ 10 个浏览器标签页按 cheat-sheet §0 顺序开好——app 标签放最后
|
| 208 |
+
☐ 本文档开在副屏 / 手机,不要照念
|
| 209 |
+
☐ docs/MEETING_CHEAT_SHEET.md 开着兜底
|
| 210 |
+
☐ models/MODEL_CARD.md 开着,老师质疑任何数字立刻打开
|
| 211 |
+
☐ figures/evaluation_summary.json 随时可发
|
| 212 |
+
☐ 手机静音
|
| 213 |
+
☐ 深呼吸。v1.0.0 已经交付。你准备好了。
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## 8. Cross-references / 相关文档索引
|
| 219 |
+
|
| 220 |
+
| Topic | File |
|
| 221 |
+
|---|---|
|
| 222 |
+
| Original 5/11 reply to 4/15 feedback | [`supervisor_meeting_brief.md`](supervisor_meeting_brief.md) |
|
| 223 |
+
| One-page cheat sheet (tab order, demo script) | [`MEETING_CHEAT_SHEET.md`](MEETING_CHEAT_SHEET.md) |
|
| 224 |
+
| Pipeline order ASCII chart | [`pipeline_order.md`](pipeline_order.md) |
|
| 225 |
+
| Dataset spec + Y derivation | [`dataset.md`](dataset.md) |
|
| 226 |
+
| Architecture deep-dive | [`architecture.md`](architecture.md) |
|
| 227 |
+
| Threshold citations | [`thresholds.md`](thresholds.md) |
|
| 228 |
+
| Model card | [`../models/MODEL_CARD.md`](../models/MODEL_CARD.md) |
|
| 229 |
+
| Evaluation summary JSON | [`../figures/evaluation_summary.json`](../figures/evaluation_summary.json) |
|
| 230 |
+
| What changed in v1.0.0 | [`../CHANGELOG.md`](../CHANGELOG.md) |
|
| 231 |
+
|
| 232 |
+
---
|
| 233 |
+
|
| 234 |
+
> *Generated 2026-05-13 for the MicroClimate-X progress-update meeting at UKM.
|
| 235 |
+
> 此页为 2026-05-13 UKM 毕业设计 MicroClimate-X 进度汇报准备文档。*
|
docs/supervisor_meeting_brief.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supervisor Meeting Brief — bilingual script
|
| 2 |
+
# 导师开会双语逐字稿
|
| 3 |
+
|
| 4 |
+
> Single-page meeting brief addressing every point of feedback from the
|
| 5 |
+
> 4/15 supervisor session. Bring this document open on screen during the
|
| 6 |
+
> meeting and walk through it in order.
|
| 7 |
+
>
|
| 8 |
+
> 一页式开会简报,逐条回应 4/15 导师 review 的所有反馈。开会时直接打开
|
| 9 |
+
> 此页,按顺序走一遍即可。
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Opening 30 seconds / 开场 30 秒
|
| 14 |
+
|
| 15 |
+
| English (say this) | 中文(口头要点) |
|
| 16 |
+
|---|---|
|
| 17 |
+
| "Sir, since our last meeting I have addressed every point of your feedback. May I walk you through them in the correct order — dataset first, then model, then app — as you instructed?" | "老师,按您上次反馈,我已经把每一条都改了。我按您要求的顺序——**先 dataset,再 model,最后才是 app**——给您过一遍可以吗?" |
|
| 18 |
+
|
| 19 |
+
**Why this opening works**: it explicitly *names* the supervisor's #1 process complaint ("app is last"). He'll relax immediately because he can see you listened.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## Concern #1 — Y target was missing
|
| 24 |
+
## 反馈一 · 缺少目标列 Y
|
| 25 |
+
|
| 26 |
+
**His original words**: "Y is missing. I don't have the output variable. If you don't have target, you cannot train a machine learning model."
|
| 27 |
+
|
| 28 |
+
| English (say this) | 中文(口头要点) |
|
| 29 |
+
|---|---|
|
| 30 |
+
| "Sir, you were right — the raw Open-Meteo CSV has no Y column. I have engineered the target explicitly. The variable is called `is_rain_event` and it is defined as 1 if the precipitation in the **next hour** is greater than 0.1 mm, else 0. The code is one line in `scripts/2_preprocess.py`." | "老师您说得对,原始 Open-Meteo CSV 确实没有 Y 列。我现在已经显式构造了目标变量,叫做 **`is_rain_event`**,定义是:**下一小时降雨量 > 0.1 mm 则为 1,否则为 0**。代码就一行,写在 `scripts/2_preprocess.py`。" |
|
| 31 |
+
| [Show this code on screen:] `df['is_rain_event'] = (df['precipitation'].shift(-1) > 0.1).astype(int)` | (把这一行代码投出来给老师看) |
|
| 32 |
+
| "Three things to notice: `.shift(-1)` means I use **future** rain as the label — features at hour t predict outcome at t+1h, so there is no temporal leakage. The 0.1 mm threshold matches the **WMO definition** of trace precipitation, not an arbitrary choice. And it is binary classification, not regression, because the downstream decision is binary." | "三个要点:(1) `.shift(-1)` 表示用**下一小时**的降雨作为标签,特征是 t 时刻、预测的是 t+1 小时——没有时间泄漏。(2) 0.1 mm 这个阈值不是我随便定的,对应 **WMO 微量降水标准**。(3) 是二分类不是回归,因为下游用户决策本身就是二元的(去 / 不去)。" |
|
| 33 |
+
|
| 34 |
+
**Artefact to show**: `docs/dataset.md` §5 (Target label derivation) — has all three points written out.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## Concern #2 — features in the document did not match the Excel
|
| 39 |
+
## 反馈二 · 文档里的特征跟 CSV 列名对不上
|
| 40 |
+
|
| 41 |
+
**His original words**: "The features that you presented here, not... not mentioned in the Excel. So, it must be matched."
|
| 42 |
+
|
| 43 |
+
| English (say this) | 中文(口头要点) |
|
| 44 |
+
|---|---|
|
| 45 |
+
| "Sir, that was also a fair point. I have rewritten the dataset specification so the documentation lists exactly the **same column names** that appear in the CSV. There is a one-to-one mapping in `docs/dataset.md` §4." | "老师,这条您也说对了。我已经把数据集文档完全重写,文档里列出的就是 CSV 里的**真实列名**,一一对应。在 `docs/dataset.md` 第 4 节。" |
|
| 46 |
+
| [Open dataset.md §4 schema table] "Every row in this table is one column in the actual CSV. The role column says whether it is a feature (X), the target (Y), or just metadata." | (打开 dataset.md §4 列结构表)"表里每一行就是 CSV 里的一列,role 列写明了它是 feature(X)、target(Y)还是 metadata。" |
|
| 47 |
+
|
| 48 |
+
**Artefact to show**: `docs/dataset.md` §4 — single canonical schema table.
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Concern #3 — study the data source
|
| 53 |
+
## 反馈三 · 研究数据源本身
|
| 54 |
+
|
| 55 |
+
**His original words**: "Please study the link. What is the purpose of the dataset? What is design for? What is the output variable?"
|
| 56 |
+
|
| 57 |
+
| English (say this) | 中文(口头要点) |
|
| 58 |
+
|---|---|
|
| 59 |
+
| "I read the Open-Meteo API documentation carefully. The dataset I use is the **ERA5 reanalysis archive**, which is ECMWF's gold-standard hourly reanalysis — they use it to validate other forecast models. It is *not* a forecast, it is a physically-consistent reconstruction of past weather, which is why it is the right dataset for training: the labels are reliable ground truth." | "我把 Open-Meteo 文档仔细读了。我用的是 **ERA5 再分析数据**,是 ECMWF 出的同化产品,气象学界用它当作**真值**去校验别的预报模型。它**不是**预报,而是对过去天气的物理一致的重建。所以用来训练 ML 是合适的——标签是可靠的 ground truth。" |
|
| 60 |
+
| "Spatial coverage: 5 Malaysian mountain sites — Genting, Cameron, Fraser's Hill, Klang Valley, Kinabalu — chosen to span elevations from 100 m to 1865 m and terrain types from valley to slope." | "空间覆盖 5 个马来西亚山地点位——云顶、金马仑、福隆港、巴生谷、神山——海拔从 100 m 到 1865 m,地形从山谷到山坡都有。" |
|
| 61 |
+
| "Temporal coverage: 5 years, hourly, 175 315 rows in total." | "时间范围 5 年,每小时一行,总共 175 315 行。" |
|
| 62 |
+
|
| 63 |
+
**Artefact to show**: `docs/dataset.md` §1-3, or open the Open-Meteo documentation page itself if he wants the original source.
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
## Concern #4 — process order was wrong: app should be last
|
| 68 |
+
## 反馈四 · 流程顺序错了,app 应该最后做
|
| 69 |
+
|
| 70 |
+
**His original words**: "First, identify a dataset. Identify a dataset. And then train the model. And then predict it. First. Once everything is finished... okay, you can develop the app. App is the last."
|
| 71 |
+
|
| 72 |
+
| English (say this) | 中文(口头要点) |
|
| 73 |
+
|---|---|
|
| 74 |
+
| "Yes Sir, I followed your process. The current state is: Step 1 dataset is identified and documented. Step 2 the model is trained — let me show you the results before I open the app." | "好的老师,我严格按您的流程做的。当前状态是:**第一步 dataset 已确认并文档化**;**第二步模型已训练完毕**——在打开 app 之前,先给您看训练结果。" |
|
| 75 |
+
| [Open `figures/01_roc_curve.png`] "Test ROC AUC is 0.871 on 35 063 held-out hourly samples. The hold-out is the **last 20 % chronologically**, not a random split — random splits leak temporal autocorrelation and would inflate accuracy unrealistically." | (打开 ROC 图)"测试集 35 063 行,ROC AUC = **0.871**。划分用的是**按时间排序的最后 20%**,不是随机划分——随机划分会泄漏时间自相关,把准确率虚高 5-15 个百分点。" |
|
| 76 |
+
| [Open `figures/03_calibration_curve.png`] "Brier score is 0.138, which means the predicted probabilities are well-calibrated — when the model says 70 % chance of rain, the actual rate is close to 70 %." | (打开 calibration 图)"Brier 分数 = 0.138,说明预测概率**校准良好**——模型说 70% 下雨概率时,实际频率接近 70%。" |
|
| 77 |
+
| [Open `figures/04_threshold_sweep.png`] "I optimised the decision threshold for **F2 score**, not F1, because in this safety-critical application a missed rain event on a windward slope can lead to flash flooding — false negatives are much worse than false positives. F2 weights recall four times more than precision. The optimal threshold is τ = 0.20, giving F2 = 0.778 and **93.4 % recall**." | (打开阈值扫描图)"我用 **F2 分数**而不是 F1 来选最优阈值——因为这是安全关键应用,**漏报**比误报严重得多(在迎风坡漏掉一次降雨可能引发山洪)。F2 把召回率的权重设为精度的 4 倍,最优阈值是 τ = 0.20,F2 = 0.778,**召回率 93.4%**。" |
|
| 78 |
+
| [Open `figures/05_feature_importance.png`] "Top-3 features the model relies on: previous hour's rain, time-of-day cyclic encoding, and 3-hour pressure tendency. These match the meteorological literature — autocorrelation, diurnal cycle, and storm precursor." | (打开特征重要性图)"模型最看重的 3 个特征:上一小时降水、时间周期编码、3 小时气压变化。这跟气象文献吻合——自相关、日变化、风暴前兆。" |
|
| 79 |
+
| **[NOW open the app]** "Step 3, the app. This is FastAPI + Vue using the trained model. When I click a coordinate, the system returns the probability and the four hazard sub-scores per the proposal §3.7." | **(这时才打开 app)**"第三步,app。这是 FastAPI + Vue 调用上面训好的模型。我点地图任意一点,系统返回概率和四个分项灾害评分(按开题 §3.7)。" |
|
| 80 |
+
|
| 81 |
+
**Why this order matters**: he literally said "App is the last" three times. Showing dataset → ROC → calibration → threshold → importance → THEN the app is exactly the order he asked for. Each chart takes 20-30 seconds to explain; total before opening the app ≈ 2-3 minutes.
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Concern #5 — regression or classification?
|
| 86 |
+
## 反馈五 · 回归还是分类?
|
| 87 |
+
|
| 88 |
+
**His original words**: "I don't think this is a classification problem because there is no class label. So I think this is a regression problem."
|
| 89 |
+
|
| 90 |
+
| English (say this) | 中文(口头要点) |
|
| 91 |
+
|---|---|
|
| 92 |
+
| "Sir, when you first looked at the raw CSV, there was no class label, so regression looked like the only option. I considered both. I chose **binary classification** for three reasons:" | "老师,您当时看原始 CSV 的时候确实没有 class label,所以看上去像 regression。我两个都考虑过,最后选了**二分类**,三个理由:" |
|
| 93 |
+
| **(1)** "The downstream decision is binary — go outside or don't. Regressing on mm of rain would still need a threshold to convert to a go/no-go output, so I would have to pick the threshold anyway." | **(1)** "下游决策本身就是二元的——出门 vs 不出门。即使做回归预测降雨毫米数,最后也要拿��个阈值转成 go/no-go,**那个阈值反正要选**。" |
|
| 94 |
+
| **(2)** "Classification lets me optimise **F2 score**, which is the right metric for a safety-critical setting where recall matters more than precision. I cannot directly optimise F2 on a regression target." | **(2)** "做分类才能直接优化 **F2 分数**——安全关键场景下召回比精度更重要,**这个指标只在分类任务下有意义**。" |
|
| 95 |
+
| **(3)** "But I still expose the **raw probability** in the API response, so any downstream component that needs a continuous score (e.g. the rule engine's rainfall sub-scorer) can still use it. So I keep the best of both worlds." | **(3)** "但 API 还是把**原始概率**暴露出来了,下游需要连续分数的组件(比如规则引擎的降雨子评分器)照样能用。**两全其美**。" |
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## Likely follow-up questions / 老师可能追问的问题
|
| 100 |
+
|
| 101 |
+
### Q1 — "Why Random Forest and not deep learning / LSTM?"
|
| 102 |
+
### Q1 ——为什么选 Random Forest 而不是深度学习 / LSTM?
|
| 103 |
+
|
| 104 |
+
| English | 中文 |
|
| 105 |
+
|---|---|
|
| 106 |
+
| "Three reasons. First, **interpretability**: feature importance lets me defend why the model predicts what it predicts — essential for a safety-critical application. A neural net is a black box. Second, **data efficiency**: with 175 K samples, Random Forest reaches state-of-the-art performance; LSTM would need an order of magnitude more data to outperform it. Third, **inference latency**: RF inference is sub-millisecond, which the FastAPI + cache architecture depends on. LSTM would be at least 10× slower and require GPU at inference time." | "三个理由:(1) **可解释性**——feature importance 让我能为每个预测**辩护**,安全关键应用必须有这一点,神经网络是黑盒。(2) **数据效率**——17 万样本下 RF 已经达到 SOTA,LSTM 需要至少 10 倍数据才能超过它。(3) **推理延迟**——RF 推理 < 1 ms,FastAPI + 缓存架构依赖这一点;LSTM 至少慢 10 倍且推理时需要 GPU。" |
|
| 107 |
+
|
| 108 |
+
### Q2 — "How do you handle out-of-distribution input (e.g. Mt Everest)?"
|
| 109 |
+
### Q2 ——分布外输入怎么处理(比如珠峰)?
|
| 110 |
+
|
| 111 |
+
| English | 中文 |
|
| 112 |
+
|---|---|
|
| 113 |
+
| "This is exactly what the **hybrid architecture** is for, Sir. The Random Forest only saw Malaysian mountains, so on Everest it returns a low probability. But the rule engine's Veto cascade catches three independent failures — altitude > 3500 m triggers hypoxia veto, temperature ≤ -5 °C triggers frostbite veto, and wind ≥ 40 km/h triggers gale veto. The composite output goes to Danger regardless of the ML probability. There is a unit test for exactly this scenario — `test_mt_everest_veto_hypoxia` in `tests/test_rule_engine.py`." | "老师,这正是我做**混合架构**的原因。RF 只见过马来西亚的山,所以在珠峰上会返回很低的概率。但**规则引擎的 Veto 级联**会捕获三个独立的失败:海拔 > 3500 m 触发缺氧 Veto,温度 ≤ -5°C 触发冻伤 Veto,风速 ≥ 40 km/h 触发大风 Veto。无论 ML 给什么概率,输出都被强制设为 Danger。我专门为这个场景写了单元测试 `test_mt_everest_veto_hypoxia`。" |
|
| 114 |
+
|
| 115 |
+
### Q3 — "What is the contribution of the topographic rule engine? Could you just use the ML model alone?"
|
| 116 |
+
### Q3 ——地形规则引擎的贡献是什么?只用 ML 不行吗?
|
| 117 |
+
|
| 118 |
+
| English | 中文 |
|
| 119 |
+
|---|---|
|
| 120 |
+
| "ML alone is statistical — it learns averages. But terrain in complex mountainous regions amplifies precipitation locally by orders of magnitude (Roe, 2005, *Annual Review of Earth & Planetary Sciences*). The decision-table R1 in proposal §3.7.2 captures exactly this: when macro rain probability is low but the wind impinges on a windward slope with falling pressure, hidden rain risk emerges. The ML model would say 'safe' here; the rule engine fires R1 and warns the user. This is the **Neuro-Symbolic AI** paradigm — learn what is learnable, hand-code what is physical." | "纯 ML 是统计性的——它学的是平均值。但复杂山地的地形会把降水**局部放大几个数量级**(Roe 2005, Annual Review of Earth & Planetary Sciences)。开题 §3.7.2 的决策表 R1 抓住的正是这一点:宏观降雨概率低、但风正对迎风坡且气压在下降时——存在**隐藏的降雨风险**。ML 在这种情况下会说"安全";规则引擎会触发 R1 警告用户。这是 **Neuro-Symbolic AI** 范式——能学的让 ML 学,物理规律手工编码。" |
|
| 121 |
+
|
| 122 |
+
### Q4 — "Did you do cross-validation? Did you check for overfitting?"
|
| 123 |
+
### Q4 ——做过交叉验证吗?检查过过拟合吗?
|
| 124 |
+
|
| 125 |
+
| English | 中文 |
|
| 126 |
+
|---|---|
|
| 127 |
+
| "Yes Sir, **time-series cross-validation** with 5 folds on the training portion — not random K-fold, which would leak temporal information. The fold AUCs range from 0.828 to 0.908, mean ≈ 0.858, which is very close to the held-out test AUC of 0.871. This consistency confirms the model is not overfitting to a single temporal slice. All fold metrics are in `models/training_report.json` and the model card." | "做了,老师。**时间序列交叉验证**,5 折,**不是**随机 K 折——随机划分会泄漏时间信息。各折 AUC 在 0.828 到 0.908 之间,均值约 0.858,跟独立测试集 AUC 0.871 非常接近——说明模型没有对某个时间段过拟合。所有指标都在 `models/training_report.json` 和 model card 里。" |
|
| 128 |
+
|
| 129 |
+
### Q5 — "How will you validate this in the real world?"
|
| 130 |
+
### Q5 ——你怎么在真实世界验证这套系统?
|
| 131 |
+
|
| 132 |
+
| English | 中文 |
|
| 133 |
+
|---|---|
|
| 134 |
+
| "Two-pronged plan for Chapter 5 evaluation. First, **hindcast validation** — I will replay the system against publicly documented Malaysian flood and landslide events from NaDMA archives and check whether the system would have produced a Warning or Danger verdict at the right time. Second, **user study** — a small panel of mountain hikers will compare the system's recommendations against their own field judgment over a one-month period. Both methodologies follow standard practice in the operational meteorology literature." | "Chapter 5 评估两条腿走路:(1) **历史事件回放** —— 用 NaDMA 公开记录的马来西亚洪水/滑坡事件,看系统在事件发生时是否会给出 Warning 或 Danger。(2) **用户研究** —— 找一小批登山者,一个月内对比系统建议和他们自己的判断。两种方法都是业务气象学界的标准做法。" |
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Closing 30 seconds / 收尾 30 秒
|
| 139 |
+
|
| 140 |
+
| English (say this) | 中文(口头要点) |
|
| 141 |
+
|---|---|
|
| 142 |
+
| "Sir, to summarise: I have addressed every point of your feedback — the missing Y is now derived, the documentation matches the data, the model is trained and evaluated before the app, and the choice of classification over regression is justified by the safety-critical nature of the application. The code is on GitHub at `KyoukoLi/microclimate-x` with CI passing, 97 % test coverage, and a published model card. May I have your guidance on the next priorities for Chapter 5?" | "老师,总结一下:您每条反馈我都已经回应——Y 已经构造好、文档跟数据完全对齐、模型在 app 之前就训好并评估过、分类而不是回归是因为应用本身就是安全关键。代码在 GitHub `KyoukoLi/microclimate-x`,CI 全过、测试覆盖率 97%、有完整的 model card。请问 Chapter 5 接下来您建议我重点做哪部分?" |
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## Materials checklist before walking in / 开会前自检清单
|
| 147 |
+
|
| 148 |
+
- [ ] Laptop charged, browser tab open to `docs/dataset.md`.
|
| 149 |
+
- [ ] All 6 figures in `figures/` rendered to a quick-flip slide deck (or just keep the PNG files in a single Finder window).
|
| 150 |
+
- [ ] GitHub repo page open in another tab, ready to show CI green badge + commit history.
|
| 151 |
+
- [ ] Frontend `frontend/index.html` ready to demo (open `make run` in a terminal **before** the meeting — not during).
|
| 152 |
+
- [ ] `models/MODEL_CARD.md` open in a third tab, in case the supervisor asks for written evidence of any number you quote.
|
| 153 |
+
- [ ] This brief (`docs/supervisor_meeting_brief.md`) open on screen — but **don't read from it word-for-word**, treat it as your safety net only.
|
| 154 |
+
|
| 155 |
+
中文版:
|
| 156 |
+
- [ ] 笔记本充满电,浏览器开好 `docs/dataset.md`
|
| 157 |
+
- [ ] `figures/` 里 6 张图全部预先点开过一次(图片预览快进就行,避免临时加载)
|
| 158 |
+
- [ ] GitHub repo 页面开另一个标签页,CI 绿勾 + commit 历史随时可看
|
| 159 |
+
- [ ] 前端 `make run` **提前**起好(不要开会时才起)
|
| 160 |
+
- [ ] `models/MODEL_CARD.md` 第三个标签页,老师追问任何数字时打开它
|
| 161 |
+
- [ ] **本文档**开着但**不要照念**,当兜底用即可
|
docs/thresholds.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Veto Thresholds & Academic Citations
|
| 2 |
+
# 一票否决阈值与学术引用
|
| 3 |
+
|
| 4 |
+
> **Why this document exists**: the thesis defence panel will ask "why 3500 m?", "why -5 °C?", "why 40 km/h?". Every numeric threshold in `backend/config.py` is justified here against authoritative literature so no value is "magic".
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 1. Altitude hypoxia — `ALTITUDE_HYPOXIA_M = 3500 m`
|
| 9 |
+
|
| 10 |
+
**Rule**: any query above 3500 m AGL immediately receives a Veto.
|
| 11 |
+
|
| 12 |
+
**Citation**: Luks, A. M., Auerbach, P. S., Freer, L., Grissom, C. K., Keyes, L. E., McIntosh, S. E., Rodway, G. W., Schoene, R. B., Zafren, K., & Hackett, P. H. (2019). *Wilderness Medical Society Clinical Practice Guidelines for the Prevention and Treatment of Acute Altitude Illness: 2019 Update*. **Wilderness & Environmental Medicine**, 30(4), S3-S18. https://doi.org/10.1016/j.wem.2019.04.006
|
| 13 |
+
|
| 14 |
+
**Justification**: Acute mountain sickness (AMS) onset is clinically significant above 2500 m and severe physiological hypoxia is the norm above 3500 m without acclimatisation. We adopt 3500 m as the *hard* Veto and 2500-3500 m as a sub-Veto penalty band.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 2. Extreme cold — `EXTREME_COLD_C = -5 °C`
|
| 19 |
+
|
| 20 |
+
**Rule**: ambient temperature ≤ -5 °C triggers a Veto (frostbite risk).
|
| 21 |
+
|
| 22 |
+
**Citation**: Petrone, P., et al. (2014). *Management of accidental hypothermia and cold injury*. **Current Problems in Surgery**, 51(10), 417-431. And UIAA Medical Commission Standard No. 19 (2017) *Frostbite*. https://www.theuiaa.org/medical_advice/
|
| 23 |
+
|
| 24 |
+
**Justification**: Exposed-skin frostbite becomes a real risk when ambient temperatures fall below -5 °C, particularly with any wind. Field guidance from UIAA medical advisors uses -5 °C as a "high vigilance" threshold for outdoor activity.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 3. Gale-force winds — `GALE_WIND_KMH = 40 km/h`
|
| 29 |
+
|
| 30 |
+
**Rule**: wind speed ≥ 40 km/h triggers a Veto.
|
| 31 |
+
|
| 32 |
+
**Citation**: World Meteorological Organization. (2024). *International Codes — Beaufort Wind Force Scale*. https://www.wmo.int/
|
| 33 |
+
|
| 34 |
+
**Justification**: Beaufort Force 6 ("Strong Breeze") covers 39-49 km/h, defined as the regime where "umbrellas are used with difficulty" and walking against the wind becomes hazardous. Above 40 km/h, balance loss and being struck by wind-borne debris become real risks for ridge / exposed-slope hikers.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 4. High CAPE (lightning) — `HIGH_CAPE_JKG = 1000 J/kg`
|
| 39 |
+
|
| 40 |
+
**Rule**: Convective Available Potential Energy ≥ 1000 J/kg triggers a Veto.
|
| 41 |
+
|
| 42 |
+
**Citation**: National Weather Service. *Convective Forecasting Handbook* (latest edition). U.S. National Oceanic and Atmospheric Administration.
|
| 43 |
+
|
| 44 |
+
**Justification**: NWS guidance characterises CAPE > 1000 J/kg as "moderate instability" capable of sustaining thunderstorms with lightning. CAPE > 2500 J/kg is "strong". For a safety-critical application aimed at hikers, the 1000 J/kg threshold provides early warning before lightning becomes likely.
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## 5. Low visibility — `LOW_VISIBILITY_M = 100 m`
|
| 49 |
+
|
| 50 |
+
**Rule**: surface visibility below 100 m triggers a Veto.
|
| 51 |
+
|
| 52 |
+
**Citation**: Federal Aviation Administration. (2024). *Aeronautical Information Manual* §7-1-12. https://www.faa.gov/
|
| 53 |
+
|
| 54 |
+
**Justification**: AIM defines Category III approach conditions as visibility below 200 m. For non-instrument human navigation, 100 m is the conventional "whiteout / dense fog" threshold below which dead-reckoning over alpine terrain becomes infeasible.
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## 6. Orographic uplift — `OROGRAPHIC_DOT_THRESHOLD = 0.7`
|
| 59 |
+
|
| 60 |
+
**Rule**: when the wind-vs-slope-normal dot product ≥ 0.7 AND ML rain probability ≥ 0.5 on a Slope terrain, a Veto fires.
|
| 61 |
+
|
| 62 |
+
**Citation**: Roe, G. H. (2005). *Orographic precipitation*. **Annual Review of Earth and Planetary Sciences**, 33, 645-671. https://doi.org/10.1146/annurev.earth.33.092203.122541
|
| 63 |
+
|
| 64 |
+
**Justification**: Forced ascent of moisture-laden air over a windward slope is one of the highest-rainfall meteorological mechanisms on Earth — entire climate regimes (e.g. Cherrapunji, India) are produced by it. Even when bulk ML probability is moderate, terrain-forced uplift can locally multiply precipitation by an order of magnitude.
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 7. Valley flash-flood — `VALLEY_FLOOD_PROB = 0.80`
|
| 69 |
+
|
| 70 |
+
**Rule**: ML rain probability ≥ 80 % combined with Valley terrain triggers a Veto.
|
| 71 |
+
|
| 72 |
+
**Citation**: Bhuiyan, M. A. E., Anagnostou, E. N., & Kruzdlo, R. (2020). *Improving satellite-based precipitation estimates over complex terrain using machine learning algorithms*. **Journal of Hydrology**, 588, 125060.
|
| 73 |
+
|
| 74 |
+
**Justification**: Valley floors collect water from the entire upstream basin. Even modest rainfall amounts upstream concentrate hydrologically downstream, producing flash floods on timescales as short as 30 minutes. The literature documents disproportionate fatality rates from flash floods relative to other rain-driven hazards.
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 8. Fog sub-hazard — `FOG_HUMIDITY_PCT = 95 %`, `FOG_DEW_DEP_MAX_C = 2 °C`, `FOG_CLOUD_BASE_MAX_M = 800 m`
|
| 79 |
+
|
| 80 |
+
**Rule**: the fog sub-scorer awards near-maximum contribution when humidity ≥ 95 %, dew-point depression ≤ 2 °C, and cloud base ≤ 800 m.
|
| 81 |
+
|
| 82 |
+
**Citation**: World Meteorological Organization. (2019). *Guide to Meteorological Instruments and Methods of Observation (CIMO Guide)*, WMO-No. 8, Chapter on Visibility. https://library.wmo.int/idurl/4/68695
|
| 83 |
+
|
| 84 |
+
**Justification**: WMO surface synoptic codes define fog as visibility < 1 km, which is observed most reliably when humidity is near saturation (typically > 95 %) and dew-point depression is below 2 °C. The 800 m cloud-base ceiling is the value used in the D5 §3.7.2 decision table to detect "low cloud meeting terrain".
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## 9. Wind gust sub-hazard — `GUST_WIND_MIN_KMH = 25 km/h`
|
| 89 |
+
|
| 90 |
+
**Rule**: wind gust sub-score scales linearly with sustained wind from 25 km/h up to the gale Veto at 40 km/h, with terrain amplification for ridges and exposed slopes.
|
| 91 |
+
|
| 92 |
+
**Citation**: WMO Beaufort Wind Force Scale; Holton, J. R. (2004). *An Introduction to Dynamic Meteorology*, 4th ed., on mountain-wave and pass-acceleration phenomena.
|
| 93 |
+
|
| 94 |
+
**Justification**: On exposed ridges and through mountain passes, sustained winds of 25 km/h commonly gust 1.3-1.8× higher (Beaufort F6 territory). Trees and shrubs near peaks become wind-snap hazards, and weight-of-pack stability margins narrow significantly above ~30 km/h sustained.
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## 10. Thunderstorm sub-hazard — `THUNDER_CAPE_MIN_JKG = 500 J/kg`, `THUNDER_PRESSURE_DROP = -2 hPa / 3 h`
|
| 99 |
+
|
| 100 |
+
**Rule**: the thunderstorm sub-scorer adds significant contribution when CAPE ≥ 500 J/kg, with a precipitator boost when pressure has dropped ≥ 2 hPa over the past 3 hours.
|
| 101 |
+
|
| 102 |
+
**Citation**: National Weather Service Convective Outlook reference values; Doswell, C. A. III, & Schultz, D. M. (2006). *On the Use of Indices and Parameters in Forecasting Severe Storms*. **E-Journal of Severe Storms Meteorology**, 1(3).
|
| 103 |
+
|
| 104 |
+
**Justification**: CAPE ≥ 500 J/kg is the conventional "moderate instability" floor at which convective storms become possible (1000 J/kg is the *Veto* — at that level lightning is likely). A 2 hPa / 3 h pressure fall is a textbook frontal-passage / mesoscale-convective-system precursor, well below the rapid-pressure-fall thresholds used in operational forecasting.
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## 11. D5 §3.7.2 / Table 4.2 Decision Table — R1-R4
|
| 109 |
+
|
| 110 |
+
| Rule | Trigger | Conclusion |
|
| 111 |
+
|---|---|---|
|
| 112 |
+
| **R1** | macro rain prob ≤ 30 %, humidity > 85 %, wind into a windward slope, pressure tendency < -1.5 hPa/3h, cloud base < 800 m | Hidden orographic-rain risk despite low macro probability |
|
| 113 |
+
| **R2** | Same humidity / pressure / cloud-base as R1, but wind NOT into slope, terrain leeward or valley | No significant rain — macro forecast is correct |
|
| 114 |
+
| **R3** | macro rain prob ≥ 70 %, wind into a windward slope | Heavy downpour incoming — avoid mountains and valleys |
|
| 115 |
+
| **R4** | macro rain prob ≥ 70 %, no terrain amplification | Standard-rain precautions; no orographic amplification |
|
| 116 |
+
|
| 117 |
+
**Citation**: D5 Proposal — "MicroClimate-X" §3.7.2 Decision Table 4.2 (own work, derived from Roe 2005 orographic-precipitation theory and standard synoptic-meteorology pressure-tendency / cloud-base rules of thumb).
|
| 118 |
+
|
| 119 |
+
**Justification**: This 4-row decision table captures the *thesis-original* contribution — converting macro-scale model output (probability of rain in a coarse grid cell) into a *terrain-aware verdict* by combining wind alignment, humidity, and pressure tendency. The fact that R1 (hidden rain) and R3 (heavy downpour) can both fire on a windward slope while R2 (no risk) fires on a leeward valley with otherwise-identical macro probability is the table's discriminative value.
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 12. Activity weights — D5 §3.7 / P4.4
|
| 124 |
+
|
| 125 |
+
| Activity | Rainfall | Fog | Wind Gust | Thunderstorm |
|
| 126 |
+
|---|---|---|---|---|
|
| 127 |
+
| **hiker** | 1.0 | **1.3** | 1.0 | **1.4** |
|
| 128 |
+
| **driver** | 0.8 | **1.5** | 1.3 | 0.9 |
|
| 129 |
+
| **construction** | 1.0 | 0.8 | **1.5** | **1.4** |
|
| 130 |
+
| **general** | 1.0 | 1.0 | 1.0 | 1.0 |
|
| 131 |
+
|
| 132 |
+
**Justification**:
|
| 133 |
+
- *Hikers* die above tree line from lightning and disorientation in fog (NOLS Wilderness Medicine, 2020 incident review).
|
| 134 |
+
- *Drivers* lose vehicle control most often in fog (visibility), with wind a secondary hazard for high-sided vehicles (FHWA *Road Weather Management* program, 2019).
|
| 135 |
+
- *Construction* workers care about wind (crane / scaffolding) and lightning (OSHA 29 CFR §1926.95 *PPE*).
|
| 136 |
+
- *General* preserves a calibration baseline against which the other profiles can be benchmarked.
|
| 137 |
+
|
| 138 |
+
Per-sub-score weight is multiplied, then per-hazard score is clipped to 100 so a weight of 1.5 cannot push a single sub-score past saturation; the composite formula then aggregates with 80 % weight on the dominant (worst) hazard.
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## Composite-index validity / 复合指数的效度
|
| 143 |
+
|
| 144 |
+
The final 0-100 risk score is a **composite indicator**, not a calibrated probability. Following the methodology of established indices (Fire Weather Index — van Wagner, 1987; Heat Index — Steadman, 1979), validity is established through:
|
| 145 |
+
|
| 146 |
+
1. **Construct validity** — each component has an independent scientific basis.
|
| 147 |
+
2. **Discriminant validity** — extreme samples (Mt Everest, hot calm tropical valley) produce extreme outputs in the expected direction.
|
| 148 |
+
3. **Face validity** — domain experts agree the categorical bins (Safe / Caution / Warning / Danger) map sensibly onto action recommendations.
|
| 149 |
+
|
| 150 |
+
A future *hindcast validation* against published Malaysian flood / landslide events is a planned thesis Chapter 5 contribution.
|
docs/项目大白话讲解.html
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>项目大白话讲解 — MicroClimate-X</title>
|
| 6 |
+
<style>
|
| 7 |
+
/* ============================================================
|
| 8 |
+
Long-form reading layout — comfortable for understanding,
|
| 9 |
+
not just glancing. Optimised for screen first, print second.
|
| 10 |
+
============================================================ */
|
| 11 |
+
:root {
|
| 12 |
+
--ink: #1a1d24;
|
| 13 |
+
--ink-soft: #353a44;
|
| 14 |
+
--muted: #6b7280;
|
| 15 |
+
--brand: #2563eb;
|
| 16 |
+
--brand-soft: #dbeafe;
|
| 17 |
+
--accent: #b91c1c;
|
| 18 |
+
--accent-soft: #fee2e2;
|
| 19 |
+
--ok: #166534;
|
| 20 |
+
--ok-soft: #dcfce7;
|
| 21 |
+
--warn: #b45309;
|
| 22 |
+
--warn-soft: #fef3c7;
|
| 23 |
+
--highlight: #fef08a;
|
| 24 |
+
--grid: #e5e7eb;
|
| 25 |
+
--bg: #fafafa;
|
| 26 |
+
--paper: #ffffff;
|
| 27 |
+
--code-bg: #f3f4f6;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
* { box-sizing: border-box; }
|
| 31 |
+
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); }
|
| 32 |
+
body {
|
| 33 |
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
| 34 |
+
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
| 35 |
+
system-ui, sans-serif;
|
| 36 |
+
font-size: 16px;
|
| 37 |
+
line-height: 1.75;
|
| 38 |
+
letter-spacing: 0.01em;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
@page { size: A4; margin: 14mm 16mm; }
|
| 42 |
+
|
| 43 |
+
/* Two-column layout: TOC sidebar + article body */
|
| 44 |
+
.layout {
|
| 45 |
+
display: grid;
|
| 46 |
+
grid-template-columns: 220px 1fr;
|
| 47 |
+
max-width: 1100px;
|
| 48 |
+
margin: 0 auto;
|
| 49 |
+
gap: 0;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
aside.toc {
|
| 53 |
+
position: sticky;
|
| 54 |
+
top: 70px;
|
| 55 |
+
align-self: start;
|
| 56 |
+
padding: 32px 16px 32px 32px;
|
| 57 |
+
height: calc(100vh - 90px);
|
| 58 |
+
overflow-y: auto;
|
| 59 |
+
border-right: 1px solid var(--grid);
|
| 60 |
+
font-size: 13px;
|
| 61 |
+
}
|
| 62 |
+
aside.toc h3 {
|
| 63 |
+
font-size: 11px;
|
| 64 |
+
text-transform: uppercase;
|
| 65 |
+
letter-spacing: 1.5px;
|
| 66 |
+
color: var(--muted);
|
| 67 |
+
margin: 0 0 12px 0;
|
| 68 |
+
}
|
| 69 |
+
aside.toc ol {
|
| 70 |
+
list-style: none;
|
| 71 |
+
padding: 0;
|
| 72 |
+
margin: 0;
|
| 73 |
+
}
|
| 74 |
+
aside.toc ol li {
|
| 75 |
+
margin: 6px 0;
|
| 76 |
+
line-height: 1.4;
|
| 77 |
+
}
|
| 78 |
+
aside.toc a {
|
| 79 |
+
color: var(--ink-soft);
|
| 80 |
+
text-decoration: none;
|
| 81 |
+
display: block;
|
| 82 |
+
padding: 4px 8px;
|
| 83 |
+
border-radius: 4px;
|
| 84 |
+
border-left: 2px solid transparent;
|
| 85 |
+
}
|
| 86 |
+
aside.toc a:hover {
|
| 87 |
+
background: var(--brand-soft);
|
| 88 |
+
color: var(--brand);
|
| 89 |
+
border-left-color: var(--brand);
|
| 90 |
+
}
|
| 91 |
+
aside.toc a .num { color: var(--muted); margin-right: 6px; font-variant-numeric: tabular-nums; }
|
| 92 |
+
|
| 93 |
+
main {
|
| 94 |
+
background: var(--paper);
|
| 95 |
+
padding: 48px 56px 80px 56px;
|
| 96 |
+
box-shadow: 0 0 0 1px var(--grid);
|
| 97 |
+
min-height: 100vh;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* Headings */
|
| 101 |
+
h1 {
|
| 102 |
+
font-size: 32px;
|
| 103 |
+
font-weight: 700;
|
| 104 |
+
margin: 0 0 8px 0;
|
| 105 |
+
line-height: 1.2;
|
| 106 |
+
}
|
| 107 |
+
.subtitle {
|
| 108 |
+
font-size: 15px;
|
| 109 |
+
color: var(--muted);
|
| 110 |
+
margin: 0 0 8px 0;
|
| 111 |
+
}
|
| 112 |
+
.meta {
|
| 113 |
+
display: flex; gap: 8px; flex-wrap: wrap;
|
| 114 |
+
margin: 16px 0 32px 0;
|
| 115 |
+
font-size: 12px;
|
| 116 |
+
}
|
| 117 |
+
.meta span {
|
| 118 |
+
background: var(--code-bg);
|
| 119 |
+
padding: 3px 10px;
|
| 120 |
+
border-radius: 12px;
|
| 121 |
+
color: var(--muted);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
h2 {
|
| 125 |
+
font-size: 24px;
|
| 126 |
+
margin: 56px 0 16px 0;
|
| 127 |
+
padding-bottom: 8px;
|
| 128 |
+
border-bottom: 2px solid var(--brand);
|
| 129 |
+
color: var(--ink);
|
| 130 |
+
scroll-margin-top: 80px;
|
| 131 |
+
}
|
| 132 |
+
h2 .num {
|
| 133 |
+
color: var(--brand);
|
| 134 |
+
font-weight: 700;
|
| 135 |
+
margin-right: 12px;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
h3 {
|
| 139 |
+
font-size: 18px;
|
| 140 |
+
margin: 32px 0 12px 0;
|
| 141 |
+
color: var(--ink-soft);
|
| 142 |
+
font-weight: 600;
|
| 143 |
+
}
|
| 144 |
+
h4 {
|
| 145 |
+
font-size: 15px;
|
| 146 |
+
margin: 20px 0 8px 0;
|
| 147 |
+
color: var(--accent);
|
| 148 |
+
font-weight: 600;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* Paragraphs / lists */
|
| 152 |
+
p { margin: 12px 0; }
|
| 153 |
+
ul, ol { padding-left: 24px; margin: 12px 0; }
|
| 154 |
+
ul li, ol li { margin: 6px 0; }
|
| 155 |
+
|
| 156 |
+
strong { color: var(--ink); font-weight: 600; }
|
| 157 |
+
em { color: var(--ink-soft); }
|
| 158 |
+
|
| 159 |
+
/* Highlight = the BIG punchline sentences */
|
| 160 |
+
.highlight, mark {
|
| 161 |
+
background: var(--highlight);
|
| 162 |
+
padding: 1px 4px;
|
| 163 |
+
border-radius: 3px;
|
| 164 |
+
color: var(--ink);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* Blockquote = "the big punchline" */
|
| 168 |
+
blockquote {
|
| 169 |
+
margin: 20px 0;
|
| 170 |
+
padding: 16px 20px;
|
| 171 |
+
background: var(--brand-soft);
|
| 172 |
+
border-left: 4px solid var(--brand);
|
| 173 |
+
border-radius: 6px;
|
| 174 |
+
font-size: 16px;
|
| 175 |
+
color: var(--ink);
|
| 176 |
+
}
|
| 177 |
+
blockquote p { margin: 4px 0; }
|
| 178 |
+
blockquote strong { color: var(--brand); }
|
| 179 |
+
|
| 180 |
+
/* Quote box for supervisor verbatim */
|
| 181 |
+
.quote {
|
| 182 |
+
background: var(--warn-soft);
|
| 183 |
+
border-left: 4px solid var(--warn);
|
| 184 |
+
padding: 12px 16px;
|
| 185 |
+
margin: 16px 0;
|
| 186 |
+
font-style: italic;
|
| 187 |
+
font-size: 14px;
|
| 188 |
+
border-radius: 4px;
|
| 189 |
+
}
|
| 190 |
+
.quote::before { content: "🎙️ "; font-style: normal; }
|
| 191 |
+
|
| 192 |
+
/* Analogy / metaphor box */
|
| 193 |
+
.analogy {
|
| 194 |
+
background: #f0fdf4;
|
| 195 |
+
border: 1px dashed var(--ok);
|
| 196 |
+
padding: 16px 20px;
|
| 197 |
+
border-radius: 8px;
|
| 198 |
+
margin: 20px 0;
|
| 199 |
+
font-size: 15px;
|
| 200 |
+
}
|
| 201 |
+
.analogy::before {
|
| 202 |
+
content: "💡 比喻";
|
| 203 |
+
display: block;
|
| 204 |
+
font-size: 12px;
|
| 205 |
+
color: var(--ok);
|
| 206 |
+
font-weight: 700;
|
| 207 |
+
margin-bottom: 6px;
|
| 208 |
+
letter-spacing: 0.5px;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* Callouts */
|
| 212 |
+
.callout {
|
| 213 |
+
margin: 20px 0;
|
| 214 |
+
padding: 14px 18px;
|
| 215 |
+
border-left: 4px solid;
|
| 216 |
+
border-radius: 4px;
|
| 217 |
+
font-size: 15px;
|
| 218 |
+
}
|
| 219 |
+
.callout.warn { background: var(--accent-soft); border-color: var(--accent); }
|
| 220 |
+
.callout.ok { background: var(--ok-soft); border-color: var(--ok); }
|
| 221 |
+
.callout.tip { background: var(--brand-soft); border-color: var(--brand); }
|
| 222 |
+
.callout-title {
|
| 223 |
+
font-weight: 700;
|
| 224 |
+
margin-bottom: 4px;
|
| 225 |
+
text-transform: uppercase;
|
| 226 |
+
letter-spacing: 0.5px;
|
| 227 |
+
font-size: 11px;
|
| 228 |
+
}
|
| 229 |
+
.callout.warn .callout-title { color: var(--accent); }
|
| 230 |
+
.callout.ok .callout-title { color: var(--ok); }
|
| 231 |
+
.callout.tip .callout-title { color: var(--brand); }
|
| 232 |
+
|
| 233 |
+
/* Code */
|
| 234 |
+
code, pre, kbd {
|
| 235 |
+
font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
| 236 |
+
}
|
| 237 |
+
code {
|
| 238 |
+
background: var(--code-bg);
|
| 239 |
+
padding: 1px 6px;
|
| 240 |
+
border-radius: 3px;
|
| 241 |
+
font-size: 13px;
|
| 242 |
+
color: var(--accent);
|
| 243 |
+
}
|
| 244 |
+
pre {
|
| 245 |
+
background: #0f172a;
|
| 246 |
+
color: #e2e8f0;
|
| 247 |
+
padding: 16px 20px;
|
| 248 |
+
border-radius: 8px;
|
| 249 |
+
overflow-x: auto;
|
| 250 |
+
margin: 16px 0;
|
| 251 |
+
font-size: 13px;
|
| 252 |
+
line-height: 1.6;
|
| 253 |
+
}
|
| 254 |
+
pre code { background: transparent; padding: 0; color: inherit; font-size: 13px; }
|
| 255 |
+
|
| 256 |
+
/* Tables */
|
| 257 |
+
table {
|
| 258 |
+
border-collapse: collapse;
|
| 259 |
+
width: 100%;
|
| 260 |
+
margin: 16px 0;
|
| 261 |
+
font-size: 14px;
|
| 262 |
+
background: white;
|
| 263 |
+
}
|
| 264 |
+
th, td {
|
| 265 |
+
padding: 10px 12px;
|
| 266 |
+
text-align: left;
|
| 267 |
+
vertical-align: top;
|
| 268 |
+
border: 1px solid var(--grid);
|
| 269 |
+
}
|
| 270 |
+
th {
|
| 271 |
+
background: #f9fafb;
|
| 272 |
+
font-weight: 600;
|
| 273 |
+
color: var(--ink-soft);
|
| 274 |
+
font-size: 13px;
|
| 275 |
+
}
|
| 276 |
+
tbody tr:nth-child(even) { background: #fafbfc; }
|
| 277 |
+
tbody tr:hover { background: var(--brand-soft); }
|
| 278 |
+
|
| 279 |
+
/* Numbered "step" boxes */
|
| 280 |
+
.step-box {
|
| 281 |
+
background: white;
|
| 282 |
+
border: 1px solid var(--grid);
|
| 283 |
+
border-left: 4px solid var(--brand);
|
| 284 |
+
border-radius: 6px;
|
| 285 |
+
padding: 16px 20px;
|
| 286 |
+
margin: 16px 0;
|
| 287 |
+
}
|
| 288 |
+
.step-box .step-label {
|
| 289 |
+
font-size: 11px;
|
| 290 |
+
text-transform: uppercase;
|
| 291 |
+
letter-spacing: 1px;
|
| 292 |
+
color: var(--brand);
|
| 293 |
+
font-weight: 700;
|
| 294 |
+
margin-bottom: 4px;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
/* Compare table — two scenarios side-by-side */
|
| 298 |
+
table.compare td.bad {
|
| 299 |
+
background: #fef2f2;
|
| 300 |
+
border-left: 3px solid var(--accent);
|
| 301 |
+
}
|
| 302 |
+
table.compare td.good {
|
| 303 |
+
background: #f0fdf4;
|
| 304 |
+
border-left: 3px solid var(--ok);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/* Toolbar */
|
| 308 |
+
.toolbar {
|
| 309 |
+
position: sticky; top: 0; z-index: 100;
|
| 310 |
+
background: var(--brand); color: white;
|
| 311 |
+
padding: 10px 24px;
|
| 312 |
+
display: flex; justify-content: space-between;
|
| 313 |
+
align-items: center;
|
| 314 |
+
font-size: 14px;
|
| 315 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 316 |
+
}
|
| 317 |
+
.toolbar button {
|
| 318 |
+
background: white; color: var(--brand); border: 0;
|
| 319 |
+
padding: 6px 16px; border-radius: 4px; font-weight: 600;
|
| 320 |
+
cursor: pointer; font-size: 13px;
|
| 321 |
+
}
|
| 322 |
+
.toolbar button:hover { background: #f3f4f6; }
|
| 323 |
+
|
| 324 |
+
/* Mantra at the end — final 5-sentence summary */
|
| 325 |
+
.mantra {
|
| 326 |
+
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
| 327 |
+
border-radius: 12px;
|
| 328 |
+
padding: 24px 28px;
|
| 329 |
+
margin: 24px 0;
|
| 330 |
+
}
|
| 331 |
+
.mantra ol {
|
| 332 |
+
counter-reset: mantra;
|
| 333 |
+
list-style: none;
|
| 334 |
+
padding: 0;
|
| 335 |
+
}
|
| 336 |
+
.mantra ol li {
|
| 337 |
+
counter-increment: mantra;
|
| 338 |
+
position: relative;
|
| 339 |
+
padding: 12px 12px 12px 56px;
|
| 340 |
+
margin: 8px 0;
|
| 341 |
+
background: white;
|
| 342 |
+
border-radius: 8px;
|
| 343 |
+
font-size: 15px;
|
| 344 |
+
line-height: 1.6;
|
| 345 |
+
}
|
| 346 |
+
.mantra ol li::before {
|
| 347 |
+
content: counter(mantra);
|
| 348 |
+
position: absolute;
|
| 349 |
+
left: 12px; top: 50%;
|
| 350 |
+
transform: translateY(-50%);
|
| 351 |
+
width: 32px; height: 32px;
|
| 352 |
+
background: var(--accent);
|
| 353 |
+
color: white;
|
| 354 |
+
border-radius: 50%;
|
| 355 |
+
display: flex; align-items: center; justify-content: center;
|
| 356 |
+
font-weight: 700;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
/* Footer */
|
| 360 |
+
footer {
|
| 361 |
+
margin-top: 80px;
|
| 362 |
+
padding-top: 24px;
|
| 363 |
+
border-top: 1px solid var(--grid);
|
| 364 |
+
color: var(--muted);
|
| 365 |
+
font-size: 13px;
|
| 366 |
+
text-align: center;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
/* Responsive — hide TOC on small screens */
|
| 370 |
+
@media (max-width: 900px) {
|
| 371 |
+
.layout { grid-template-columns: 1fr; }
|
| 372 |
+
aside.toc { display: none; }
|
| 373 |
+
main { padding: 24px 24px 60px 24px; }
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
/* Print refinements */
|
| 377 |
+
@media print {
|
| 378 |
+
body { background: white; }
|
| 379 |
+
.layout { grid-template-columns: 1fr; }
|
| 380 |
+
aside.toc, .toolbar { display: none; }
|
| 381 |
+
main { padding: 0; box-shadow: none; }
|
| 382 |
+
h2 { page-break-after: avoid; }
|
| 383 |
+
pre, blockquote, .analogy, .callout { page-break-inside: avoid; }
|
| 384 |
+
}
|
| 385 |
+
</style>
|
| 386 |
+
</head>
|
| 387 |
+
<body>
|
| 388 |
+
|
| 389 |
+
<div class="toolbar">
|
| 390 |
+
<strong>项目大白话讲解 · MicroClimate-X</strong>
|
| 391 |
+
<button onclick="window.print()">🖨 打印 / 存为 PDF</button>
|
| 392 |
+
</div>
|
| 393 |
+
|
| 394 |
+
<div class="layout">
|
| 395 |
+
|
| 396 |
+
<aside class="toc">
|
| 397 |
+
<h3>目录</h3>
|
| 398 |
+
<ol>
|
| 399 |
+
<li><a href="#s0"><span class="num">0</span>一句话讲清楚</a></li>
|
| 400 |
+
<li><a href="#s1"><span class="num">1</span>为什么有意义</a></li>
|
| 401 |
+
<li><a href="#s2"><span class="num">2</span>三块拼图</a></li>
|
| 402 |
+
<li><a href="#s3"><span class="num">3</span>Dataset 数��集</a></li>
|
| 403 |
+
<li><a href="#s4"><span class="num">4</span>Model 模型</a></li>
|
| 404 |
+
<li><a href="#s5"><span class="num">5</span>App 应用</a></li>
|
| 405 |
+
<li><a href="#s6"><span class="num">6</span>做对了什么</a></li>
|
| 406 |
+
<li><a href="#s7"><span class="num">7</span>回应老师 3 大疑惑</a></li>
|
| 407 |
+
<li><a href="#s8"><span class="num">8</span>汇报 4 步动作</a></li>
|
| 408 |
+
<li><a href="#s9"><span class="num">9</span>傻问题速查</a></li>
|
| 409 |
+
<li><a href="#s10"><span class="num">10</span>终极心法 5 句话</a></li>
|
| 410 |
+
</ol>
|
| 411 |
+
</aside>
|
| 412 |
+
|
| 413 |
+
<main>
|
| 414 |
+
|
| 415 |
+
<h1>你的毕业设计 —— 大白话讲解版</h1>
|
| 416 |
+
<p class="subtitle">看完这篇文章,你就能用自己的话把项目讲给完全不懂技术的朋友听。</p>
|
| 417 |
+
<p class="subtitle">不需要背任何术语,理解了原理,老师怎么追问你都不慌。</p>
|
| 418 |
+
|
| 419 |
+
<div class="meta">
|
| 420 |
+
<span>📅 2026-05-13</span>
|
| 421 |
+
<span>🎓 UKM FYP</span>
|
| 422 |
+
<span>📖 阅读时间约 30 分钟</span>
|
| 423 |
+
<span>🎯 目标:彻底搞懂项目</span>
|
| 424 |
+
</div>
|
| 425 |
+
|
| 426 |
+
<!-- ===== 0 ===== -->
|
| 427 |
+
<h2 id="s0"><span class="num">0.</span>先用一句话讲清楚你做了什么</h2>
|
| 428 |
+
|
| 429 |
+
<blockquote>
|
| 430 |
+
<p><strong>你做了一个"山区天气危险预警 App" —— 专门给登山者用的,比手机上的天气预报靠谱得多。</strong></p>
|
| 431 |
+
</blockquote>
|
| 432 |
+
|
| 433 |
+
<p>就这么简单。下面解释为什么"比手机预报靠谱"。</p>
|
| 434 |
+
|
| 435 |
+
<!-- ===== 1 ===== -->
|
| 436 |
+
<h2 id="s1"><span class="num">1.</span>为什么这个项目有意义?(痛点)</h2>
|
| 437 |
+
|
| 438 |
+
<p>打开你手机上的天气 App,它告诉你"吉隆坡今天有雨"。</p>
|
| 439 |
+
|
| 440 |
+
<p><strong>问题来了</strong>:吉隆坡有几千平方公里,里面有平地、有山、有山谷、有山顶。</p>
|
| 441 |
+
|
| 442 |
+
<p>天气预报背后的网格大概是 <strong>20 公里 × 20 公里</strong> 一格 —— 也就是说,<strong>山顶、山谷、迎风坡共用同一个预报</strong>。但实际上:</p>
|
| 443 |
+
|
| 444 |
+
<ul>
|
| 445 |
+
<li><strong>山顶</strong>可能在下暴雨</li>
|
| 446 |
+
<li><strong>山谷</strong>可能阳光明媚</li>
|
| 447 |
+
<li><strong>迎风坡</strong>可能起大雾</li>
|
| 448 |
+
</ul>
|
| 449 |
+
|
| 450 |
+
<blockquote>
|
| 451 |
+
<p>这就像有人告诉你"中国今天天气晴" —— 这种粗粒度的信息对登山者<strong>毫无用处</strong>。</p>
|
| 452 |
+
</blockquote>
|
| 453 |
+
|
| 454 |
+
<p><strong>你做的事</strong>:把预报精度从 20 公里降到一个具体坐标点,让登山者点地图上任意一个点,就能知道那个具体位置的风险。</p>
|
| 455 |
+
|
| 456 |
+
<!-- ===== 2 ===== -->
|
| 457 |
+
<h2 id="s2"><span class="num">2.</span>整个项目分三块:Dataset → Model → App</h2>
|
| 458 |
+
|
| 459 |
+
<p>这是导师上次反复强调的顺序 —— <mark>先有数据,再训模型,最后才做 App</mark>。</p>
|
| 460 |
+
|
| 461 |
+
<div class="analogy">
|
| 462 |
+
<p>你要教一个小孩看天气判断会不会下雨:</p>
|
| 463 |
+
<table>
|
| 464 |
+
<tr><th>步骤</th><th>比喻</th><th>项目里对应什么</th></tr>
|
| 465 |
+
<tr><td>1</td><td>找一本天气百科全书给小孩看</td><td><strong>Dataset</strong>(数据集)</td></tr>
|
| 466 |
+
<tr><td>2</td><td>让小孩反复看书,学会判断规律</td><td><strong>Model</strong>(训练模型)</td></tr>
|
| 467 |
+
<tr><td>3</td><td>把小孩装到一个"天气问答机器人"里,让别人能问他</td><td><strong>App</strong>(应用)</td></tr>
|
| 468 |
+
</table>
|
| 469 |
+
</div>
|
| 470 |
+
|
| 471 |
+
<!-- ===== 3 ===== -->
|
| 472 |
+
<h2 id="s3"><span class="num">3.</span>Dataset(数据集)—— 你拿什么"教"电脑</h2>
|
| 473 |
+
|
| 474 |
+
<h3>3.1 这一步在干什么</h3>
|
| 475 |
+
<p>机器学习的本质是 <strong>"喂样本,让它自己找规律"</strong>。所以第一件事:<strong>准备样本</strong>。</p>
|
| 476 |
+
|
| 477 |
+
<h3>3.2 你的数据从哪来?</h3>
|
| 478 |
+
<p><strong>来源</strong>:欧洲气象中心(ECMWF)的 <strong>ERA5 数据库</strong>。</p>
|
| 479 |
+
|
| 480 |
+
<p>这个数据库<strong>不是预报</strong>,是<strong>对过去天气的"完美回放"</strong> —— 气象学家会把所有历史观测数据(卫星、气球、地面站)综合起来,反推出"那一天那一刻那个地方真实的天气是怎样的"。</p>
|
| 481 |
+
|
| 482 |
+
<div class="analogy">
|
| 483 |
+
警察破案时调看监控录像 —— 录像就是 ERA5,是<strong>已经发生的事实</strong>。<br>
|
| 484 |
+
而手机预报是"预测未来",那是另一回事。
|
| 485 |
+
</div>
|
| 486 |
+
|
| 487 |
+
<p><strong>学术界把 ERA5 当作真值</strong> —— 其他预报系统都拿 ERA5 来校准自己。所以拿它训练 ML 模型最合适,因为<mark>答案是可信的</mark>。</p>
|
| 488 |
+
|
| 489 |
+
<h3>3.3 你要了多少数据?</h3>
|
| 490 |
+
<table>
|
| 491 |
+
<tr><th>项</th><th>数值</th></tr>
|
| 492 |
+
<tr><td>地点</td><td>5 个马来西亚山区(云顶、金马仑、福隆港、巴生谷、神山)</td></tr>
|
| 493 |
+
<tr><td>时间</td><td>2020-01-01 到 2024-12-31,整整 5 年</td></tr>
|
| 494 |
+
<tr><td>频率</td><td>每小时一行</td></tr>
|
| 495 |
+
<tr><td><strong>总行数</strong></td><td><strong>175 315 行</strong>(17.5 万行)</td></tr>
|
| 496 |
+
<tr><td>每行包含</td><td>温度、湿度、风速、风向、气压、降雨量等十几个数字</td></tr>
|
| 497 |
+
</table>
|
| 498 |
+
|
| 499 |
+
<h3>3.4 ⭐ 关键问题:原始数据里<u>没有"答案"</u></h3>
|
| 500 |
+
|
| 501 |
+
<div class="callout warn">
|
| 502 |
+
<div class="callout-title">这是上��老师最大的疑惑</div>
|
| 503 |
+
原始数据只有"现象",没有"标签"。
|
| 504 |
+
</div>
|
| 505 |
+
|
| 506 |
+
<p>原始数据长这样(简化):</p>
|
| 507 |
+
|
| 508 |
+
<pre><code>时间 温度 湿度 降雨量
|
| 509 |
+
2023-06-01 14:00 25°C 80% 0.0 mm
|
| 510 |
+
2023-06-01 15:00 24°C 85% 0.3 mm
|
| 511 |
+
2023-06-01 16:00 23°C 90% 1.2 mm</code></pre>
|
| 512 |
+
|
| 513 |
+
<p>老师看了说:<em>"这里面没有"答案"啊!没有一列告诉电脑'这是会下雨的情况'还是'这是不会下雨的情况' —— 你怎么训练模型?"</em></p>
|
| 514 |
+
|
| 515 |
+
<p><strong>他说的完全正确</strong>。原始数据只有"现象",没有"标签"。</p>
|
| 516 |
+
|
| 517 |
+
<h3>3.5 你的解决方案:自己<u>造</u>一个答案列</h3>
|
| 518 |
+
|
| 519 |
+
<p>你想了一个聪明的办法 —— <strong>用未来发生的事,反过来当现在的答案</strong>:</p>
|
| 520 |
+
|
| 521 |
+
<pre><code>df['is_rain_event'] = (df['precipitation'].shift(-1) > 0.1).astype(int)</code></pre>
|
| 522 |
+
|
| 523 |
+
<p>翻译成大白话:</p>
|
| 524 |
+
|
| 525 |
+
<blockquote>
|
| 526 |
+
<p>看每一行的"下一个小时" —— 如果下一小时下雨超过 0.1 毫米,就在这一行标记 <strong>1</strong>(会下雨);否则标记 <strong>0</strong>(不会下雨)。</p>
|
| 527 |
+
</blockquote>
|
| 528 |
+
|
| 529 |
+
<table>
|
| 530 |
+
<tr><th>时间</th><th>温度</th><th>湿度</th><th>降雨量</th><th>答案 is_rain_event</th></tr>
|
| 531 |
+
<tr><td>2023-06-01 14:00</td><td>25°C</td><td>80%</td><td>0.0</td><td><strong>1</strong>(下一小时 0.3 > 0.1)</td></tr>
|
| 532 |
+
<tr><td>2023-06-01 15:00</td><td>24°C</td><td>85%</td><td>0.3</td><td><strong>1</strong>(下一小时 1.2 > 0.1)</td></tr>
|
| 533 |
+
<tr><td>2023-06-01 16:00</td><td>23°C</td><td>90%</td><td>1.2</td><td><strong>1</strong></td></tr>
|
| 534 |
+
</table>
|
| 535 |
+
|
| 536 |
+
<div class="callout tip">
|
| 537 |
+
<div class="callout-title">核心思想</div>
|
| 538 |
+
训练时电脑看到 14:00 那一行的所有特征(温度湿度等),它要预测的"答案"是<strong>那一刻之后会不会下雨</strong>。<br>
|
| 539 |
+
这样训练完,未来给它一组当前的天气数据,它就能预测"接下来会不会下雨"。
|
| 540 |
+
</div>
|
| 541 |
+
|
| 542 |
+
<h3>3.6 这一步有 3 个<u>必须能讲出来</u>的细节</h3>
|
| 543 |
+
|
| 544 |
+
<div class="step-box">
|
| 545 |
+
<div class="step-label">细节 ①</div>
|
| 546 |
+
<h4 style="margin-top:0"><code>.shift(-1)</code> 的意义 —— 没有"作弊"</h4>
|
| 547 |
+
<p><code>.shift(-1)</code> 意思是"用未来的数据当答案"。这听起来像作弊,但其实<strong>完全正确</strong>:</p>
|
| 548 |
+
<ul>
|
| 549 |
+
<li>训练时:电脑只看"当前"特征,预测"未来"答案</li>
|
| 550 |
+
<li>预测时(真实使用):用户给当前特征,模型预测未来 —— <strong>用法一致</strong></li>
|
| 551 |
+
<li>如果用"当前"特征预测"当前"是否下雨 —— 那不是预测,是<strong>作弊</strong></li>
|
| 552 |
+
</ul>
|
| 553 |
+
<p>专业术语叫 <strong>"无时间泄漏"</strong>(no temporal leakage)。</p>
|
| 554 |
+
</div>
|
| 555 |
+
|
| 556 |
+
<div class="step-box">
|
| 557 |
+
<div class="step-label">细节 ②</div>
|
| 558 |
+
<h4 style="margin-top:0">为什么是 0.1 毫米?—— 不是拍脑袋</h4>
|
| 559 |
+
<p>0.1 mm 是 <strong>WMO(世界气象组织)官方定义的"微量降水"阈值</strong> —— 下到 0.1 毫米才算真的下雨,更少的算"湿润空气"。</p>
|
| 560 |
+
<p>这是<strong>国际标准</strong>,不是你随便定的。老师追问时这一点很重要 —— 证明你<strong>读过文献</strong>。</p>
|
| 561 |
+
</div>
|
| 562 |
+
|
| 563 |
+
<div class="step-box">
|
| 564 |
+
<div class="step-label">细节 ③</div>
|
| 565 |
+
<h4 style="margin-top:0">为什么是 0/1 二分类,不是预测毫米数?</h4>
|
| 566 |
+
<p>这是上次老师的<strong>第二大疑惑</strong> —— 他认为应该做"回归"(预测降雨多少毫米)。</p>
|
| 567 |
+
<p>你的答案 3 条:</p>
|
| 568 |
+
<ol>
|
| 569 |
+
<li><strong>下游决策本身就是二选一</strong>:登山者只关心"今天能不能去爬山" —— 出门或不出门,<strong>就是 0/1</strong></li>
|
| 570 |
+
<li><strong>回归还是要选阈值</strong>:哪怕你预测出 5.2 mm,最后还是要拿一个阈值(比如 1 mm)转成"出门 / 不出门" —— <strong>那个阈值反正要选</strong>,不如一开始就用分类</li>
|
| 571 |
+
<li><strong>退路</strong>:API 仍然返回<strong>概率值</strong>(比如"70% 会下雨"),需要连续值的下游组件照样能用 —— <strong>两全其美</strong></li>
|
| 572 |
+
</ol>
|
| 573 |
+
</div>
|
| 574 |
+
|
| 575 |
+
<!-- ===== 4 ===== -->
|
| 576 |
+
<h2 id="s4"><span class="num">4.</span>Model(模型)—— 让电脑学会判断</h2>
|
| 577 |
+
|
| 578 |
+
<h3>4.1 这一步在干什么</h3>
|
| 579 |
+
<p>把 17.5 万行数据<strong>喂</strong>给一个算法,让它<strong>自己找规律</strong>。</p>
|
| 580 |
+
<p>学完之后,未来给它任何一组新天气数据,它能立刻输出"会下雨的概率是 X%"。</p>
|
| 581 |
+
|
| 582 |
+
<h3>4.2 你用了什么算法?</h3>
|
| 583 |
+
<p><strong>Random Forest(随机森林)</strong> —— 一种经典的传统机器学习算法。</p>
|
| 584 |
+
|
| 585 |
+
<div class="analogy">
|
| 586 |
+
<p><strong>随机森林 = 一群医生会诊</strong></p>
|
| 587 |
+
<p>想象你身体不舒服,你不只看一个医生,而是<strong>找 100 个医生分别诊断</strong> ——</p>
|
| 588 |
+
<ul>
|
| 589 |
+
<li>每个医生看的"病例资料"略有不同(随机抽一部分)</li>
|
| 590 |
+
<li>每个医生��关注几个症状(不是全部)</li>
|
| 591 |
+
<li>最后 <strong>100 个医生投票</strong>,多数说"感冒"那就是感冒</li>
|
| 592 |
+
</ul>
|
| 593 |
+
<p>随机森林就是这个原理 —— 它训练 100 棵小决策树,每棵树看一部分数据、问一部分特征,最后投票决定结果。</p>
|
| 594 |
+
</div>
|
| 595 |
+
|
| 596 |
+
<h3>4.3 为什么选随机森林,不用 ChatGPT 那种深度学习?</h3>
|
| 597 |
+
<p>老师<strong>很可能问这个</strong>。3 条理由:</p>
|
| 598 |
+
|
| 599 |
+
<div class="step-box">
|
| 600 |
+
<div class="step-label">理由 ①</div>
|
| 601 |
+
<h4 style="margin-top:0">可解释性</h4>
|
| 602 |
+
<p>随机森林能告诉你"<strong>它为什么这么判断</strong>" —— 比如"我看到上一小时下了 0.3 毫米雨,所以判断接下来还会下"。</p>
|
| 603 |
+
<p>深度学习是<strong>黑盒</strong> —— 它说会下雨,<strong>没人知道它为什么这么说</strong>。</p>
|
| 604 |
+
<p>在<strong>安全关键</strong>应用里(关系到登山者性命),可解释性必须有。</p>
|
| 605 |
+
</div>
|
| 606 |
+
|
| 607 |
+
<div class="step-box">
|
| 608 |
+
<div class="step-label">理由 ②</div>
|
| 609 |
+
<h4 style="margin-top:0">数据量</h4>
|
| 610 |
+
<p>深度学习需要<strong>几百万到几千万</strong>条数据才能发挥优势。你只有 17.5 万行 —— <strong>太少了</strong>,深度学习反而不如随机森林。</p>
|
| 611 |
+
</div>
|
| 612 |
+
|
| 613 |
+
<div class="step-box">
|
| 614 |
+
<div class="step-label">理由 ③</div>
|
| 615 |
+
<h4 style="margin-top:0">速度</h4>
|
| 616 |
+
<p>随机森林预测一次 <strong>< 1 毫秒</strong>。深度学习要 10 倍以上时间,还要 GPU。</p>
|
| 617 |
+
<p>你的 App 要做到"用户点地图立刻出结果" —— 快是必须的。</p>
|
| 618 |
+
</div>
|
| 619 |
+
|
| 620 |
+
<h3>4.4 模型训练得怎么样?(这是老师最关心的数字)</h3>
|
| 621 |
+
|
| 622 |
+
<h4>你必须能背的 4 个数字</h4>
|
| 623 |
+
<table>
|
| 624 |
+
<tr><th>指标</th><th>数值</th><th>大白话翻译</th></tr>
|
| 625 |
+
<tr><td><strong>ROC AUC</strong></td><td><strong>0.871</strong></td><td>整体准确度的综合分,<strong>满分 1.0</strong> —— 0.87 算"很好"</td></tr>
|
| 626 |
+
<tr><td><strong>Recall(召回率)</strong></td><td><strong>93.4%</strong></td><td>真的下了雨的 100 次里,模型成功预警了 93 次(<strong>只漏报 7 次</strong>)</td></tr>
|
| 627 |
+
<tr><td><strong>Brier Score</strong></td><td><strong>0.138</strong></td><td>概率校准度 —— <strong>越低越好</strong>,0.14 算"校准良好"</td></tr>
|
| 628 |
+
<tr><td><strong>测试样本数</strong></td><td><strong>35 063 条</strong></td><td>用来打分的数据量(占总数据的 20%)</td></tr>
|
| 629 |
+
</table>
|
| 630 |
+
|
| 631 |
+
<h4>怎么"考试"?—— 时间序列切分</h4>
|
| 632 |
+
<p>你<strong>没有</strong>用随机切分(很多人会犯的错)。</p>
|
| 633 |
+
|
| 634 |
+
<div class="analogy">
|
| 635 |
+
老师出期末考试,如果他用学生<strong>平时做过的题</strong>来考 —— 那是作弊。必须用<strong>新的</strong>、学生没见过的题。
|
| 636 |
+
</div>
|
| 637 |
+
|
| 638 |
+
<p>你的做法:把 5 年数据按时间顺序排,<strong>最后 1 年(20%)扣下来不给电脑看</strong>,只用前 4 年训练。训练完,拿最后 1 年的数据"考试" —— 这才公平。</p>
|
| 639 |
+
|
| 640 |
+
<h4>为什么不用准确率(accuracy)?</h4>
|
| 641 |
+
<p>很多人会问"模型准确率多少"。<strong>陷阱</strong>:</p>
|
| 642 |
+
<ul>
|
| 643 |
+
<li>马来西亚山区只有 30% 的时间下雨</li>
|
| 644 |
+
<li>一个<strong>永远预测"不下雨"的傻瓜模型</strong>,准确率自动是 70%</li>
|
| 645 |
+
<li>但它<strong>永远漏报</strong>,登山者会被淋成落汤鸡</li>
|
| 646 |
+
</ul>
|
| 647 |
+
|
| 648 |
+
<p>所以你用 <strong>F2 分数</strong> + <strong>召回率</strong> 而不是准确率 —— 这两个指标<strong>重视"不漏报"</strong>。</p>
|
| 649 |
+
|
| 650 |
+
<blockquote>
|
| 651 |
+
老师如果追问,就说:"<strong>安全关键应用,漏报比误报严重得多。漏一次真下雨,登山者可能丧命;误报一次,登山者最多取消行程。所以我用 F2 分数,它把召回率的权重设为精度的 4 倍。</strong>"
|
| 652 |
+
</blockquote>
|
| 653 |
+
|
| 654 |
+
<h3>4.5 模型学到了什么?(特征重要性)</h3>
|
| 655 |
+
<p>电脑学完之后,能告诉你"它最看重哪些信号"。前 4 个是:</p>
|
| 656 |
+
|
| 657 |
+
<table>
|
| 658 |
+
<tr><th>排名</th><th>特征</th><th>大白话</th></tr>
|
| 659 |
+
<tr><td>1</td><td>上一小时降雨量</td><td>"刚才下了,接下来很可能继续下" —— <strong>雨的惯性</strong></td></tr>
|
| 660 |
+
<tr><td>2-3</td><td>时间(一天中的几点)</td><td>马来西亚山区<strong>下午 3-5 点最爱下雨</strong> —— 电脑学会了这个规律</td></tr>
|
| 661 |
+
<tr><td>4</td><td>3 小时气压变化</td><td><strong>气压下降预示风暴来临</strong> —— 这是经典气象学常识,电脑自己学到了</td></tr>
|
| 662 |
+
</table>
|
| 663 |
+
|
| 664 |
+
<div class="callout ok">
|
| 665 |
+
<div class="callout-title">这一段非常加分</div>
|
| 666 |
+
证明<strong>你的模型学到的是"物理上有意义"的规律</strong>,不是乱猜。
|
| 667 |
+
</div>
|
| 668 |
+
|
| 669 |
+
<!-- ===== 5 ===== -->
|
| 670 |
+
<h2 id="s5"><span class="num">5.</span>App(应用)—— 把模型包装成产品</h2>
|
| 671 |
+
|
| 672 |
+
<h3>5.1 这一步在干什么</h3>
|
| 673 |
+
<p>模型本身只是个 <code>.pkl</code> 文件 —— 一个 Python 对象。<strong>没法用</strong>。</p>
|
| 674 |
+
<p>App 的工作是:<strong>让��户能用浏览器点地图,立刻看到那个地方的风险评分</strong>。</p>
|
| 675 |
+
|
| 676 |
+
<h3>5.2 整个 App 长什么样?</h3>
|
| 677 |
+
|
| 678 |
+
<pre><code>用户在浏览器点地图上一个点 (lat, lon)
|
| 679 |
+
│
|
| 680 |
+
▼
|
| 681 |
+
前端(Vue + Leaflet 地图)
|
| 682 |
+
│
|
| 683 |
+
│ HTTP 请求
|
| 684 |
+
▼
|
| 685 |
+
后端(FastAPI)
|
| 686 |
+
│
|
| 687 |
+
├──► 调用 Open-Meteo API:获取这个点当前的实时天气
|
| 688 |
+
├──► 调用 Open Topo Data:获取这个点的海拔高度
|
| 689 |
+
│
|
| 690 |
+
├──► 引擎 A:把天气数据喂给训练好的 RF 模型 → 得到下雨概率
|
| 691 |
+
├──► 引擎 B:用一组手写规则评估其他危险(雾、风、雷暴)
|
| 692 |
+
│
|
| 693 |
+
▼
|
| 694 |
+
综合评分(0-100)+ 双语建议返回前端
|
| 695 |
+
│
|
| 696 |
+
▼
|
| 697 |
+
前端显示:彩色仪表盘 + "建议:可以安全出行" / "警告:暴雨风险"</code></pre>
|
| 698 |
+
|
| 699 |
+
<h3>5.3 ⭐ 双引擎架构 —— 这是项目的<u>核心创新</u></h3>
|
| 700 |
+
|
| 701 |
+
<div class="callout tip">
|
| 702 |
+
<div class="callout-title">这是整个项目最值得吹的地方</div>
|
| 703 |
+
老师追问"为什么混合架构"时你要能讲清楚。
|
| 704 |
+
</div>
|
| 705 |
+
|
| 706 |
+
<h4>引擎 A:随机森林 = "经验主义"</h4>
|
| 707 |
+
<p>学过 17.5 万条历史数据的"经验" —— <strong>它见过的天气模式</strong>它能很准地预测。</p>
|
| 708 |
+
|
| 709 |
+
<h4>引擎 B:手写规则 = "原则主义"</h4>
|
| 710 |
+
<p>一组<strong>人工写的物理规则</strong>:</p>
|
| 711 |
+
|
| 712 |
+
<pre><code>如果 海拔 > 3500 米 → 缺氧危险(不管模型说什么,强制报警)
|
| 713 |
+
如果 温度 ≤ -5°C → 冻伤危险
|
| 714 |
+
如果 风速 ≥ 40 km/h → 大风危险
|
| 715 |
+
如果 风向 + 地形 = 迎风坡 → 地形抬升造雨风险</code></pre>
|
| 716 |
+
|
| 717 |
+
<h4>为什么必须两个一起用?—— 珠峰例子</h4>
|
| 718 |
+
<p><strong>情景</strong>:用户点了一下珠穆朗玛峰(8 848 米)。</p>
|
| 719 |
+
|
| 720 |
+
<table class="compare">
|
| 721 |
+
<tr><th>只用引擎 A(纯 ML)</th><th>你的混合架构</th></tr>
|
| 722 |
+
<tr>
|
| 723 |
+
<td class="bad">模型只见过马来西亚的山(最高 1865 米)</td>
|
| 724 |
+
<td class="good">模型仍然给出低概率("看起来不像会下雨")</td>
|
| 725 |
+
</tr>
|
| 726 |
+
<tr>
|
| 727 |
+
<td class="bad">它说"下雨概率 5%"</td>
|
| 728 |
+
<td class="good"><strong>但引擎 B 立刻发现</strong>:海拔 > 3500 → 缺氧;温度 -30°C → 冻伤;风速 80 km/h → 大风</td>
|
| 729 |
+
</tr>
|
| 730 |
+
<tr>
|
| 731 |
+
<td class="bad">系统返回"安全" ❌</td>
|
| 732 |
+
<td class="good"><strong>三个独立否决(Veto)触发</strong></td>
|
| 733 |
+
</tr>
|
| 734 |
+
<tr>
|
| 735 |
+
<td class="bad">登山者去了 → 死</td>
|
| 736 |
+
<td class="good">综合评分被强制设为 100 = <strong>危险</strong> ✅</td>
|
| 737 |
+
</tr>
|
| 738 |
+
</table>
|
| 739 |
+
|
| 740 |
+
<blockquote>
|
| 741 |
+
<p><strong>学术上这叫 "Neuro-Symbolic AI(神经-符号 AI)"</strong> —— 能学的让机器学(神经),物理规律手工编码(符号)。</p>
|
| 742 |
+
<p>这不是你瞎编的,是 2020 年后学术界的热门方向,参考文献 Garcez & Lamb 2020。</p>
|
| 743 |
+
</blockquote>
|
| 744 |
+
|
| 745 |
+
<h3>5.4 App 的技术栈(被问到时回答)</h3>
|
| 746 |
+
|
| 747 |
+
<table>
|
| 748 |
+
<tr><th>部分</th><th>用了什么</th><th>一句话解释</th></tr>
|
| 749 |
+
<tr><td>前端</td><td>Vue 3 + Leaflet + ECharts</td><td>浏览器里的页面,地图,图表</td></tr>
|
| 750 |
+
<tr><td>后端</td><td>FastAPI(Python)</td><td>处理用户请求,调模型,返回结果</td></tr>
|
| 751 |
+
<tr><td>缓存</td><td>SQLite</td><td>同一个点 10 分钟内重复查不用每次都算</td></tr>
|
| 752 |
+
<tr><td>部署</td><td>Docker</td><td>一键就能在任何机器上跑起来</td></tr>
|
| 753 |
+
</table>
|
| 754 |
+
|
| 755 |
+
<!-- ===== 6 ===== -->
|
| 756 |
+
<h2 id="s6"><span class="num">6.</span>整个项目你"做对了什么" —— 别人吹起来的话</h2>
|
| 757 |
+
|
| 758 |
+
<table>
|
| 759 |
+
<tr><th>#</th><th>做对的事</th><th>为什么加分</th></tr>
|
| 760 |
+
<tr><td>1</td><td>数据来自 ERA5 而不是网上随便爬的</td><td><strong>业内金标准</strong>,老师不会质疑数据质量</td></tr>
|
| 761 |
+
<tr><td>2</td><td>答案列自己工程构造,方法符合 WMO 标准</td><td>证明<strong>读过文献</strong>,不是拍脑袋</td></tr>
|
| 762 |
+
<tr><td>3</td><td>时间序列切分而不是随机切分</td><td>证明<strong>懂 ML 基础</strong>,没有数据泄漏</td></tr>
|
| 763 |
+
<tr><td>4</td><td>用 F2 分数选阈值而不是 F1</td><td>证明<strong>理解任务背景</strong> —— 安全关键场景重视召回</td></tr>
|
| 764 |
+
<tr><td>5</td><td>混合架构(ML + 规则)而不是纯 ML</td><td>项目的<strong>核心研究贡献</strong></td></tr>
|
| 765 |
+
<tr><td>6</td><td>写了 70 个测试,覆盖率 97%</td><td>证明<strong>工程能力</strong> —— 老师不会怀疑代码可靠性</td></tr>
|
| 766 |
+
<tr><td>7</td><td>一行命令 <code>make evaluate</code> 复现所有数字</td><td>评审老师能<strong>独立验证</strong>论文里的每个 claim</td></tr>
|
| 767 |
+
</table>
|
| 768 |
+
|
| 769 |
+
<!-- ===== 7 ===== -->
|
| 770 |
+
<h2 id="s7"><span class="num">7.</span>上次汇报老师的 3 大疑惑 —— 逐条解决</h2>
|
| 771 |
+
|
| 772 |
+
<table>
|
| 773 |
+
<tr><th>#</th><th>老师疑惑</th><th>你现在的回答</th></tr>
|
| 774 |
+
<tr>
|
| 775 |
+
<td>1</td>
|
| 776 |
+
<td>"App is the last(顺序错了)"</td>
|
| 777 |
+
<td>严格按 dataset → model → app 顺序展示,写在 <code>pipeline_order.md</code> 里</td>
|
| 778 |
+
</tr>
|
| 779 |
+
<tr>
|
| 780 |
+
<td>2</td>
|
| 781 |
+
<td>"Y is missing(没有答案列)"</td>
|
| 782 |
+
<td>自己工程构造的 <code>is_rain_event</code>,方法符合 WMO 标准</td>
|
| 783 |
+
</tr>
|
| 784 |
+
<tr>
|
| 785 |
+
<td>3</td>
|
| 786 |
+
<td>"应该做回归不是分类"</td>
|
| 787 |
+
<td>下游决策是二元的、F2 分数只在分类下有意义、API 仍暴露概率</td>
|
| 788 |
+
</tr>
|
| 789 |
+
</table>
|
| 790 |
+
|
| 791 |
+
<!-- ===== 8 ===== -->
|
| 792 |
+
<h2 id="s8"><span class="num">8.</span>这次汇报你只需要做 4 件事</h2>
|
| 793 |
+
|
| 794 |
+
<h3>8.1 开场(30 秒)</h3>
|
| 795 |
+
<blockquote>
|
| 796 |
+
"老师感谢您抽时间。接着上次的内容,我做完了 v1.0.0 工程化强化,整条流水线现在可以端到端复现。我按上次的顺序 —— <strong>dataset、model、app</strong> —— 给您过一遍新的进展,最后讲我对 Chapter 5 的下一步计划,可以吗?"
|
| 797 |
+
</blockquote>
|
| 798 |
+
|
| 799 |
+
<h3>8.2 按顺序展示(5 分钟)</h3>
|
| 800 |
+
<table>
|
| 801 |
+
<tr><th>步骤</th><th>打开</th><th>讲什么</th><th>时长</th></tr>
|
| 802 |
+
<tr><td>1</td><td><code>docs/dataset.md</code></td><td>"数据来自 ERA5,5 个山点 5 年共 17.5 万行;答案列 <code>is_rain_event</code> 我自己构造的,定义在 §5"</td><td>30 秒</td></tr>
|
| 803 |
+
<tr><td>2</td><td><code>figures/01_roc_curve.png</code></td><td>"测试 AUC 0.871,召回率 93.4%"</td><td>30 秒</td></tr>
|
| 804 |
+
<tr><td>3</td><td><code>figures/03_calibration_curve.png</code></td><td>"Brier 分数 0.138,校准良好"</td><td>20 秒</td></tr>
|
| 805 |
+
<tr><td>4</td><td><code>figures/04_threshold_sweep.png</code></td><td>"用 F2 分数选阈值 —— 安全关键场景重视召回"</td><td>20 秒</td></tr>
|
| 806 |
+
<tr><td>5</td><td><code>figures/05_feature_importance.png</code></td><td>"前 3 个特征:上一小时降雨、时间周期、气压变化 —— 和气象学文献吻合"</td><td>20 秒</td></tr>
|
| 807 |
+
<tr><td>6</td><td>App(<strong>最后</strong>才打开)</td><td>演示云顶 + 珠峰两个场景</td><td>90 秒</td></tr>
|
| 808 |
+
</table>
|
| 809 |
+
|
| 810 |
+
<h3>8.3 讲下一步(90 秒)</h3>
|
| 811 |
+
<p>打开 <code>docs/progress_update_brief.html</code> §4,照着 5 条 Chapter 5 方向讲一遍,然后<strong>请老师拍板:先做哪两条</strong>?</p>
|
| 812 |
+
|
| 813 |
+
<h3>8.4 收尾(30 秒)</h3>
|
| 814 |
+
<blockquote>
|
| 815 |
+
"明早之前给您发 3 条要点的邮件总结,留个书面确认。谢谢老师。"
|
| 816 |
+
</blockquote>
|
| 817 |
+
|
| 818 |
+
<!-- ===== 9 ===== -->
|
| 819 |
+
<h2 id="s9"><span class="num">9.</span>老师可能问的"傻"问题(其实不傻)</h2>
|
| 820 |
+
|
| 821 |
+
<table>
|
| 822 |
+
<tr><th>问题</th><th>你的回答</th></tr>
|
| 823 |
+
<tr><td>"你的模型是什么?"</td><td>"Random Forest,随机森林"</td></tr>
|
| 824 |
+
<tr><td>"为什么不用深度学习?"</td><td>"数据量不够,需要可解释性,而且要快"</td></tr>
|
| 825 |
+
<tr><td>"测试准确率多少?"</td><td>"AUC 0.871,召回率 93.4% —— 我没用准确率,因为类别不平衡"</td></tr>
|
| 826 |
+
<tr><td>"怎么知道没过拟合?"</td><td>"做了 5 折时间序列交叉验证,每折 AUC 都在 0.83-0.91"</td></tr>
|
| 827 |
+
<tr><td>"Y 是怎么来的?"</td><td>"从 precipitation 列工程构造的:下一小时 > 0.1 mm 标 1,0.1 mm 是 WMO 标准"</td></tr>
|
| 828 |
+
<tr><td>"为什么是分类不是回归?"</td><td>"下游决策本身就是二元的,而且只有分类才能直接优化 F2 分数"</td></tr>
|
| 829 |
+
<tr><td>"规则引擎为什么必要?"</td><td>"纯 ML 学的是平均,遇到分布外输入(比如珠峰)会失效 —— 规则引擎兜底"</td></tr>
|
| 830 |
+
<tr><td>"万一哪天模型挂了?"</td><td>"三层降级:模型挂了走启发式 / 异常返回类型化错误 / 规则引擎独立运行"</td></tr>
|
| 831 |
+
</table>
|
| 832 |
+
|
| 833 |
+
<!-- ===== 10 ===== -->
|
| 834 |
+
<h2 id="s10"><span class="num">10.</span>终极心法 —— 你只要记住这 5 句话</h2>
|
| 835 |
+
|
| 836 |
+
<div class="mantra">
|
| 837 |
+
<ol>
|
| 838 |
+
<li>"我做的是给登山者用的<strong>山区天气危险预警 App</strong>。"<br><em style="color:var(--muted);font-size:13px">→ 一句话讲清项目</em></li>
|
| 839 |
+
<li>"我用 ERA5 历史天气数据,<strong>自己构造了答案列 <code>is_rain_event</code></strong>,训了一个随机森林模型。"<br><em style="color:var(--muted);font-size:13px">→ 数据 + 模型</em></li>
|
| 840 |
+
<li>"测试 <strong>AUC 0.871,召回率 93.4%</strong> —— 我重视召回因为漏报比误报危险。"<br><em style="color:var(--muted);font-size:13px">→ 核心数字</em></li>
|
| 841 |
+
<li>"我用<strong>混合架构 —— 机器学习 + 手写规则</strong>,规则引擎在分布外场景兜底。"<br><em style="color:var(--muted);font-size:13px">→ 核心创新</em></li>
|
| 842 |
+
<li>"接下来 Chapter 5 我列了 <strong>5 个评估方向</strong>,请老师建议先做哪两个。"<br><em style="color:var(--muted);font-size:13px">→ 请示</em></li>
|
| 843 |
+
</ol>
|
| 844 |
+
</div>
|
| 845 |
+
|
| 846 |
+
<div class="callout ok">
|
| 847 |
+
<div class="callout-title">关键心法</div>
|
| 848 |
+
<strong>这 5 句话能完整回答 90% 的追问。</strong>其余的细节,遇到了再翻这份���档。
|
| 849 |
+
</div>
|
| 850 |
+
|
| 851 |
+
<footer>
|
| 852 |
+
写这份文档的目的:让你<strong>真的懂</strong>自己的项目,而不是背稿子。<br>
|
| 853 |
+
懂了,老师怎么问你都不慌 —— 因为你说出来的每一句话都是<strong>你自己想清楚的</strong>。<br><br>
|
| 854 |
+
<span style="color: var(--brand);">L.ZH @ UKM · KyoukoLi/microclimate-x · 2026-05-13</span>
|
| 855 |
+
</footer>
|
| 856 |
+
|
| 857 |
+
</main>
|
| 858 |
+
|
| 859 |
+
</div>
|
| 860 |
+
|
| 861 |
+
<script>
|
| 862 |
+
// Highlight current TOC item on scroll
|
| 863 |
+
const tocLinks = document.querySelectorAll('aside.toc a');
|
| 864 |
+
const sections = Array.from(tocLinks).map(link => document.getElementById(link.getAttribute('href').slice(1)));
|
| 865 |
+
|
| 866 |
+
function highlightCurrent() {
|
| 867 |
+
let current = 0;
|
| 868 |
+
sections.forEach((sec, i) => {
|
| 869 |
+
if (sec && sec.getBoundingClientRect().top < 100) current = i;
|
| 870 |
+
});
|
| 871 |
+
tocLinks.forEach((link, i) => {
|
| 872 |
+
link.style.background = i === current ? 'var(--brand-soft)' : '';
|
| 873 |
+
link.style.color = i === current ? 'var(--brand)' : '';
|
| 874 |
+
link.style.borderLeftColor = i === current ? 'var(--brand)' : 'transparent';
|
| 875 |
+
link.style.fontWeight = i === current ? '600' : '400';
|
| 876 |
+
});
|
| 877 |
+
}
|
| 878 |
+
window.addEventListener('scroll', highlightCurrent);
|
| 879 |
+
highlightCurrent();
|
| 880 |
+
</script>
|
| 881 |
+
|
| 882 |
+
</body>
|
| 883 |
+
</html>
|
docs/项目大白话讲解.md
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 你的毕业设计——大白话讲解版
|
| 2 |
+
|
| 3 |
+
> 看完这篇文章,你就能用自己的话把项目讲给完全不懂技术的朋友听。
|
| 4 |
+
> 不需要背任何术语,理解了原理,老师怎么追问你都不慌。
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 0. 先用一句话讲清楚你做了什么
|
| 9 |
+
|
| 10 |
+
> **你做了一个"山区天气危险预警 App"——专门给登山者用的,比手机上的天气预报靠谱得多。**
|
| 11 |
+
|
| 12 |
+
就这么简单。下面解释为什么"比手机预报靠谱"。
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. 为什么这个项目有意义?(痛点)
|
| 17 |
+
|
| 18 |
+
打开你手机上的天气 App,它告诉你"吉隆坡今天有雨"。
|
| 19 |
+
|
| 20 |
+
**问题来了**:吉隆坡有几千平方公里,里面有平地、有山、有山谷、有山顶。
|
| 21 |
+
|
| 22 |
+
天气预报背后的网格大概是 **20 公里 × 20 公里** 一格——也就是说,**山顶、山谷、迎风坡共用同一个预报**。但实际上:
|
| 23 |
+
|
| 24 |
+
- **山顶**可能在下暴雨
|
| 25 |
+
- **山谷**可能阳光明媚
|
| 26 |
+
- **迎风坡**可能起大雾
|
| 27 |
+
|
| 28 |
+
> 这就像有人告诉你"中国今天天气晴"——这种粗粒度的信息对登山者**毫无用处**。
|
| 29 |
+
|
| 30 |
+
你做的事:**把预报精度从 20 公里降到一个具体坐标点**,让登山者点地图上任意一个点,就能知道那个具体位置的风险。
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## 2. 整个项目分三块:Dataset → Model → App
|
| 35 |
+
|
| 36 |
+
这是导师上次反复强调的顺序——**先有数据,再训模型,最后才做 App**。
|
| 37 |
+
|
| 38 |
+
### 想象一个比喻
|
| 39 |
+
|
| 40 |
+
你要教一个小孩看天气判断会不会下雨:
|
| 41 |
+
|
| 42 |
+
| 步骤 | 比喻 | 项目里对应什么 |
|
| 43 |
+
|---|---|---|
|
| 44 |
+
| 1 | 找一本天气百科全书给小孩看 | **Dataset**(数据集) |
|
| 45 |
+
| 2 | 让小孩反复看书,学会判断规律 | **Model**(训练模型) |
|
| 46 |
+
| 3 | 把小孩装到一个"天气问答机器人"里,让别人能问他 | **App**(应用) |
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## 3. Dataset(数据集)—— 你拿什么"教"电脑
|
| 51 |
+
|
| 52 |
+
### 3.1 这一步在干什么
|
| 53 |
+
|
| 54 |
+
机器学习的本质是 **"喂样本,让它自己找规律"**。所以第一件事:**准备样本**。
|
| 55 |
+
|
| 56 |
+
### 3.2 你的数据从哪来?
|
| 57 |
+
|
| 58 |
+
**来源**:欧洲气象中心(ECMWF)的 **ERA5 数据库**。
|
| 59 |
+
|
| 60 |
+
这个数据库**不是预报**,是**对过去天气的"完美回放"**——气象学家会把所有历史观测数据(卫星、气球、地面站)综合起来,反推出"那一天那一刻那个地方真实的天气是怎样的"。
|
| 61 |
+
|
| 62 |
+
> **类比**:警察破案时调看监控录像——录像就是 ERA5,是已经发生的事实。
|
| 63 |
+
> 而手机预报是"预测未来",那是另一回事。
|
| 64 |
+
|
| 65 |
+
**学术界把 ERA5 当作真值**——其他预报系统都拿 ERA5 来校准自己。所以拿它训练 ML 模型最合适,因为**答案是可信的**。
|
| 66 |
+
|
| 67 |
+
### 3.3 你要了多少数据?
|
| 68 |
+
|
| 69 |
+
| 项 | 数值 |
|
| 70 |
+
|---|---|
|
| 71 |
+
| 地点 | 5 个马来西亚山区(云顶、金马仑、福隆港、巴生谷、神山) |
|
| 72 |
+
| 时间 | 2020-01-01 到 2024-12-31,整整 5 年 |
|
| 73 |
+
| 频率 | 每小时一行 |
|
| 74 |
+
| **总行数** | **175 315 行**(17.5 万行) |
|
| 75 |
+
| 每行包含 | 温度、湿度、风速、风向、气压、降雨量等十几个数字 |
|
| 76 |
+
|
| 77 |
+
### 3.4 ⭐ 关键问题:原始数据里**没有"答案"**
|
| 78 |
+
|
| 79 |
+
这是上次老师**最大的疑惑**。
|
| 80 |
+
|
| 81 |
+
原始数据长这样(简化):
|
| 82 |
+
|
| 83 |
+
```
|
| 84 |
+
时间 温度 湿度 降雨量
|
| 85 |
+
2023-06-01 14:00 25°C 80% 0.0 mm
|
| 86 |
+
2023-06-01 15:00 24°C 85% 0.3 mm
|
| 87 |
+
2023-06-01 16:00 23°C 90% 1.2 mm
|
| 88 |
+
...
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
老师看了说:"**这里面没有"答案"啊!** 没有一列告诉电脑'这是会下雨的情况'还是'这是不会下雨的情况'——你怎么训练模型?"
|
| 92 |
+
|
| 93 |
+
**他说的完全正确**。原始数据只有"现象",没有"标签"。
|
| 94 |
+
|
| 95 |
+
### 3.5 你的解决方案:自己**造**一个答案列
|
| 96 |
+
|
| 97 |
+
你想了一个聪明的办法——**用未来发生的事,反过来当现在的答案**:
|
| 98 |
+
|
| 99 |
+
```python
|
| 100 |
+
df['is_rain_event'] = (df['precipitation'].shift(-1) > 0.1).astype(int)
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
翻译成大白话:**看每一行的"下一个小时"——如果下一小时下雨超过 0.1 毫米,就在这一行标记 1(会下雨);否则标记 0(不会下雨)。**
|
| 104 |
+
|
| 105 |
+
| 时间 | 温度 | 湿度 | 降雨量 | **答案 is_rain_event** |
|
| 106 |
+
|---|---|---|---|---|
|
| 107 |
+
| 2023-06-01 14:00 | 25°C | 80% | 0.0 | **0**(因为下一小时只有 0.3 mm < 0.1)—— 等等,0.3 > 0.1 应该是 **1** |
|
| 108 |
+
| 2023-06-01 15:00 | 24°C | 85% | 0.3 | **1**(下一小时 1.2 > 0.1) |
|
| 109 |
+
| 2023-06-01 16:00 | 23°C | 90% | 1.2 | **1** |
|
| 110 |
+
|
| 111 |
+
> **核心思想**:训练时电脑看到 14:00 那一行的所有特征(温度湿度等),它要预测的"答案"是**那一刻之后会不会下雨**。
|
| 112 |
+
> 这样训练完,未来给它一组当前的天气数据,它就能预测"接下来会不会下雨"。
|
| 113 |
+
|
| 114 |
+
### 3.6 这一步有 3 个**必须能讲出来**的细节
|
| 115 |
+
|
| 116 |
+
老师如果追问,你要能讲清楚下面这 3 点:
|
| 117 |
+
|
| 118 |
+
#### 细节 ① `.shift(-1)` 的意义 —— 没有"作弊"
|
| 119 |
+
|
| 120 |
+
`.shift(-1)` 意思是"用未来的数据当答案"。这听起来像作弊,但其实**完全正确**:
|
| 121 |
+
|
| 122 |
+
- 训练时:电脑只看"当前"特征,预测"未来"答案
|
| 123 |
+
- 预测时(真实使用):用户给当前特征,模型预测未来——**用法一致**
|
| 124 |
+
- 如果用"当前"特征预测"当前"是否下雨——那不是预测,是**作弊**
|
| 125 |
+
|
| 126 |
+
**专业术语叫"无时间泄漏"**(no temporal leakage)。
|
| 127 |
+
|
| 128 |
+
#### 细节 ② 为什么是 0.1 毫米?—— 不是拍脑袋
|
| 129 |
+
|
| 130 |
+
0.1 mm 是 **WMO(世界气象组织)官方定义的"微量降水"阈值**——下到 0.1 毫米才算真的下雨,更少的算"湿润空气"。
|
| 131 |
+
|
| 132 |
+
这是**国际标准**,不是你随便定的。老师追问时这一点很重要——证明你**读了文献**。
|
| 133 |
+
|
| 134 |
+
#### 细节 ③ 为什么是 0/1 二分类,不是预测毫米数?
|
| 135 |
+
|
| 136 |
+
这是上次老师的**第二大疑惑**——他认为应该做"回归"(预测降雨多少毫米)。
|
| 137 |
+
|
| 138 |
+
你的答案 3 条:
|
| 139 |
+
1. **下游决策本身就是二选一**:登山者只关心"今天能不能去爬山"——出门或不出门,**就是 0/1**
|
| 140 |
+
2. **回归还是要选阈值**:哪怕你预测出 5.2 mm,最后还是要拿一个阈值(比如 1 mm)转成"出门 / 不出门"——**那个阈值反正要选**,不如一开始就用分类
|
| 141 |
+
3. **退路**:API 仍然返回**概率值**(比如"70% 会下雨"),需要连续值的下游组件照样能用——**两全其美**
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## 4. Model(模型)—— 让电脑学会判断
|
| 146 |
+
|
| 147 |
+
### 4.1 这一步在干什么
|
| 148 |
+
|
| 149 |
+
把 17.5 万行数据**喂**给一个算法,让它**自己找规律**。
|
| 150 |
+
|
| 151 |
+
学完之后,未来给它任何一组新天气数据,它能立刻输出"会下雨的概率是 X%"。
|
| 152 |
+
|
| 153 |
+
### 4.2 你用了什么算法?
|
| 154 |
+
|
| 155 |
+
**Random Forest(随机森林)**——一种经典的传统机器学习算法。
|
| 156 |
+
|
| 157 |
+
#### 比喻:随机森林 = 一群医生会诊
|
| 158 |
+
|
| 159 |
+
想象你身体不舒服,你不只看一个医生,而是**找 100 个医生分别诊断**——
|
| 160 |
+
|
| 161 |
+
- 每个医生看的"病例资料"略有不同(随机抽一部分)
|
| 162 |
+
- 每个医生只关注几个症状(不是全部)
|
| 163 |
+
- 最后**100 个医生投票**,多数说"感冒"那就是感冒
|
| 164 |
+
|
| 165 |
+
随机森林就是这个原理——它训练 100 棵小决策树,每棵树看一部分数据、问一部分特征,最后投票决定结果。
|
| 166 |
+
|
| 167 |
+
### 4.3 为什么选随机森林,不用 ChatGPT 那种深度学习?
|
| 168 |
+
|
| 169 |
+
老师**很可能问这个**。3 条理由:
|
| 170 |
+
|
| 171 |
+
#### 理由 ① 可解释性
|
| 172 |
+
随机森林能告诉你"**它为什么这么判断**"——比如"我看到上一小时下了 0.3 毫米雨,所以判断接下来还会下"。
|
| 173 |
+
|
| 174 |
+
深度学习是黑盒——它说会下雨,**没人知道它为什么这么说**。
|
| 175 |
+
|
| 176 |
+
> 在**安全关键**应用里(关系到登山者性命),可解释性必须有。
|
| 177 |
+
|
| 178 |
+
#### 理由 ② 数据量
|
| 179 |
+
深度学习需要**几百万到几千万**条数据才能发挥优势。你只有 17.5 万行——**太少了**,深度学习反而不如随机森林。
|
| 180 |
+
|
| 181 |
+
#### 理由 ③ 速度
|
| 182 |
+
随机森林预测一次 **< 1 毫秒**。深度学习要 10 倍以上时间,还要 GPU。
|
| 183 |
+
|
| 184 |
+
> 你的 App 要做到"用户点地图立刻出结果"——快是必须的。
|
| 185 |
+
|
| 186 |
+
### 4.4 模型训练得怎么样?(这是老师最关心的数字)
|
| 187 |
+
|
| 188 |
+
#### 你必须能背的 4 个数字
|
| 189 |
+
|
| 190 |
+
| 指标 | 数值 | 大白话翻译 |
|
| 191 |
+
|---|---|---|
|
| 192 |
+
| **ROC AUC** | **0.871** | 整体准确度的综合分,**满分 1.0**——0.87 算"很好" |
|
| 193 |
+
| **Recall(召回率)** | **93.4%** | 真的下了雨的 100 次里,模型成功预警了 93 次(**只漏报 7 次**) |
|
| 194 |
+
| **Brier Score** | **0.138** | 概率校准度——**越低越好**,0.14 算"校准良好" |
|
| 195 |
+
| **测试样本数** | **35 063 条** | 用来打分的数据量(占总数据的 20%) |
|
| 196 |
+
|
| 197 |
+
#### 怎么"考试"?—— 时间序列切分
|
| 198 |
+
|
| 199 |
+
你**没有**用随机切分(很多人会犯的错)。
|
| 200 |
+
|
| 201 |
+
**比喻**:老师出期末考试,如果他用学生平时做过的题来考——那是作弊。必须用**新的**、学生没见过的题。
|
| 202 |
+
|
| 203 |
+
你的做法:把 5 年数据按时间顺序排,**最后 1 年(20%)扣下来不给电脑看**,只用前 4 年训练。训练完,拿最后 1 年的数据"考试"——这才公平。
|
| 204 |
+
|
| 205 |
+
#### 为什么不用准确率(accuracy)?
|
| 206 |
+
|
| 207 |
+
很多人会问"模型准确率多少"。**陷阱**:
|
| 208 |
+
|
| 209 |
+
- 马来西亚山区只有 30% 的时间下雨
|
| 210 |
+
- 一个**永远预测"不下雨"的傻瓜模型**,准确率自动是 70%
|
| 211 |
+
- 但它**永远漏报**,登山者会被淋成落汤鸡
|
| 212 |
+
|
| 213 |
+
所以你用 **F2 分数** + **召回率** 而不是准确率——这两个指标**重视"不漏报"**。
|
| 214 |
+
|
| 215 |
+
> 老师如果追问,就说:**"安全关键应用,漏报比误报严重得多。漏一次真下雨,登山者可能丧命;误报一次,登山者最多取消行程。所以我用 F2 分数,它把召回率的权重设为精度的 4 倍。"**
|
| 216 |
+
|
| 217 |
+
### 4.5 模型学到了什么?(特征重要性)
|
| 218 |
+
|
| 219 |
+
电脑学完之后,能告诉你"它最看重哪些信号"。前 4 个是:
|
| 220 |
+
|
| 221 |
+
| 排名 | 特征 | 大白话 |
|
| 222 |
+
|---|---|---|
|
| 223 |
+
| 1 | 上一小时降雨量 | "刚才下了,接下来很可能继续下"——**雨的惯性** |
|
| 224 |
+
| 2-3 | 时间(一天中的几点) | 马来西亚山区**下午 3-5 点最爱下雨**——电脑学会了这个规律 |
|
| 225 |
+
| 4 | 3 小时气压变化 | **气压下降预示风暴来临**——这是经典气象学常识,电脑自己学到了 |
|
| 226 |
+
|
| 227 |
+
> 这一段非常加分——证明**你的模型学到的是"物理上有意义"的规律**,不是乱猜。
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## 5. App(应用)—— 把模型包装成产品
|
| 232 |
+
|
| 233 |
+
### 5.1 这一步在干什么
|
| 234 |
+
|
| 235 |
+
模型本身只是个 `.pkl` 文件——一个 Python 对象。**没法用**。
|
| 236 |
+
|
| 237 |
+
App 的工作是:**让用户能用浏览器点地图,立刻看到那个地方的风险评分**。
|
| 238 |
+
|
| 239 |
+
### 5.2 整个 App 长什么样?
|
| 240 |
+
|
| 241 |
+
```
|
| 242 |
+
用户在浏览器点地图上一个点 (lat, lon)
|
| 243 |
+
│
|
| 244 |
+
▼
|
| 245 |
+
前端(Vue + Leaflet 地图)
|
| 246 |
+
│
|
| 247 |
+
│ HTTP 请求
|
| 248 |
+
▼
|
| 249 |
+
后端(FastAPI)
|
| 250 |
+
│
|
| 251 |
+
├──► 调用 Open-Meteo API:获取这个点当前的实时天气
|
| 252 |
+
├──► 调用 Open Topo Data:获取这个点的海拔高度
|
| 253 |
+
│
|
| 254 |
+
├──► 引擎 A:把天气数据喂给训练好的 RF 模型 → 得到下雨概率
|
| 255 |
+
├──► 引擎 B:用一组手写规则评估其他危险(雾、风、雷暴)
|
| 256 |
+
│
|
| 257 |
+
▼
|
| 258 |
+
综合评分(0-100)+ 双语建议返回前端
|
| 259 |
+
│
|
| 260 |
+
▼
|
| 261 |
+
前端显示:彩色仪表盘 + "建议:可以安全出行" / "警告:暴雨风险"
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
### 5.3 ⭐ 双引擎架构 —— 这是项目的**核心创新**
|
| 265 |
+
|
| 266 |
+
这是整个项目最值得吹的地方。**老师追问"为什么混合架构"时**你要能讲清楚。
|
| 267 |
+
|
| 268 |
+
#### 引擎 A:随机森林 = "经验主义"
|
| 269 |
+
学过 17.5 万条历史数据的"经验"——**它见过的天气模式**它能很准地预测。
|
| 270 |
+
|
| 271 |
+
#### 引擎 B:手写规则 = "原则主义"
|
| 272 |
+
一组**人工写的物理规则**:
|
| 273 |
+
|
| 274 |
+
```
|
| 275 |
+
如果 海拔 > 3500 米 → 缺氧危险(不管模型说什么,强制报警)
|
| 276 |
+
如果 温度 ≤ -5°C → 冻伤危险
|
| 277 |
+
如果 风速 ≥ 40 km/h → 大风危险
|
| 278 |
+
如果 风向 + 地形 = 迎风坡 → 地形抬升造雨风险
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
#### 为什么必须两个一起用?—— 珠峰例子
|
| 282 |
+
|
| 283 |
+
**情景**:用户点了一下珠穆朗玛峰(8 848 米)。
|
| 284 |
+
|
| 285 |
+
| 只用引擎 A(纯 ML) | 你的混合架构 |
|
| 286 |
+
|---|---|
|
| 287 |
+
| 模型只见过马来西亚的山(最高 1865 米) | 模型仍然给出低概率("看起来不像会下雨") |
|
| 288 |
+
| 它说"下雨概率 5%" | **但引擎 B 立刻发现**:海拔 > 3500 → 缺氧;温度 -30°C → 冻伤;风速 80 km/h → 大风 |
|
| 289 |
+
| 系统返回"安全" ❌ | **三个独立否决(Veto)触发** |
|
| 290 |
+
| 登山者去了 → 死 | 综合评分被强制设为 100 = **危险** ✅ |
|
| 291 |
+
|
| 292 |
+
> **学术上这叫"Neuro-Symbolic AI(神经-符号 AI)"**——能学的让机器学(神经),物理规律手工编码(符号)。
|
| 293 |
+
> 这不是你瞎编的,是 2020 年后学术界的热门方向,参考文献 Garcez & Lamb 2020。
|
| 294 |
+
|
| 295 |
+
### 5.4 App 的技术栈(被问到时回答)
|
| 296 |
+
|
| 297 |
+
| 部分 | 用了什么 | 一句话解释 |
|
| 298 |
+
|---|---|---|
|
| 299 |
+
| 前端 | Vue 3 + Leaflet + ECharts | 浏览器里的页面,地图,图表 |
|
| 300 |
+
| 后端 | FastAPI(Python) | 处理用户请求,调模型,返回结果 |
|
| 301 |
+
| 缓存 | SQLite | 同一个点 10 分钟内重复查不用每次都算 |
|
| 302 |
+
| 部署 | Docker | 一键就能在任何机器上跑起来 |
|
| 303 |
+
|
| 304 |
+
---
|
| 305 |
+
|
| 306 |
+
## 6. 整个项目你"做对了什么"—— 别人吹起来的话
|
| 307 |
+
|
| 308 |
+
| # | 做对的事 | 为什么加分 |
|
| 309 |
+
|---|---|---|
|
| 310 |
+
| 1 | 数据来自 ERA5 而不是网上随便爬的 | **业内金标准**,老师不会质疑数据质量 |
|
| 311 |
+
| 2 | 答案列自己工程构造,方法符合 WMO 标准 | 证明**读过文献**,不是拍脑袋 |
|
| 312 |
+
| 3 | 时间序列切分而不是随机切分 | 证明**懂 ML 基础**,没有数据泄漏 |
|
| 313 |
+
| 4 | 用 F2 分数选阈值而不是 F1 | 证明**理解任务背景**——安全关键场景重视召回 |
|
| 314 |
+
| 5 | 混合架构(ML + 规则)而不是纯 ML | 项目的**核心研究贡献** |
|
| 315 |
+
| 6 | 写了 70 个测试,覆盖率 97% | 证明**工程能力**——老师不会怀疑代码可靠性 |
|
| 316 |
+
| 7 | 一行命令 `make evaluate` 复现所有数字 | 评审老师能**独立验证**论文里的每个 claim |
|
| 317 |
+
|
| 318 |
+
---
|
| 319 |
+
|
| 320 |
+
## 7. 上次汇报老师的 3 大疑惑——逐条解决
|
| 321 |
+
|
| 322 |
+
| # | 老师疑惑 | 你现在的回答 |
|
| 323 |
+
|---|---|---|
|
| 324 |
+
| 1 | "App is the last(顺序错了)" | 严格按 dataset → model → app 顺序展示,写在 `pipeline_order.md` 里 |
|
| 325 |
+
| 2 | "Y is missing(没有答案列)" | 自己工程构造的 `is_rain_event`,方法符合 WMO 标准 |
|
| 326 |
+
| 3 | "应该做回归不是分类" | 下游决策是二元的、F2 分数只在分类下有意义、API 仍暴露概率 |
|
| 327 |
+
|
| 328 |
+
---
|
| 329 |
+
|
| 330 |
+
## 8. 这次汇报你只需要做 4 件事
|
| 331 |
+
|
| 332 |
+
### 8.1 开场(30 秒)
|
| 333 |
+
> "老师感谢您抽时间。接着上次的内容,我做完了 v1.0.0 工程化强化,整条流水线现在可以端到端复现。我按上次的顺序——**dataset、model、app**——给您过一遍新的进展,最后讲我对 Chapter 5 的下一步计划,可以吗?"
|
| 334 |
+
|
| 335 |
+
### 8.2 按顺序展示(5 分钟)
|
| 336 |
+
|
| 337 |
+
| 步骤 | 打开 | 讲什么 | 时长 |
|
| 338 |
+
|---|---|---|---|
|
| 339 |
+
| 1 | `docs/dataset.md` | "数据来自 ERA5,5 个山点 5 年共 17.5 万行;答案列 `is_rain_event` 我自己构造的,定义在 §5" | 30 秒 |
|
| 340 |
+
| 2 | `figures/01_roc_curve.png` | "测试 AUC 0.871,召回率 93.4%" | 30 秒 |
|
| 341 |
+
| 3 | `figures/03_calibration_curve.png` | "Brier 分数 0.138,校准良好" | 20 秒 |
|
| 342 |
+
| 4 | `figures/04_threshold_sweep.png` | "用 F2 分数选阈值——安全关键场景重视召回" | 20 秒 |
|
| 343 |
+
| 5 | `figures/05_feature_importance.png` | "前 3 个特征:上一小时降雨、时间周期、气压变化——和气象学文献吻合" | 20 秒 |
|
| 344 |
+
| 6 | App(**最后**才打开) | 演示云顶 + 珠峰两个场景 | 90 秒 |
|
| 345 |
+
|
| 346 |
+
### 8.3 讲下一步(90 秒)
|
| 347 |
+
|
| 348 |
+
打开 `docs/progress_update_brief.html` §4,照着 5 条 Chapter 5 方向讲一遍,然后**请老师拍板:先做哪两条**?
|
| 349 |
+
|
| 350 |
+
### 8.4 收尾(30 秒)
|
| 351 |
+
> "明早之前给您发 3 条要点的邮件总结,留个书面确认。谢谢老师。"
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## 9. 老师可能问的"傻"问题(其实不傻)
|
| 356 |
+
|
| 357 |
+
| 问题 | 你的回答 |
|
| 358 |
+
|---|---|
|
| 359 |
+
| "你的模型是什么?" | "Random Forest,随机森林" |
|
| 360 |
+
| "为什么不用深度学习?" | "数据量不够,需要可解释性,而且要快" |
|
| 361 |
+
| "测试准确率多少?" | "AUC 0.871,召回率 93.4%——我没用准确率,因为类别不平衡" |
|
| 362 |
+
| "怎么知道没过拟合?" | "做了 5 折时间序列交叉验证,每折 AUC 都在 0.83-0.91" |
|
| 363 |
+
| "Y 是怎么来的?" | "从 precipitation 列工程构造的:下一小时 > 0.1 mm 标 1,0.1 mm 是 WMO 标准" |
|
| 364 |
+
| "为什么是分类不是回归?" | "下游决策本身就是二元的,而且只有分类才能直接优化 F2 分数" |
|
| 365 |
+
| "规则引擎为什么必要?" | "纯 ML 学的是平均,遇到分布外输入(比如珠峰)会失效——规则引擎兜底" |
|
| 366 |
+
| "万一哪天模型挂了?" | "三层降级:模型挂了走启发式 / 异常返回类型化错误 / 规则引擎独立运行" |
|
| 367 |
+
|
| 368 |
+
---
|
| 369 |
+
|
| 370 |
+
## 10. 终极心法 —— 你只要记住这 5 句话
|
| 371 |
+
|
| 372 |
+
1. **"我做的是给登山者用的山区天气危险预警 App。"**(一句话讲清项目)
|
| 373 |
+
2. **"我用 ERA5 历史天气数据,自己构造了答案列 `is_rain_event`,训了一个随机森林模型。"**(数据 + 模型)
|
| 374 |
+
3. **"测试 AUC 0.871,召回率 93.4%——我重视召回因为漏报比误报危险。"**(核心数字)
|
| 375 |
+
4. **"我用混合架构——机器学习 + 手写规则,规则引擎在分布外场景兜底。"**(核心创新)
|
| 376 |
+
5. **"接下来 Chapter 5 我列了 5 个评估方向,请老师建议先做哪两个。"**(请示)
|
| 377 |
+
|
| 378 |
+
**这 5 句话能完整回答 90% 的追问。** 其余的细节,遇到了再翻这份文档。
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
> 写这份文档的目的:让你**真的懂**自己的项目,而不是背稿子。
|
| 383 |
+
> 懂了,老师怎么问你都不慌——因为你说出来的每一句话都是**你自己想清楚的**。
|
frontend/index.html
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>MicroClimate-X — Hybrid Microclimate Risk</title>
|
| 7 |
+
<meta name="description" content="Intelligent meteorological analysis for complex terrain." />
|
| 8 |
+
|
| 9 |
+
<!-- CDN deps -->
|
| 10 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 11 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
| 12 |
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
| 13 |
+
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
| 14 |
+
|
| 15 |
+
<style>
|
| 16 |
+
:root { color-scheme: dark; }
|
| 17 |
+
body { background:#0b0f17; color:#cbd5e1; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; }
|
| 18 |
+
.mono { font-family: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace; }
|
| 19 |
+
.panel { background:#111827; border:1px solid #1f2937; border-radius:14px; }
|
| 20 |
+
.leaflet-container { background:#0b0f17; }
|
| 21 |
+
.ring-gauge { transition: stroke-dashoffset 0.8s ease, stroke 0.4s ease; }
|
| 22 |
+
.log-line { animation: fadeUp 0.25s ease-out both; }
|
| 23 |
+
@keyframes fadeUp { from { opacity:0; transform: translateY(4px); } to { opacity:1; transform:none; } }
|
| 24 |
+
.veto-row { animation: blink 1.2s ease-in-out infinite; }
|
| 25 |
+
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.55} }
|
| 26 |
+
.kbd { background:#1f2937; border:1px solid #374151; padding:1px 6px; border-radius:6px; font-size:11px; }
|
| 27 |
+
.mini-gauge-svg { transition: stroke-dashoffset 0.6s ease, stroke 0.4s ease; }
|
| 28 |
+
.pill { padding: 4px 10px; border-radius: 999px; font-size: 11px; border:1px solid #374151;
|
| 29 |
+
color:#94a3b8; transition: background .15s, color .15s, border-color .15s; cursor:pointer; }
|
| 30 |
+
.pill:hover { color:#e2e8f0; }
|
| 31 |
+
.pill:focus-visible { outline: 2px solid #34d399; outline-offset: 2px; }
|
| 32 |
+
.pill-active { background:#34d399; color:#052e2b; border-color:#34d399; font-weight:600; }
|
| 33 |
+
.rule-badge { padding:2px 6px; font-size:10px; border-radius:6px; background:#1f2937;
|
| 34 |
+
border:1px solid #374151; color:#94a3b8; font-family: ui-monospace, monospace; }
|
| 35 |
+
.rule-badge-fired { background: #422006; border-color: #b45309; color: #fbbf24; }
|
| 36 |
+
|
| 37 |
+
/* Loading spinner — overlays panels while a request is in flight. */
|
| 38 |
+
.spinner { width:18px; height:18px; border:2px solid #1f2937; border-top-color:#34d399;
|
| 39 |
+
border-radius:50%; animation: spin 0.75s linear infinite; display:inline-block; }
|
| 40 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 41 |
+
|
| 42 |
+
/* Toast — bottom-right notification stack. */
|
| 43 |
+
#toast-host { position: fixed; bottom: 18px; right: 18px; z-index: 9999;
|
| 44 |
+
display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
| 45 |
+
.toast { pointer-events: auto; min-width: 240px; max-width: 360px;
|
| 46 |
+
background:#1f2937; border:1px solid #374151; border-radius:10px;
|
| 47 |
+
padding: 10px 14px; color:#e2e8f0; font-size:12px;
|
| 48 |
+
box-shadow: 0 6px 16px rgba(0,0,0,.5);
|
| 49 |
+
animation: slideIn .25s ease-out; }
|
| 50 |
+
.toast-err { border-color: #b91c1c; background:#2a0f10; }
|
| 51 |
+
.toast-ok { border-color: #047857; background:#062b22; }
|
| 52 |
+
@keyframes slideIn { from { transform: translateX(20px); opacity:0; } }
|
| 53 |
+
|
| 54 |
+
/* Mobile-first compaction. */
|
| 55 |
+
@media (max-width: 640px) {
|
| 56 |
+
.pill { padding: 3px 7px; font-size: 10px; }
|
| 57 |
+
.activity-bar { gap: 4px; }
|
| 58 |
+
}
|
| 59 |
+
</style>
|
| 60 |
+
</head>
|
| 61 |
+
<body class="min-h-screen">
|
| 62 |
+
<div id="app" class="min-h-screen flex flex-col">
|
| 63 |
+
<!-- ─── Header ─────────────────────────────────────── -->
|
| 64 |
+
<header class="border-b border-slate-800 px-5 py-3 flex items-center justify-between">
|
| 65 |
+
<div class="flex items-center gap-3">
|
| 66 |
+
<div class="w-9 h-9 rounded-lg bg-emerald-500/15 ring-1 ring-emerald-400/40 flex items-center justify-center text-emerald-300 font-bold">μ</div>
|
| 67 |
+
<div>
|
| 68 |
+
<div class="text-slate-100 font-semibold tracking-tight">MicroClimate-X</div>
|
| 69 |
+
<div class="text-[11px] text-slate-500 mono">Hybrid Microclimate Risk Engine · UKM FYP</div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="flex items-center gap-3 flex-wrap activity-bar">
|
| 73 |
+
<!-- Demo scenarios -->
|
| 74 |
+
<select v-model="selectedScenario"
|
| 75 |
+
@change="onScenarioChange"
|
| 76 |
+
class="bg-slate-800 text-slate-200 border border-slate-700 rounded-md text-[11px] px-2 py-1 focus:outline-none focus:ring-1 focus:ring-emerald-400"
|
| 77 |
+
:aria-label="t.scenarios">
|
| 78 |
+
<option value="" disabled>{{ t.scenarios }}</option>
|
| 79 |
+
<option v-for="s in SCENARIOS" :key="s.key" :value="s.key">{{ t.scenarioLabels[s.key] }}</option>
|
| 80 |
+
</select>
|
| 81 |
+
|
| 82 |
+
<!-- Activity selector -->
|
| 83 |
+
<div class="flex items-center gap-1">
|
| 84 |
+
<span class="text-[11px] text-slate-500 mr-1 hidden md:inline">{{ t.activity }}</span>
|
| 85 |
+
<button v-for="a in ACTIVITIES" :key="a"
|
| 86 |
+
@click="setActivity(a)"
|
| 87 |
+
:aria-label="t.activities[a]"
|
| 88 |
+
:aria-pressed="activity===a"
|
| 89 |
+
:class="['pill', activity===a && 'pill-active']">
|
| 90 |
+
{{ t.activities[a] }}
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
<span class="text-slate-700">|</span>
|
| 94 |
+
<button @click="lang='en'" :class="['px-2.5 py-1 rounded text-xs ring-1 ring-slate-700',
|
| 95 |
+
lang==='en' ? 'bg-emerald-500/20 text-emerald-300 ring-emerald-500/40' : 'text-slate-400 hover:text-slate-200']">EN</button>
|
| 96 |
+
<button @click="lang='zh'" :class="['px-2.5 py-1 rounded text-xs ring-1 ring-slate-700',
|
| 97 |
+
lang==='zh' ? 'bg-emerald-500/20 text-emerald-300 ring-emerald-500/40' : 'text-slate-400 hover:text-slate-200']">中文</button>
|
| 98 |
+
<span v-if="loading" class="spinner ml-1" :title="t.loading"></span>
|
| 99 |
+
<div class="hidden md:flex items-center text-[11px] text-slate-500 gap-2">
|
| 100 |
+
<span class="kbd">click</span>
|
| 101 |
+
<span>{{ t.clickHint }}</span>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</header>
|
| 105 |
+
|
| 106 |
+
<!-- ─── Main ───────────────────────────────────────── -->
|
| 107 |
+
<main class="flex-1 grid grid-cols-12 gap-3 p-3">
|
| 108 |
+
<!-- Map -->
|
| 109 |
+
<section class="col-span-12 lg:col-span-7 panel overflow-hidden" style="min-height: 60vh;">
|
| 110 |
+
<div id="map" class="w-full h-full" style="min-height: 60vh;"></div>
|
| 111 |
+
</section>
|
| 112 |
+
|
| 113 |
+
<!-- Right column -->
|
| 114 |
+
<aside class="col-span-12 lg:col-span-5 flex flex-col gap-3">
|
| 115 |
+
|
| 116 |
+
<!-- Score card -->
|
| 117 |
+
<div class="panel p-5">
|
| 118 |
+
<div class="flex items-center gap-5">
|
| 119 |
+
<!-- Gauge -->
|
| 120 |
+
<div class="relative w-28 h-28 shrink-0">
|
| 121 |
+
<svg viewBox="0 0 120 120" class="w-full h-full -rotate-90">
|
| 122 |
+
<circle cx="60" cy="60" r="52" stroke="#1f2937" stroke-width="10" fill="none"/>
|
| 123 |
+
<circle cx="60" cy="60" r="52" :stroke="riskColor" stroke-width="10" fill="none"
|
| 124 |
+
stroke-linecap="round"
|
| 125 |
+
:stroke-dasharray="2 * Math.PI * 52"
|
| 126 |
+
:stroke-dashoffset="2 * Math.PI * 52 * (1 - riskFraction)"
|
| 127 |
+
class="ring-gauge"/>
|
| 128 |
+
</svg>
|
| 129 |
+
<div class="absolute inset-0 flex items-center justify-center flex-col">
|
| 130 |
+
<div class="text-3xl font-semibold tracking-tight" :style="{color: riskColor}">{{ display.risk_score ?? '—' }}</div>
|
| 131 |
+
<div class="text-[10px] uppercase tracking-widest text-slate-500 mono">risk</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
<!-- Meta -->
|
| 135 |
+
<div class="flex-1 min-w-0">
|
| 136 |
+
<div class="text-xs uppercase tracking-widest text-slate-500 mono">{{ t.status }}</div>
|
| 137 |
+
<div class="text-xl font-medium" :style="{color: riskColor}">{{ riskLevelText }}</div>
|
| 138 |
+
<div class="mt-2 text-xs text-slate-400 mono break-words">
|
| 139 |
+
<span v-if="display.latitude != null">{{ display.latitude.toFixed(4) }}, {{ display.longitude.toFixed(4) }}</span>
|
| 140 |
+
<span v-else>{{ t.awaiting }}</span>
|
| 141 |
+
</div>
|
| 142 |
+
<div class="mt-1 text-[11px] text-slate-500 mono">
|
| 143 |
+
<span v-if="display.terrain">{{ t.terrain }}: <span class="text-slate-300">{{ display.terrain }}</span></span>
|
| 144 |
+
<span v-if="display.elevation_m != null"> · {{ Math.round(display.elevation_m) }} m</span>
|
| 145 |
+
<span v-if="display.cached" class="ml-2 text-emerald-400">⚡ cached ({{ display.cache_ttl }} s)</span>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
<p v-if="display.advice_en || display.advice_zh"
|
| 150 |
+
class="mt-4 text-sm leading-relaxed border-l-2 pl-3"
|
| 151 |
+
:style="{borderColor: riskColor}">
|
| 152 |
+
{{ lang === 'zh' ? display.advice_zh : display.advice_en }}
|
| 153 |
+
</p>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<!-- Sub-hazard mini-gauges (D5 §3.7 / P4.3) -->
|
| 157 |
+
<div class="panel p-4">
|
| 158 |
+
<div class="flex items-center justify-between text-xs uppercase tracking-widest text-slate-500 mono mb-3">
|
| 159 |
+
<span>{{ t.subHazards }}</span>
|
| 160 |
+
<span class="text-slate-600">P4.3 · P4.4</span>
|
| 161 |
+
</div>
|
| 162 |
+
<div class="grid grid-cols-4 gap-2">
|
| 163 |
+
<div v-for="h in HAZARDS" :key="h.key"
|
| 164 |
+
class="flex flex-col items-center gap-1"
|
| 165 |
+
:title="t.hazardTooltip[h.key]"
|
| 166 |
+
:aria-label="t.hazards[h.key] + ' ' + (display.hazard_subscores?.[h.key] ?? '?') + '/100'">
|
| 167 |
+
<div class="relative w-14 h-14">
|
| 168 |
+
<svg viewBox="0 0 60 60" class="w-full h-full -rotate-90">
|
| 169 |
+
<circle cx="30" cy="30" r="24" stroke="#1f2937" stroke-width="6" fill="none"/>
|
| 170 |
+
<circle cx="30" cy="30" r="24" :stroke="subHazardColor(display.hazard_subscores?.[h.key])"
|
| 171 |
+
stroke-width="6" fill="none" stroke-linecap="round"
|
| 172 |
+
:stroke-dasharray="2 * Math.PI * 24"
|
| 173 |
+
:stroke-dashoffset="2 * Math.PI * 24 * (1 - ((display.hazard_subscores?.[h.key] ?? 0) / 100))"
|
| 174 |
+
class="mini-gauge-svg"/>
|
| 175 |
+
</svg>
|
| 176 |
+
<div class="absolute inset-0 flex items-center justify-center">
|
| 177 |
+
<div class="text-[13px] font-semibold mono"
|
| 178 |
+
:style="{color: subHazardColor(display.hazard_subscores?.[h.key])}">
|
| 179 |
+
{{ display.hazard_subscores?.[h.key] ?? '–' }}
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="text-[10px] text-slate-400 text-center leading-tight">
|
| 184 |
+
{{ t.hazards[h.key] }}
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
<div class="mt-3 flex items-center justify-between text-[10px] text-slate-500 mono">
|
| 189 |
+
<span>Activity weight: {{ t.activities[display.activity || activity] }}</span>
|
| 190 |
+
<span>Composite ← max-dominant + 0.2 · others</span>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<!-- D5 §3.7.2 Decision Table R1-R4 -->
|
| 195 |
+
<div class="panel p-4">
|
| 196 |
+
<div class="flex items-center justify-between text-xs uppercase tracking-widest text-slate-500 mono mb-2">
|
| 197 |
+
<span>{{ t.decisionTable }}</span>
|
| 198 |
+
<span class="text-slate-600">§3.7.2 Table 4.2</span>
|
| 199 |
+
</div>
|
| 200 |
+
<div class="flex gap-1.5 mb-2">
|
| 201 |
+
<span v-for="r in ['R1','R2','R3','R4']" :key="r"
|
| 202 |
+
:class="['rule-badge', ruleFired(r) && 'rule-badge-fired']">{{ r }}</span>
|
| 203 |
+
</div>
|
| 204 |
+
<div v-if="display.decision_table_matches && display.decision_table_matches.length">
|
| 205 |
+
<p v-for="m in display.decision_table_matches" :key="m.rule"
|
| 206 |
+
class="text-[12px] text-amber-200 leading-snug pl-1">
|
| 207 |
+
<span class="mono text-amber-400">{{ m.rule }}</span>
|
| 208 |
+
· {{ lang === 'zh' ? m.conclusion_zh : m.conclusion_en }}
|
| 209 |
+
</p>
|
| 210 |
+
</div>
|
| 211 |
+
<p v-else class="text-[11px] text-slate-500">{{ t.noRuleFired }}</p>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<!-- Veto block -->
|
| 215 |
+
<div v-if="display.veto_triggers && display.veto_triggers.length"
|
| 216 |
+
class="panel p-4 border-red-500/40">
|
| 217 |
+
<div class="text-xs uppercase tracking-widest text-red-400 mono mb-2">{{ t.veto }}</div>
|
| 218 |
+
<ul class="space-y-1.5">
|
| 219 |
+
<li v-for="v in display.veto_triggers" :key="v.rule"
|
| 220 |
+
class="veto-row text-sm text-red-200 flex gap-2">
|
| 221 |
+
<span class="text-red-400 mono shrink-0">▶</span>
|
| 222 |
+
<span>{{ lang === 'zh' ? v.message_zh : v.message_en }}</span>
|
| 223 |
+
</li>
|
| 224 |
+
</ul>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<!-- ML probability bar -->
|
| 228 |
+
<div class="panel p-4">
|
| 229 |
+
<div class="flex items-center justify-between text-xs text-slate-400 mono mb-2">
|
| 230 |
+
<span>{{ t.mlProb }}</span>
|
| 231 |
+
<span class="text-slate-200">{{ display.ml_rain_probability != null ? (display.ml_rain_probability * 100).toFixed(1) + '%' : '—' }}</span>
|
| 232 |
+
</div>
|
| 233 |
+
<div class="w-full h-2 bg-slate-800 rounded-full overflow-hidden">
|
| 234 |
+
<div class="h-full transition-all duration-700" :style="{
|
| 235 |
+
width: ((display.ml_rain_probability ?? 0) * 100) + '%',
|
| 236 |
+
background: 'linear-gradient(90deg, #34d399, #fbbf24, #ef4444)'
|
| 237 |
+
}"></div>
|
| 238 |
+
</div>
|
| 239 |
+
<div class="mt-1 text-[10px] text-slate-500 mono">Engine A · Random Forest</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<!-- XAI log -->
|
| 243 |
+
<div class="panel p-4 flex-1 min-h-[200px] flex flex-col">
|
| 244 |
+
<div class="flex items-center justify-between text-xs uppercase tracking-widest text-slate-500 mono mb-2">
|
| 245 |
+
<span>{{ t.inferenceLog }}</span>
|
| 246 |
+
<span class="text-slate-600">XAI</span>
|
| 247 |
+
</div>
|
| 248 |
+
<div ref="logScroll" class="flex-1 overflow-auto text-[12px] mono space-y-1 pr-1">
|
| 249 |
+
<div v-if="!display.inference_log || !display.inference_log.length" class="text-slate-600">{{ t.awaiting }}</div>
|
| 250 |
+
<div v-for="(s, i) in display.inference_log" :key="i"
|
| 251 |
+
class="log-line flex gap-2"
|
| 252 |
+
:class="logColor(s.kind)">
|
| 253 |
+
<span class="shrink-0 w-12 text-slate-500">[{{ s.kind }}]</span>
|
| 254 |
+
<span>{{ lang === 'zh' ? s.text_zh : s.text_en }}</span>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</aside>
|
| 259 |
+
</main>
|
| 260 |
+
|
| 261 |
+
<!-- ─── Footer ─────────────────────────────────────── -->
|
| 262 |
+
<footer class="px-5 py-2 text-[11px] mono text-slate-600 border-t border-slate-800 flex flex-wrap items-center justify-between gap-2">
|
| 263 |
+
<span>API: <span class="text-slate-400">{{ apiBase }}</span></span>
|
| 264 |
+
<span>{{ t.disclaimer }}</span>
|
| 265 |
+
</footer>
|
| 266 |
+
|
| 267 |
+
<!-- ─���─ Toast host ─────────────────────────────────── -->
|
| 268 |
+
<div id="toast-host" aria-live="polite">
|
| 269 |
+
<div v-for="t in toasts" :key="t.id"
|
| 270 |
+
:class="['toast', t.kind === 'error' ? 'toast-err' : 'toast-ok']"
|
| 271 |
+
role="status">
|
| 272 |
+
{{ t.text }}
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<script>
|
| 278 |
+
const { createApp, reactive, computed, ref, onMounted, watch, nextTick } = Vue;
|
| 279 |
+
|
| 280 |
+
const ACTIVITIES = ["hiker", "driver", "construction", "general"];
|
| 281 |
+
const HAZARDS = [
|
| 282 |
+
{ key: "rainfall" },
|
| 283 |
+
{ key: "fog" },
|
| 284 |
+
{ key: "wind_gust" },
|
| 285 |
+
{ key: "thunderstorm" },
|
| 286 |
+
];
|
| 287 |
+
const SCENARIOS = [
|
| 288 |
+
{ key: "genting", lat: 3.4225, lon: 101.7935 }, // Genting Highlands
|
| 289 |
+
{ key: "cameron", lat: 4.4710, lon: 101.3779 }, // Cameron valley
|
| 290 |
+
{ key: "kinabalu", lat: 6.0747, lon: 116.5586 }, // Mt Kinabalu
|
| 291 |
+
{ key: "everest", lat: 27.9881, lon: 86.9250 }, // Mt Everest (OOD)
|
| 292 |
+
{ key: "singapore", lat: 1.3521, lon: 103.8198 }, // Flat tropical
|
| 293 |
+
];
|
| 294 |
+
|
| 295 |
+
const I18N = {
|
| 296 |
+
en: {
|
| 297 |
+
clickHint: "any point on the map for analysis",
|
| 298 |
+
status: "Status",
|
| 299 |
+
awaiting: "Click any coordinate to analyse…",
|
| 300 |
+
terrain: "Terrain",
|
| 301 |
+
mlProb: "Rain probability (next hour)",
|
| 302 |
+
veto: "Veto triggers",
|
| 303 |
+
inferenceLog: "Inference log",
|
| 304 |
+
disclaimer: "Decision-support only. Always consult official forecasts.",
|
| 305 |
+
levels: { Safe:"Safe", Caution:"Caution", Warning:"Warning", Danger:"Danger" },
|
| 306 |
+
activity: "Activity",
|
| 307 |
+
activities: { hiker:"🥾 Hiker", driver:"🚗 Driver", construction:"🏗️ Construction", general:"🧭 General" },
|
| 308 |
+
subHazards: "Hazard breakdown",
|
| 309 |
+
hazards: { rainfall:"Rainfall", fog:"Fog", wind_gust:"Wind gust", thunderstorm:"Thunderstorm" },
|
| 310 |
+
hazardTooltip: {
|
| 311 |
+
rainfall: "Macro rain probability + terrain amplification.",
|
| 312 |
+
fog: "Humidity, dew-point depression, cloud cover & basin geometry.",
|
| 313 |
+
wind_gust: "Sustained wind + ridge/pass acceleration.",
|
| 314 |
+
thunderstorm: "CAPE instability + falling pressure precursor.",
|
| 315 |
+
},
|
| 316 |
+
decisionTable: "Decision table",
|
| 317 |
+
noRuleFired: "No D5 §3.7.2 rule fired for this scenario.",
|
| 318 |
+
scenarios: "Quick scenarios",
|
| 319 |
+
scenarioLabels: {
|
| 320 |
+
genting: "🇲🇾 Genting Highlands · slope",
|
| 321 |
+
cameron: "🇲🇾 Cameron Highlands · valley",
|
| 322 |
+
kinabalu: "🇲🇾 Mt Kinabalu · 4 095 m peak",
|
| 323 |
+
everest: "🏔️ Mt Everest · 8 848 m (OOD)",
|
| 324 |
+
singapore: "🌴 Singapore · flat tropical",
|
| 325 |
+
},
|
| 326 |
+
loading: "Loading…",
|
| 327 |
+
errorTitle: "Request failed",
|
| 328 |
+
},
|
| 329 |
+
zh: {
|
| 330 |
+
clickHint: "点击地图任意位置开始分析",
|
| 331 |
+
status: "状态",
|
| 332 |
+
awaiting: "请在地图上点击任意坐标开始分析…",
|
| 333 |
+
terrain: "地形",
|
| 334 |
+
mlProb: "未来一小时降雨概率",
|
| 335 |
+
veto: "一票否决",
|
| 336 |
+
inferenceLog: "推理日志",
|
| 337 |
+
disclaimer: "仅供辅助决策,请同时参考官方气象预报。",
|
| 338 |
+
levels: { Safe:"安全", Caution:"注意", Warning:"警告", Danger:"危险" },
|
| 339 |
+
activity: "活动",
|
| 340 |
+
activities: { hiker:"🥾 徒步", driver:"🚗 驾驶", construction:"🏗️ 施工", general:"🧭 通用" },
|
| 341 |
+
subHazards: "分项灾害评分",
|
| 342 |
+
hazards: { rainfall:"降雨", fog:"雾", wind_gust:"阵风", thunderstorm:"雷暴" },
|
| 343 |
+
hazardTooltip: {
|
| 344 |
+
rainfall: "宏观降雨概率 + 地形放大。",
|
| 345 |
+
fog: "湿度、露点温差、云量、盆地汇雾。",
|
| 346 |
+
wind_gust: "持续风速 + 山脊/山口加速。",
|
| 347 |
+
thunderstorm: "CAPE 不稳定 + 气压骤降前兆。",
|
| 348 |
+
},
|
| 349 |
+
decisionTable: "决策表",
|
| 350 |
+
noRuleFired: "当前场景未触发 D5 §3.7.2 中的任何规则。",
|
| 351 |
+
scenarios: "快速场景",
|
| 352 |
+
scenarioLabels: {
|
| 353 |
+
genting: "🇲🇾 云顶高原 · 山坡",
|
| 354 |
+
cameron: "🇲🇾 金马仑高原 · 山谷",
|
| 355 |
+
kinabalu: "🇲🇾 神山 · 4 095 m 山峰",
|
| 356 |
+
everest: "🏔️ 珠穆朗玛 · 8 848 m (OOD)",
|
| 357 |
+
singapore: "🌴 新加坡 · 热带平原",
|
| 358 |
+
},
|
| 359 |
+
loading: "加载中…",
|
| 360 |
+
errorTitle: "请求失败",
|
| 361 |
+
},
|
| 362 |
+
};
|
| 363 |
+
|
| 364 |
+
createApp({
|
| 365 |
+
setup() {
|
| 366 |
+
const lang = ref(localStorage.getItem("mcx_lang") || "en");
|
| 367 |
+
const activity = ref(localStorage.getItem("mcx_activity") || "hiker");
|
| 368 |
+
const loading = ref(false);
|
| 369 |
+
const toasts = reactive([]);
|
| 370 |
+
const selectedScenario = ref("");
|
| 371 |
+
const t = computed(() => I18N[lang.value]);
|
| 372 |
+
watch(lang, v => localStorage.setItem("mcx_lang", v));
|
| 373 |
+
const apiBase = (() => {
|
| 374 |
+
const meta = document.querySelector('meta[name="api-base"]');
|
| 375 |
+
if (meta) return meta.content;
|
| 376 |
+
if (location.protocol === "file:") return "http://localhost:8000";
|
| 377 |
+
return location.origin;
|
| 378 |
+
})();
|
| 379 |
+
|
| 380 |
+
const display = reactive({
|
| 381 |
+
latitude: null,
|
| 382 |
+
longitude: null,
|
| 383 |
+
elevation_m: null,
|
| 384 |
+
terrain: null,
|
| 385 |
+
ml_rain_probability: null,
|
| 386 |
+
risk_score: null,
|
| 387 |
+
risk_level: null,
|
| 388 |
+
veto_triggers: [],
|
| 389 |
+
inference_log: [],
|
| 390 |
+
advice_en: "",
|
| 391 |
+
advice_zh: "",
|
| 392 |
+
cached: false,
|
| 393 |
+
cache_ttl: 0,
|
| 394 |
+
hazard_subscores: null,
|
| 395 |
+
decision_table_matches: [],
|
| 396 |
+
activity: null,
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
const riskFraction = computed(() =>
|
| 400 |
+
display.risk_score == null ? 0 : Math.max(0, Math.min(1, display.risk_score / 100))
|
| 401 |
+
);
|
| 402 |
+
const riskColor = computed(() => {
|
| 403 |
+
const s = display.risk_score ?? -1;
|
| 404 |
+
if (s < 0) return "#475569";
|
| 405 |
+
if (s >= 80) return "#ef4444";
|
| 406 |
+
if (s >= 55) return "#f97316";
|
| 407 |
+
if (s >= 30) return "#fbbf24";
|
| 408 |
+
return "#34d399";
|
| 409 |
+
});
|
| 410 |
+
const riskLevelText = computed(() => {
|
| 411 |
+
if (!display.risk_level) return "—";
|
| 412 |
+
return t.value.levels[display.risk_level] || display.risk_level;
|
| 413 |
+
});
|
| 414 |
+
|
| 415 |
+
const logColor = (kind) => ({
|
| 416 |
+
info: "text-slate-300",
|
| 417 |
+
ml: "text-cyan-300",
|
| 418 |
+
rule: "text-amber-300",
|
| 419 |
+
veto: "text-red-400 font-medium",
|
| 420 |
+
score: "text-emerald-300",
|
| 421 |
+
hazard: "text-violet-300",
|
| 422 |
+
table: "text-amber-200 font-medium",
|
| 423 |
+
activity: "text-emerald-200",
|
| 424 |
+
}[kind] || "text-slate-400");
|
| 425 |
+
|
| 426 |
+
const subHazardColor = (score) => {
|
| 427 |
+
if (score == null) return "#475569";
|
| 428 |
+
if (score >= 80) return "#ef4444";
|
| 429 |
+
if (score >= 55) return "#f97316";
|
| 430 |
+
if (score >= 30) return "#fbbf24";
|
| 431 |
+
return "#34d399";
|
| 432 |
+
};
|
| 433 |
+
|
| 434 |
+
const ruleFired = (rule) =>
|
| 435 |
+
(display.decision_table_matches || []).some(m => m.rule === rule);
|
| 436 |
+
|
| 437 |
+
const logScroll = ref(null);
|
| 438 |
+
|
| 439 |
+
watch(() => display.inference_log, async () => {
|
| 440 |
+
await nextTick();
|
| 441 |
+
if (logScroll.value) logScroll.value.scrollTop = logScroll.value.scrollHeight;
|
| 442 |
+
}, { deep: true });
|
| 443 |
+
|
| 444 |
+
let map, marker;
|
| 445 |
+
|
| 446 |
+
function pushToast(text, kind = "ok", ttl = 4500) {
|
| 447 |
+
const item = { id: Date.now() + Math.random(), text, kind };
|
| 448 |
+
toasts.push(item);
|
| 449 |
+
setTimeout(() => {
|
| 450 |
+
const i = toasts.findIndex(x => x.id === item.id);
|
| 451 |
+
if (i >= 0) toasts.splice(i, 1);
|
| 452 |
+
}, ttl);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
async function fetchPrediction(lat, lon) {
|
| 456 |
+
// typewriter — pre-clear and show "thinking"
|
| 457 |
+
display.inference_log = [
|
| 458 |
+
{ kind: "info",
|
| 459 |
+
text_en: `Querying (${lat.toFixed(4)}, ${lon.toFixed(4)}) for activity=${activity.value}…`,
|
| 460 |
+
text_zh: `查询坐标 (${lat.toFixed(4)}, ${lon.toFixed(4)}),活动类型 ${activity.value}…` },
|
| 461 |
+
];
|
| 462 |
+
loading.value = true;
|
| 463 |
+
try {
|
| 464 |
+
const r = await fetch(
|
| 465 |
+
`${apiBase}/api/predict?lat=${lat}&lon=${lon}&activity=${encodeURIComponent(activity.value)}`
|
| 466 |
+
);
|
| 467 |
+
if (!r.ok) {
|
| 468 |
+
let body;
|
| 469 |
+
try { body = await r.json(); } catch (_) { body = {}; }
|
| 470 |
+
const msg = body?.detail || `HTTP ${r.status}`;
|
| 471 |
+
throw new Error(msg);
|
| 472 |
+
}
|
| 473 |
+
const data = await r.json();
|
| 474 |
+
Object.assign(display, data);
|
| 475 |
+
const full = data.inference_log || [];
|
| 476 |
+
display.inference_log = [];
|
| 477 |
+
for (let i = 0; i < full.length; i++) {
|
| 478 |
+
await new Promise(res => setTimeout(res, 90));
|
| 479 |
+
display.inference_log.push(full[i]);
|
| 480 |
+
}
|
| 481 |
+
} catch (err) {
|
| 482 |
+
const text = (lang.value === "zh"
|
| 483 |
+
? `${t.value.errorTitle}:${err.message}`
|
| 484 |
+
: `${t.value.errorTitle}: ${err.message}`);
|
| 485 |
+
pushToast(text, "error", 6000);
|
| 486 |
+
display.inference_log.push({
|
| 487 |
+
kind: "veto",
|
| 488 |
+
text_en: `Request failed: ${err.message}. Is the API running on ${apiBase}?`,
|
| 489 |
+
text_zh: `请求失败:${err.message}。请确认 API 是否运行在 ${apiBase}。`,
|
| 490 |
+
});
|
| 491 |
+
} finally {
|
| 492 |
+
loading.value = false;
|
| 493 |
+
}
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
function onClick(e) {
|
| 497 |
+
const { lat, lng } = e.latlng;
|
| 498 |
+
display.latitude = lat;
|
| 499 |
+
display.longitude = lng;
|
| 500 |
+
if (marker) marker.setLatLng([lat, lng]);
|
| 501 |
+
else marker = L.marker([lat, lng]).addTo(map);
|
| 502 |
+
fetchPrediction(lat, lng);
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
function setActivity(a) {
|
| 506 |
+
activity.value = a;
|
| 507 |
+
localStorage.setItem("mcx_activity", a);
|
| 508 |
+
if (display.latitude != null && display.longitude != null) {
|
| 509 |
+
fetchPrediction(display.latitude, display.longitude);
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
function onScenarioChange() {
|
| 514 |
+
const s = SCENARIOS.find(x => x.key === selectedScenario.value);
|
| 515 |
+
if (!s) return;
|
| 516 |
+
display.latitude = s.lat;
|
| 517 |
+
display.longitude = s.lon;
|
| 518 |
+
map.flyTo([s.lat, s.lon], 10, { duration: 1.2 });
|
| 519 |
+
if (marker) marker.setLatLng([s.lat, s.lon]);
|
| 520 |
+
else marker = L.marker([s.lat, s.lon]).addTo(map);
|
| 521 |
+
fetchPrediction(s.lat, s.lon);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
async function checkBackendHealth() {
|
| 525 |
+
try {
|
| 526 |
+
const r = await fetch(`${apiBase}/api/health`, { cache: "no-store" });
|
| 527 |
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
| 528 |
+
const h = await r.json();
|
| 529 |
+
if (!h.ml_loaded) {
|
| 530 |
+
pushToast(
|
| 531 |
+
lang.value === "zh"
|
| 532 |
+
? "未检测到训练模型,正在使用启发式回退。运行 make train 后即可启用 Random Forest。"
|
| 533 |
+
: "No trained model found — running on heuristic fallback. Run `make train` to enable Random Forest.",
|
| 534 |
+
"error", 7000);
|
| 535 |
+
}
|
| 536 |
+
} catch (_e) {
|
| 537 |
+
// The first /api/predict call will surface its own error toast.
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
onMounted(() => {
|
| 542 |
+
map = L.map("map", {
|
| 543 |
+
center: [3.4225, 101.7935], // Genting Highlands
|
| 544 |
+
zoom: 9,
|
| 545 |
+
zoomControl: true,
|
| 546 |
+
});
|
| 547 |
+
const dark = L.tileLayer(
|
| 548 |
+
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
| 549 |
+
{ attribution: "© OpenStreetMap, © CARTO", maxZoom: 19 }
|
| 550 |
+
);
|
| 551 |
+
const topo = L.tileLayer(
|
| 552 |
+
"https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
|
| 553 |
+
{ attribution: "© OpenTopoMap, © OpenStreetMap", maxZoom: 17 }
|
| 554 |
+
);
|
| 555 |
+
dark.addTo(map);
|
| 556 |
+
L.control.layers(
|
| 557 |
+
{ "Dark": dark, "Topographic": topo },
|
| 558 |
+
{}, { position: "bottomleft", collapsed: true }
|
| 559 |
+
).addTo(map);
|
| 560 |
+
map.on("click", onClick);
|
| 561 |
+
|
| 562 |
+
checkBackendHealth();
|
| 563 |
+
// Auto-trigger an initial demo query for Genting Highlands.
|
| 564 |
+
setTimeout(() => onClick({ latlng: { lat: 3.4225, lng: 101.7935 } }), 600);
|
| 565 |
+
});
|
| 566 |
+
|
| 567 |
+
return {
|
| 568 |
+
lang, t, display, riskFraction, riskColor, riskLevelText,
|
| 569 |
+
logColor, logScroll, apiBase,
|
| 570 |
+
activity, setActivity, subHazardColor, ruleFired,
|
| 571 |
+
ACTIVITIES, HAZARDS, SCENARIOS,
|
| 572 |
+
selectedScenario, onScenarioChange,
|
| 573 |
+
loading, toasts,
|
| 574 |
+
};
|
| 575 |
+
},
|
| 576 |
+
}).mount("#app");
|
| 577 |
+
</script>
|
| 578 |
+
</body>
|
| 579 |
+
</html>
|
models/.gitkeep
ADDED
|
File without changes
|
models/MODEL_CARD.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model Card — MicroClimate-X Rain Predictor (Random Forest v1.0)
|
| 2 |
+
|
| 3 |
+
> Following the *Model Card* methodology of Mitchell et al. (2019).
|
| 4 |
+
> Authored: 2026-05-11 · UKM Final Year Project · KyoukoLi
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 1. Model Details
|
| 9 |
+
|
| 10 |
+
| Field | Value |
|
| 11 |
+
|---|---|
|
| 12 |
+
| **Model name** | MicroClimate-X RF Rain Predictor |
|
| 13 |
+
| **Version** | 1.0.0 |
|
| 14 |
+
| **Architecture** | `sklearn.ensemble.RandomForestClassifier` |
|
| 15 |
+
| **Hyper-parameters** | `n_estimators=200, max_depth=None, class_weight='balanced', n_jobs=-1, random_state=42` |
|
| 16 |
+
| **Features (n=18)** | `elevation_m`, `temperature_c`, `humidity_pct`, `wind_speed_kmh`, `wind_direction_deg`, `pressure_hpa`, `dew_point_c`, `cloud_cover_pct`, `cape_jkg`, `visibility_m`, `wind_u`, `wind_v`, `hour_sin`, `hour_cos`, `month_sin`, `month_cos`, `dew_point_depression`, `pressure_change_3h`, `precipitation_lag_1h` |
|
| 17 |
+
| **Target** | `is_rain_event` ∈ {0, 1} — defined as `precipitation(t+1h) > 0.1 mm` |
|
| 18 |
+
| **Output** | `predict_proba(...)[:, 1]` — calibrated probability of rain in the next hour |
|
| 19 |
+
| **Author / Contact** | Li Zhenyue (`KyoukoLi`), Faculty of Information Science & Technology, UKM |
|
| 20 |
+
| **Licence** | MIT (see `LICENSE`) |
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 2. Intended Use
|
| 25 |
+
|
| 26 |
+
* **Primary use case**: terrain-aware rain-risk decision support inside the MicroClimate-X *hybrid* pipeline. The RF probability is one input among many — the topographic Rule Engine has *final authority* (Veto cascade + R1-R4 decision table).
|
| 27 |
+
* **Intended users**: hikers, drivers, construction crews, and other outdoor decision makers in complex terrain (initially Malaysian mountain regions).
|
| 28 |
+
* **Out-of-scope uses**:
|
| 29 |
+
* Lightning forecasting (CAPE → thunderstorm risk is handled by the rule engine sub-scorer, not by this model).
|
| 30 |
+
* Multi-hour quantitative precipitation forecasting.
|
| 31 |
+
* Aviation, marine, or any life-critical use without the Rule Engine veto layer in the loop.
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## 3. Training Data
|
| 36 |
+
|
| 37 |
+
| Field | Value |
|
| 38 |
+
|---|---|
|
| 39 |
+
| **Source** | ECMWF ERA5 Reanalysis (via Open-Meteo Historical Archive API) |
|
| 40 |
+
| **Spatial coverage** | 5 mountain sites in West Malaysia (Genting, Cameron, Brinchang, Korbu, Kinabalu) |
|
| 41 |
+
| **Temporal coverage** | 2019-01-01 → 2024-12-31 (5 years, hourly) |
|
| 42 |
+
| **Total rows** | 175 315 |
|
| 43 |
+
| **Class balance** | 29.2 % positive (rain-event), 70.8 % negative |
|
| 44 |
+
| **Train / test split** | Time-based; 80 % oldest → train; 20 % newest → test. **No random shuffling** — would leak temporal autocorrelation. |
|
| 45 |
+
| **Synthetic fallback** | `scripts/1b_synth_dataset.py` generates a physically-plausible synthetic replacement when the Open-Meteo API is unreachable. The synthetic data set has the same schema and is sufficient for end-to-end pipeline verification but should **not** be used to ship a production model. |
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## 4. Evaluation — Held-out 20 % temporal test set (n = 35 063)
|
| 50 |
+
|
| 51 |
+
Numbers below come from `figures/evaluation_summary.json`, reproducible via `make evaluate`.
|
| 52 |
+
|
| 53 |
+
### 4.1 Discrimination
|
| 54 |
+
|
| 55 |
+
| Metric | Value |
|
| 56 |
+
|---|---|
|
| 57 |
+
| ROC AUC | **0.871** |
|
| 58 |
+
| PR Average Precision | **0.750** |
|
| 59 |
+
| Test-set base rate | 0.292 |
|
| 60 |
+
|
| 61 |
+
### 4.2 Calibration
|
| 62 |
+
|
| 63 |
+
| Metric | Value |
|
| 64 |
+
|---|---|
|
| 65 |
+
| Brier score | **0.138** (lower is better; 0 is perfect, 0.25 is random) |
|
| 66 |
+
|
| 67 |
+
The reliability diagram (`figures/03_calibration_curve.png`) shows the predicted probability tracks the empirical frequency closely; no post-hoc calibration (Platt / isotonic) was deemed necessary.
|
| 68 |
+
|
| 69 |
+
### 4.3 Operating point — safety-critical threshold
|
| 70 |
+
|
| 71 |
+
| Threshold τ | F1 | F2 | Precision | Recall |
|
| 72 |
+
|---|---|---|---|---|
|
| 73 |
+
| 0.50 (default) | 0.696 | 0.694 | 0.700 | 0.692 |
|
| 74 |
+
| **0.20 (chosen)** | 0.621 | **0.778** | 0.466 | **0.934** |
|
| 75 |
+
|
| 76 |
+
We adopt **τ = 0.20** because the application is **safety-critical**: a missed rain event (false negative) on a windward slope can cascade into orographic flash flooding. F2 weights recall 4× higher than precision and is the appropriate metric for this regime (Sasaki, 2007).
|
| 77 |
+
|
| 78 |
+
### 4.4 Confusion matrix at τ = 0.20
|
| 79 |
+
|
| 80 |
+
| | Pred = 0 | Pred = 1 |
|
| 81 |
+
|---|---|---|
|
| 82 |
+
| **True = 0** | 13 877 (TN) | 10 950 (FP) |
|
| 83 |
+
| **True = 1** | 679 (FN) | 9 557 (TP) |
|
| 84 |
+
|
| 85 |
+
Recall = 9 557 / (9 557 + 679) = **93.4 %** — the operationally important metric for "do not let people walk into a storm".
|
| 86 |
+
|
| 87 |
+
### 4.5 Top feature importances
|
| 88 |
+
|
| 89 |
+
1. `precipitation_lag_1h` — recent rain is by far the strongest signal (rain begets rain).
|
| 90 |
+
2. `hour_cos` / `hour_sin` — diurnal cycle (afternoon convective storms in tropical climates).
|
| 91 |
+
3. `pressure_change_3h` — falling pressure is a classical storm precursor.
|
| 92 |
+
4. `wind_v` — meridional wind component, relevant for monsoon-driven precipitation.
|
| 93 |
+
5. `dew_point_c` / `dew_point_depression` / `temperature_c` — moisture saturation indicators.
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## 5. Quantitative Limitations
|
| 98 |
+
|
| 99 |
+
* **Geographic generalisation** — the model has only seen West Malaysian mountains. Hindcast validation in other tropical mountainous regions is a planned thesis Chapter 5 contribution; until then, the Rule Engine Veto cascade is the only safety net for out-of-distribution coordinates (e.g. Himalayas).
|
| 100 |
+
* **Convective forecasting** — the model uses *current-hour* features to predict *next-hour* rain. Forecasting horizon > 1 h would degrade accuracy substantially.
|
| 101 |
+
* **Class imbalance** — addressed via `class_weight='balanced'` and the F2-optimal threshold, but precision at τ = 0.20 is moderate (47 %). False positives are tolerable because they only inflate the *rainfall sub-score*; the composite-score formula combines this with three other hazards.
|
| 102 |
+
* **Calibration drift** — Brier = 0.138 in 2024 hold-out. Calibration should be re-checked annually as climate signals shift.
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## 6. Ethical / Safety Considerations
|
| 107 |
+
|
| 108 |
+
* **Decision-support only.** The system is explicitly **not** a substitute for official meteorological forecasts; the disclaimer is shown in every UI footer.
|
| 109 |
+
* **Hidden risk surfaced, not hidden.** The R1 decision-table rule deliberately raises an alarm when *macro* model probability is low but local terrain inputs suggest hidden orographic rain — this is the OPPOSITE of the harmful failure mode where ML over-confidently says "safe".
|
| 110 |
+
* **Mt-Everest test (worst-case OOD).** When fed coordinates the model has never seen, the RF returns ~0 % rain probability — and the Rule Engine then immediately vetoes on `altitude_hypoxia + extreme_cold + gale_wind`. See `tests/test_rule_engine.py::test_mt_everest_veto_hypoxia`.
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
## 7. Reproducibility
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
# Full pipeline from scratch — works offline via the synthetic dataset.
|
| 118 |
+
make install-dev
|
| 119 |
+
make synth # OR: download real data via scripts/1_download_dataset.py
|
| 120 |
+
make preprocess
|
| 121 |
+
make train
|
| 122 |
+
make evaluate # writes figures/*.png + figures/evaluation_summary.json
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
The seed is fixed (`random_state=42`) and figures are written to `figures/` so the thesis can pull them in directly.
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## 8. Citation
|
| 130 |
+
|
| 131 |
+
If you reference this model in academic work, please cite:
|
| 132 |
+
|
| 133 |
+
> Li Zhenyue (KyoukoLi). *MicroClimate-X: A Hybrid Microclimate Risk Engine for Complex Terrain*. Bachelor's Thesis, Universiti Kebangsaan Malaysia, Faculty of Information Science & Technology, 2026. GitHub: <https://github.com/KyoukoLi/microclimate-x>
|
models/feature_columns.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"elevation_m",
|
| 3 |
+
"temperature_c",
|
| 4 |
+
"humidity_pct",
|
| 5 |
+
"wind_speed_kmh",
|
| 6 |
+
"wind_direction_deg",
|
| 7 |
+
"wind_u",
|
| 8 |
+
"wind_v",
|
| 9 |
+
"pressure_hpa",
|
| 10 |
+
"pressure_change_3h",
|
| 11 |
+
"dew_point_c",
|
| 12 |
+
"dew_point_depression",
|
| 13 |
+
"cloud_cover_pct",
|
| 14 |
+
"cape_jkg",
|
| 15 |
+
"precipitation_lag_1h",
|
| 16 |
+
"hour_sin",
|
| 17 |
+
"hour_cos",
|
| 18 |
+
"month_sin",
|
| 19 |
+
"month_cos"
|
| 20 |
+
]
|
models/training_report.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"n_train": 140250,
|
| 3 |
+
"n_test": 35065,
|
| 4 |
+
"class_balance": 0.3082679747882383,
|
| 5 |
+
"cv_fold_metrics": [
|
| 6 |
+
{
|
| 7 |
+
"fold": 1,
|
| 8 |
+
"precision": 0.6098984999550885,
|
| 9 |
+
"recall": 0.8534439416792358,
|
| 10 |
+
"f1": 0.7114044737807114,
|
| 11 |
+
"f2": 0.7903252089298601,
|
| 12 |
+
"auc": 0.8658920922848545
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"fold": 2,
|
| 16 |
+
"precision": 0.6706632265897708,
|
| 17 |
+
"recall": 0.6446389496717724,
|
| 18 |
+
"f1": 0.6573936328473668,
|
| 19 |
+
"f2": 0.6496809668029051,
|
| 20 |
+
"auc": 0.8450208663371146
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"fold": 3,
|
| 24 |
+
"precision": 0.5280549297285699,
|
| 25 |
+
"recall": 0.7822631913541005,
|
| 26 |
+
"f1": 0.6305002241721642,
|
| 27 |
+
"f2": 0.7135608454869669,
|
| 28 |
+
"auc": 0.828111331389444
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"fold": 4,
|
| 32 |
+
"precision": 0.5639066975855954,
|
| 33 |
+
"recall": 0.7033004423273223,
|
| 34 |
+
"f1": 0.6259368612309789,
|
| 35 |
+
"f2": 0.6701682715689136,
|
| 36 |
+
"auc": 0.8407467041985305
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"fold": 5,
|
| 40 |
+
"precision": 0.7484357589783149,
|
| 41 |
+
"recall": 0.8668718356001192,
|
| 42 |
+
"f1": 0.803311867525299,
|
| 43 |
+
"f2": 0.8402779114301661,
|
| 44 |
+
"auc": 0.9082845804487562
|
| 45 |
+
}
|
| 46 |
+
],
|
| 47 |
+
"test_metrics": {
|
| 48 |
+
"f1": 0.685783089546914,
|
| 49 |
+
"f2": 0.7235752465557479,
|
| 50 |
+
"auc": 0.8709626679626591,
|
| 51 |
+
"confusion_matrix": [
|
| 52 |
+
[
|
| 53 |
+
20330,
|
| 54 |
+
4499
|
| 55 |
+
],
|
| 56 |
+
[
|
| 57 |
+
2547,
|
| 58 |
+
7689
|
| 59 |
+
]
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
"feature_importance": {
|
| 63 |
+
"precipitation_lag_1h": 0.37103009181018626,
|
| 64 |
+
"hour_cos": 0.11580750850535977,
|
| 65 |
+
"hour_sin": 0.07037540088518035,
|
| 66 |
+
"pressure_change_3h": 0.047174819845785594,
|
| 67 |
+
"wind_v": 0.041368375623903254,
|
| 68 |
+
"dew_point_c": 0.04043788445078633,
|
| 69 |
+
"dew_point_depression": 0.039252614064941044,
|
| 70 |
+
"temperature_c": 0.037485880642672,
|
| 71 |
+
"pressure_hpa": 0.0373177439776536,
|
| 72 |
+
"cloud_cover_pct": 0.03461861797659651,
|
| 73 |
+
"wind_u": 0.03413653721205715,
|
| 74 |
+
"humidity_pct": 0.033652723237235005,
|
| 75 |
+
"wind_direction_deg": 0.03199287061898489,
|
| 76 |
+
"wind_speed_kmh": 0.026890889422381343,
|
| 77 |
+
"month_cos": 0.013067682406623812,
|
| 78 |
+
"month_sin": 0.01302571181506734,
|
| 79 |
+
"elevation_m": 0.012364647504585904,
|
| 80 |
+
"cape_jkg": 0.0
|
| 81 |
+
}
|
| 82 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "microclimate-x"
|
| 3 |
+
version = "1.0.0"
|
| 4 |
+
description = "Hybrid Microclimate Risk Engine for Complex Terrain — UKM FYP."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.9"
|
| 7 |
+
license = { text = "MIT" }
|
| 8 |
+
authors = [{ name = "Li Zhenyue (KyoukoLi)" }]
|
| 9 |
+
|
| 10 |
+
[tool.ruff]
|
| 11 |
+
line-length = 110
|
| 12 |
+
target-version = "py39"
|
| 13 |
+
extend-exclude = [".venv", "data", "models", "figures", "htmlcov"]
|
| 14 |
+
|
| 15 |
+
[tool.ruff.lint]
|
| 16 |
+
# E: pycodestyle errors F: pyflakes I: isort UP: pyupgrade
|
| 17 |
+
# B: flake8-bugbear SIM: simplify RUF: ruff-native
|
| 18 |
+
# N: pep8-naming ANN: annotations
|
| 19 |
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
| 20 |
+
ignore = [
|
| 21 |
+
"E501", # line-too-long — config.py has long comment citations on purpose
|
| 22 |
+
"B008", # function calls in argument defaults — FastAPI Query(...) idiom
|
| 23 |
+
"UP007", # `X | Y` syntax — pydantic on py3.9 needs eval_type_backport anyway
|
| 24 |
+
"UP045", # same
|
| 25 |
+
"RUF001", # ambiguous unicode chars — bilingual EN/ZH strings legitimately use them
|
| 26 |
+
"RUF002", # docstring ambiguous unicode chars
|
| 27 |
+
"RUF003", # comment ambiguous unicode chars
|
| 28 |
+
"SIM117", # combined `with` — readability over compactness here
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
[tool.ruff.lint.per-file-ignores]
|
| 32 |
+
"tests/*" = ["E402", "F401"] # tests sometimes reorder imports for env setup
|
| 33 |
+
"scripts/*" = ["E402"] # scripts often set up paths before importing
|
| 34 |
+
|
| 35 |
+
[tool.ruff.format]
|
| 36 |
+
quote-style = "double"
|
| 37 |
+
indent-style = "space"
|
| 38 |
+
|
| 39 |
+
[tool.pytest.ini_options]
|
| 40 |
+
testpaths = ["tests"]
|
| 41 |
+
addopts = "-ra --strict-markers"
|
| 42 |
+
asyncio_mode = "auto"
|
| 43 |
+
filterwarnings = [
|
| 44 |
+
"ignore::DeprecationWarning:pydantic.*",
|
| 45 |
+
]
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
|
| 3 |
+
# Test infrastructure
|
| 4 |
+
pytest>=7.4.0
|
| 5 |
+
pytest-asyncio>=0.23.0
|
| 6 |
+
pytest-cov>=4.1.0
|
| 7 |
+
respx>=0.23.0
|
| 8 |
+
httpx>=0.27.0
|
| 9 |
+
|
| 10 |
+
# Linter / formatter — keep version pinned so CI is reproducible
|
| 11 |
+
ruff>=0.6.0
|
| 12 |
+
|
| 13 |
+
# Plotting (for scripts/4_evaluate_model.py)
|
| 14 |
+
matplotlib>=3.8.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend
|
| 2 |
+
fastapi>=0.110.0
|
| 3 |
+
uvicorn[standard]>=0.27.0
|
| 4 |
+
pydantic>=2.5.0
|
| 5 |
+
httpx>=0.27.0
|
| 6 |
+
tenacity>=8.2.3
|
| 7 |
+
eval_type_backport>=0.2.0 # lets pydantic resolve `X | Y` on Python 3.9
|
| 8 |
+
|
| 9 |
+
# Data & ML
|
| 10 |
+
pandas>=2.1.0
|
| 11 |
+
numpy>=1.26.0
|
| 12 |
+
scikit-learn>=1.4.0
|
| 13 |
+
joblib>=1.3.0
|
| 14 |
+
|
| 15 |
+
# Testing
|
| 16 |
+
pytest>=7.4.0
|
| 17 |
+
pytest-asyncio>=0.23.0
|
scripts/1_download_dataset.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Step 1 / Dataset Download
|
| 3 |
+
==========================
|
| 4 |
+
Downloads hourly historical weather data from Open-Meteo Historical Weather API
|
| 5 |
+
(backed by ECMWF ERA5 reanalysis) for 5 Malaysian mountain locations,
|
| 6 |
+
plus elevation data from Open-Topo-Data (SRTM DEM).
|
| 7 |
+
|
| 8 |
+
Parameters as confirmed with supervisor:
|
| 9 |
+
- Location: Malaysia (mountain regions)
|
| 10 |
+
- Time range: 2020-01-01 to 2023-12-31
|
| 11 |
+
- Variables: temperature_2m, relative_humidity_2m, precipitation,
|
| 12 |
+
wind_speed_10m, wind_direction_10m, surface_pressure
|
| 13 |
+
|
| 14 |
+
Output: data/raw_<site>.csv (one file per location)
|
| 15 |
+
|
| 16 |
+
Run: python scripts/1_download_dataset.py
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import sys
|
| 21 |
+
import time
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import httpx
|
| 25 |
+
import pandas as pd
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 28 |
+
DATA_DIR = ROOT / "data"
|
| 29 |
+
DATA_DIR.mkdir(exist_ok=True)
|
| 30 |
+
|
| 31 |
+
# Malaysian mountain locations (lat, lon, name).
|
| 32 |
+
# Chosen to span Peninsular Malaysia + Borneo and cover diverse terrain:
|
| 33 |
+
# valleys, highlands, and one extreme peak for OOD reference.
|
| 34 |
+
SITES = [
|
| 35 |
+
("genting_highlands", 3.4225, 101.7935),
|
| 36 |
+
("cameron_highlands", 4.4694, 101.3776),
|
| 37 |
+
("frasers_hill", 3.7256, 101.7378),
|
| 38 |
+
("klang_valley", 3.0738, 101.5183),
|
| 39 |
+
("mt_kinabalu_base", 6.0535, 116.5586),
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
START_DATE = "2020-01-01"
|
| 43 |
+
END_DATE = "2023-12-31"
|
| 44 |
+
|
| 45 |
+
HOURLY_VARS = [
|
| 46 |
+
"temperature_2m",
|
| 47 |
+
"relative_humidity_2m",
|
| 48 |
+
"precipitation",
|
| 49 |
+
"wind_speed_10m",
|
| 50 |
+
"wind_direction_10m",
|
| 51 |
+
"surface_pressure",
|
| 52 |
+
"dew_point_2m",
|
| 53 |
+
"cloud_cover",
|
| 54 |
+
"cape",
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
OPEN_METEO_URL = "https://archive-api.open-meteo.com/v1/archive"
|
| 58 |
+
OPEN_TOPO_URL = "https://api.opentopodata.org/v1/srtm30m"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def fetch_elevation(lat: float, lon: float) -> float:
|
| 62 |
+
"""Fetch ground elevation in meters from Open-Topo-Data (SRTM 30m)."""
|
| 63 |
+
resp = httpx.get(
|
| 64 |
+
OPEN_TOPO_URL,
|
| 65 |
+
params={"locations": f"{lat},{lon}"},
|
| 66 |
+
timeout=30.0,
|
| 67 |
+
)
|
| 68 |
+
resp.raise_for_status()
|
| 69 |
+
data = resp.json()
|
| 70 |
+
return float(data["results"][0]["elevation"])
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def fetch_hourly(lat: float, lon: float) -> pd.DataFrame:
|
| 74 |
+
"""Fetch hourly historical weather data for the configured date range."""
|
| 75 |
+
resp = httpx.get(
|
| 76 |
+
OPEN_METEO_URL,
|
| 77 |
+
params={
|
| 78 |
+
"latitude": lat,
|
| 79 |
+
"longitude": lon,
|
| 80 |
+
"start_date": START_DATE,
|
| 81 |
+
"end_date": END_DATE,
|
| 82 |
+
"hourly": ",".join(HOURLY_VARS),
|
| 83 |
+
"timezone": "Asia/Kuala_Lumpur",
|
| 84 |
+
"windspeed_unit": "kmh",
|
| 85 |
+
},
|
| 86 |
+
timeout=120.0,
|
| 87 |
+
)
|
| 88 |
+
resp.raise_for_status()
|
| 89 |
+
payload = resp.json()
|
| 90 |
+
df = pd.DataFrame(payload["hourly"])
|
| 91 |
+
df["time"] = pd.to_datetime(df["time"])
|
| 92 |
+
return df
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def download_site(name: str, lat: float, lon: float) -> Path:
|
| 96 |
+
out = DATA_DIR / f"raw_{name}.csv"
|
| 97 |
+
if out.exists():
|
| 98 |
+
print(f" [skip] {name}: already exists at {out}")
|
| 99 |
+
return out
|
| 100 |
+
|
| 101 |
+
print(f" [elev] fetching elevation for {name} ({lat}, {lon})…")
|
| 102 |
+
elev = fetch_elevation(lat, lon)
|
| 103 |
+
print(f" elevation = {elev:.1f} m")
|
| 104 |
+
|
| 105 |
+
print(f" [hourly] fetching weather time-series for {name}…")
|
| 106 |
+
df = fetch_hourly(lat, lon)
|
| 107 |
+
|
| 108 |
+
df.insert(0, "site", name)
|
| 109 |
+
df.insert(1, "latitude", lat)
|
| 110 |
+
df.insert(2, "longitude", lon)
|
| 111 |
+
df.insert(3, "elevation_m", elev)
|
| 112 |
+
|
| 113 |
+
df.to_csv(out, index=False)
|
| 114 |
+
print(f" [save] {len(df):>6} rows → {out}")
|
| 115 |
+
return out
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main() -> int:
|
| 119 |
+
print(f"Downloading {len(SITES)} sites from Open-Meteo + Open-Topo-Data…")
|
| 120 |
+
print(f" date range: {START_DATE} → {END_DATE}")
|
| 121 |
+
print(f" variables: {', '.join(HOURLY_VARS)}\n")
|
| 122 |
+
|
| 123 |
+
for name, lat, lon in SITES:
|
| 124 |
+
print(f"[ {name} ]")
|
| 125 |
+
try:
|
| 126 |
+
download_site(name, lat, lon)
|
| 127 |
+
except httpx.HTTPError as exc:
|
| 128 |
+
print(f" [error] {exc}", file=sys.stderr)
|
| 129 |
+
return 1
|
| 130 |
+
time.sleep(1.0) # be polite to the public APIs
|
| 131 |
+
|
| 132 |
+
print("\nDone. Next step:")
|
| 133 |
+
print(" python scripts/2_preprocess.py")
|
| 134 |
+
return 0
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
raise SystemExit(main())
|
scripts/1b_synth_dataset.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Step 1B / Synthetic Dataset Generator (offline fallback)
|
| 3 |
+
==========================================================
|
| 4 |
+
|
| 5 |
+
When the real Open-Meteo / Open-Topo-Data APIs are unreachable (e.g. behind
|
| 6 |
+
a restrictive corporate proxy or in an offline classroom), this script
|
| 7 |
+
generates a physically-plausible synthetic dataset with the *exact same
|
| 8 |
+
schema* as scripts/1_download_dataset.py.
|
| 9 |
+
|
| 10 |
+
This lets the end-to-end pipeline (preprocess + train + serve) be
|
| 11 |
+
validated without network access. To switch back to real data later,
|
| 12 |
+
delete data/raw_*.csv and run scripts/1_download_dataset.py.
|
| 13 |
+
|
| 14 |
+
The synthetic generator encodes:
|
| 15 |
+
* Standard atmosphere lapse rate (≈ -6.5 °C / km)
|
| 16 |
+
* Hydrostatic pressure decay with altitude (~ -12 hPa / 100 m)
|
| 17 |
+
* Tropical diurnal temperature cycle (cooler at night, warmer mid-afternoon)
|
| 18 |
+
* Malaysia's bimodal monsoon precipitation seasonality (Apr-May, Oct-Nov peaks)
|
| 19 |
+
* Humidity inversely correlated with temperature, plus monsoon boost
|
| 20 |
+
* Heavy-tailed precipitation distribution (most hours dry, rare extremes)
|
| 21 |
+
* CAPE rising with humid afternoon convection
|
| 22 |
+
* Dew-point depression that shrinks toward saturation as humidity rises
|
| 23 |
+
|
| 24 |
+
This is *NOT* a substitute for real ERA5 reanalysis data in the final
|
| 25 |
+
thesis — its purpose is purely to exercise the ML pipeline end-to-end.
|
| 26 |
+
|
| 27 |
+
Run: python scripts/1b_synth_dataset.py
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
import pandas as pd
|
| 35 |
+
|
| 36 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 37 |
+
DATA_DIR = ROOT / "data"
|
| 38 |
+
DATA_DIR.mkdir(exist_ok=True)
|
| 39 |
+
|
| 40 |
+
# Site (name, lat, lon, approx elevation_m) — same as scripts/1_download_dataset.py
|
| 41 |
+
SITES = [
|
| 42 |
+
("genting_highlands", 3.4225, 101.7935, 1742.0),
|
| 43 |
+
("cameron_highlands", 4.4694, 101.3776, 1500.0),
|
| 44 |
+
("frasers_hill", 3.7256, 101.7378, 1300.0),
|
| 45 |
+
("klang_valley", 3.0738, 101.5183, 120.0),
|
| 46 |
+
("mt_kinabalu_base", 6.0535, 116.5586, 1800.0),
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
START = pd.Timestamp("2020-01-01 00:00:00")
|
| 50 |
+
END = pd.Timestamp("2023-12-31 23:00:00")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def generate_site(name: str, lat: float, lon: float, elev: float,
|
| 54 |
+
rng: np.random.Generator) -> pd.DataFrame:
|
| 55 |
+
"""Generate hourly synthetic weather time-series for a single site."""
|
| 56 |
+
timestamps = pd.date_range(START, END, freq="h")
|
| 57 |
+
n = len(timestamps)
|
| 58 |
+
|
| 59 |
+
hour = timestamps.hour.to_numpy()
|
| 60 |
+
doy = timestamps.dayofyear.to_numpy()
|
| 61 |
+
|
| 62 |
+
# Temperature: tropical baseline 27 °C at sea level, lapse rate to altitude,
|
| 63 |
+
# plus diurnal swing (±4 °C) and seasonal (±1.5 °C).
|
| 64 |
+
sea_level_temp = 27.0
|
| 65 |
+
lapse = -6.5 * (elev / 1000.0)
|
| 66 |
+
diurnal = -4.0 * np.cos(2 * np.pi * (hour - 3) / 24.0)
|
| 67 |
+
seasonal = 1.5 * np.cos(2 * np.pi * (doy - 60) / 365.25)
|
| 68 |
+
noise_T = rng.normal(0.0, 1.2, n)
|
| 69 |
+
temperature = sea_level_temp + lapse + diurnal + seasonal + noise_T
|
| 70 |
+
|
| 71 |
+
# Pressure: hydrostatic decay, plus 3-hourly random walk for synoptic systems.
|
| 72 |
+
sea_level_p = 1010.0
|
| 73 |
+
p_alt = sea_level_p - 12.0 * (elev / 100.0)
|
| 74 |
+
pressure = p_alt + rng.normal(0.0, 0.8, n)
|
| 75 |
+
pressure = pd.Series(pressure).rolling(3, min_periods=1).mean().to_numpy()
|
| 76 |
+
|
| 77 |
+
# Monsoon-driven rainy season: Apr-May and Oct-Nov are peak rainfall in
|
| 78 |
+
# Peninsular Malaysia; weight precipitation probability accordingly.
|
| 79 |
+
monsoon_weight = (
|
| 80 |
+
0.5 + 0.5 * np.cos(2 * np.pi * (doy - 305) / 365.25) # NE monsoon
|
| 81 |
+
+ 0.4 * np.exp(-0.5 * ((doy - 135) / 25.0) ** 2) # SW pre-monsoon
|
| 82 |
+
+ 0.4 * np.exp(-0.5 * ((doy - 305) / 30.0) ** 2)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Humidity: anti-correlated with diurnal temperature; lifted by monsoon.
|
| 86 |
+
humidity_base = 78.0 + 4.0 * monsoon_weight
|
| 87 |
+
humidity = humidity_base - 0.9 * diurnal + rng.normal(0.0, 5.0, n)
|
| 88 |
+
humidity = np.clip(humidity, 30.0, 100.0)
|
| 89 |
+
|
| 90 |
+
# CAPE: builds with afternoon humid heat — peaks 13-16h on humid days.
|
| 91 |
+
afternoon = np.exp(-0.5 * ((hour - 14.5) / 2.5) ** 2)
|
| 92 |
+
cape = (
|
| 93 |
+
afternoon * (humidity - 60.0) * 25.0 * monsoon_weight
|
| 94 |
+
+ rng.normal(0.0, 80.0, n)
|
| 95 |
+
)
|
| 96 |
+
cape = np.clip(cape, 0.0, 4500.0)
|
| 97 |
+
|
| 98 |
+
# Cloud cover: tied to humidity & monsoon.
|
| 99 |
+
cloud = np.clip(
|
| 100 |
+
0.55 * humidity + 25.0 * monsoon_weight + rng.normal(0.0, 8.0, n),
|
| 101 |
+
0.0, 100.0,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Dew point depression shrinks at high humidity (saturation).
|
| 105 |
+
dew_dep = np.clip(36.0 - 0.32 * humidity + rng.normal(0.0, 1.4, n), 0.1, 30.0)
|
| 106 |
+
dew_point = temperature - dew_dep
|
| 107 |
+
|
| 108 |
+
# Wind: weak in tropics; daytime sea breeze in lowlands, slightly more wind aloft.
|
| 109 |
+
wind_base = 5.0 + 0.0025 * elev
|
| 110 |
+
wind_speed = np.clip(
|
| 111 |
+
wind_base + 2.5 * afternoon + np.abs(rng.normal(0.0, 2.5, n)),
|
| 112 |
+
0.0, 60.0,
|
| 113 |
+
)
|
| 114 |
+
# Direction: slow random walk so consecutive hours have correlated direction.
|
| 115 |
+
dir_steps = rng.normal(0.0, 25.0, n).cumsum()
|
| 116 |
+
wind_dir = (dir_steps % 360.0 + 180.0 * monsoon_weight) % 360.0
|
| 117 |
+
|
| 118 |
+
# Precipitation: zero-inflated; probability rises with humidity × monsoon × CAPE.
|
| 119 |
+
rain_prob = (
|
| 120 |
+
0.04
|
| 121 |
+
+ 0.55 * monsoon_weight * (humidity > 80).astype(float)
|
| 122 |
+
+ 0.0001 * cape
|
| 123 |
+
+ 0.25 * afternoon * (humidity > 85).astype(float)
|
| 124 |
+
)
|
| 125 |
+
rain_prob = np.clip(rain_prob, 0.0, 0.85)
|
| 126 |
+
rain_event = rng.random(n) < rain_prob
|
| 127 |
+
# When it rains, amount follows an exponential distribution (heavy-tailed).
|
| 128 |
+
rain_amount = np.where(
|
| 129 |
+
rain_event,
|
| 130 |
+
rng.exponential(scale=2.8, size=n), # mm/h
|
| 131 |
+
0.0,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
df = pd.DataFrame({
|
| 135 |
+
"site": name,
|
| 136 |
+
"latitude": lat,
|
| 137 |
+
"longitude": lon,
|
| 138 |
+
"elevation_m": elev,
|
| 139 |
+
"time": timestamps,
|
| 140 |
+
"temperature_2m": np.round(temperature, 2),
|
| 141 |
+
"relative_humidity_2m": np.round(humidity, 1),
|
| 142 |
+
"precipitation": np.round(rain_amount, 2),
|
| 143 |
+
"wind_speed_10m": np.round(wind_speed, 2),
|
| 144 |
+
"wind_direction_10m": np.round(wind_dir, 1),
|
| 145 |
+
"surface_pressure": np.round(pressure, 1),
|
| 146 |
+
"dew_point_2m": np.round(dew_point, 2),
|
| 147 |
+
"cloud_cover": np.round(cloud, 1),
|
| 148 |
+
"cape": np.round(cape, 0),
|
| 149 |
+
})
|
| 150 |
+
return df
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def main() -> int:
|
| 154 |
+
rng = np.random.default_rng(seed=42)
|
| 155 |
+
print(f"Generating SYNTHETIC dataset for {len(SITES)} sites…")
|
| 156 |
+
print(f" date range: {START.date()} → {END.date()}\n")
|
| 157 |
+
for name, lat, lon, elev in SITES:
|
| 158 |
+
out = DATA_DIR / f"raw_{name}.csv"
|
| 159 |
+
df = generate_site(name, lat, lon, elev, rng)
|
| 160 |
+
df.to_csv(out, index=False)
|
| 161 |
+
rain_pct = (df["precipitation"] > 0.1).mean() * 100.0
|
| 162 |
+
print(f" [{name:<18}] {len(df):>6} rows rain-hours={rain_pct:4.1f}% → {out.name}")
|
| 163 |
+
print("\nDone (synthetic). Next: python scripts/2_preprocess.py")
|
| 164 |
+
return 0
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
raise SystemExit(main())
|
scripts/2_preprocess.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Step 2 / Preprocessing & Feature Engineering
|
| 3 |
+
=============================================
|
| 4 |
+
Reads raw per-site CSVs, engineers ML-ready features, and derives the binary
|
| 5 |
+
target `is_rain_event` from the raw `precipitation` column.
|
| 6 |
+
|
| 7 |
+
Pipeline:
|
| 8 |
+
1. Load all data/raw_*.csv and concatenate.
|
| 9 |
+
2. Drop rows with NaN in critical fields.
|
| 10 |
+
3. Engineer features:
|
| 11 |
+
- wind_u, wind_v (decompose circular wind direction)
|
| 12 |
+
- hour_sin, hour_cos (cyclic time encoding)
|
| 13 |
+
- month_sin, month_cos (captures Malaysia's monsoon seasonality)
|
| 14 |
+
- precipitation_lag_1h (autocorrelation signal)
|
| 15 |
+
- dew_point_depression (T - T_dew, saturation proxy)
|
| 16 |
+
- pressure_change_3h (storm-approaching signal)
|
| 17 |
+
4. Derive target:
|
| 18 |
+
is_rain_event(t) = 1 iff precipitation(t+1h) > RAIN_THRESHOLD_MM (WMO trace)
|
| 19 |
+
5. Save data/processed.csv
|
| 20 |
+
|
| 21 |
+
Run: python scripts/2_preprocess.py
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pandas as pd
|
| 29 |
+
|
| 30 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 31 |
+
DATA_DIR = ROOT / "data"
|
| 32 |
+
|
| 33 |
+
# WMO definition of "trace precipitation": >= 0.1 mm in an hour.
|
| 34 |
+
RAIN_THRESHOLD_MM = 0.1
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def engineer_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 38 |
+
"""Add domain-informed derived features. Operates per site to avoid
|
| 39 |
+
cross-site leakage in lag/shift operations."""
|
| 40 |
+
out_frames: list[pd.DataFrame] = []
|
| 41 |
+
for _, g in df.groupby("site", sort=False):
|
| 42 |
+
g = g.sort_values("time").reset_index(drop=True).copy()
|
| 43 |
+
|
| 44 |
+
# Wind: decompose into u/v components. Raw degrees are circular and
|
| 45 |
+
# mathematically misleading to tree models (0° vs 360° look "far").
|
| 46 |
+
rad = np.deg2rad(g["wind_direction_10m"])
|
| 47 |
+
g["wind_u"] = g["wind_speed_10m"] * np.sin(rad)
|
| 48 |
+
g["wind_v"] = g["wind_speed_10m"] * np.cos(rad)
|
| 49 |
+
|
| 50 |
+
# Cyclic time encoding (avoids the 23→0 hour discontinuity).
|
| 51 |
+
h = g["time"].dt.hour
|
| 52 |
+
m = g["time"].dt.month
|
| 53 |
+
g["hour_sin"] = np.sin(2 * np.pi * h / 24)
|
| 54 |
+
g["hour_cos"] = np.cos(2 * np.pi * h / 24)
|
| 55 |
+
g["month_sin"] = np.sin(2 * np.pi * m / 12)
|
| 56 |
+
g["month_cos"] = np.cos(2 * np.pi * m / 12)
|
| 57 |
+
|
| 58 |
+
# Lag / tendency features (storm precursors).
|
| 59 |
+
g["precipitation_lag_1h"] = g["precipitation"].shift(1).fillna(0.0)
|
| 60 |
+
g["pressure_change_3h"] = g["surface_pressure"] - g["surface_pressure"].shift(3)
|
| 61 |
+
g["pressure_change_3h"] = g["pressure_change_3h"].fillna(0.0)
|
| 62 |
+
|
| 63 |
+
# Dew point depression: small value = atmosphere near saturation.
|
| 64 |
+
g["dew_point_depression"] = g["temperature_2m"] - g["dew_point_2m"]
|
| 65 |
+
|
| 66 |
+
# === Target: predict whether rain occurs in the NEXT hour ===
|
| 67 |
+
# Using shift(-1) explicitly to avoid temporal data leakage:
|
| 68 |
+
# features at time t pair with the rainfall outcome at t+1h.
|
| 69 |
+
next_hour_precip = g["precipitation"].shift(-1)
|
| 70 |
+
g["is_rain_event"] = (next_hour_precip > RAIN_THRESHOLD_MM).astype("Int64")
|
| 71 |
+
|
| 72 |
+
# Drop the final row (no t+1h label) and any all-NaN rows.
|
| 73 |
+
g = g.iloc[:-1].copy()
|
| 74 |
+
out_frames.append(g)
|
| 75 |
+
|
| 76 |
+
return pd.concat(out_frames, ignore_index=True)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def main() -> int:
|
| 80 |
+
raw_files = sorted(DATA_DIR.glob("raw_*.csv"))
|
| 81 |
+
if not raw_files:
|
| 82 |
+
print("ERROR: no data/raw_*.csv found. Run scripts/1_download_dataset.py first.")
|
| 83 |
+
return 1
|
| 84 |
+
|
| 85 |
+
print(f"Loading {len(raw_files)} raw site files…")
|
| 86 |
+
dfs = [pd.read_csv(p, parse_dates=["time"]) for p in raw_files]
|
| 87 |
+
df = pd.concat(dfs, ignore_index=True)
|
| 88 |
+
print(f" rows total: {len(df):,}")
|
| 89 |
+
|
| 90 |
+
# Standardised column names (presentation-friendly + matches design doc).
|
| 91 |
+
df = df.rename(columns={
|
| 92 |
+
"temperature_2m": "temperature_c",
|
| 93 |
+
"relative_humidity_2m": "humidity_pct",
|
| 94 |
+
"wind_speed_10m": "wind_speed_kmh",
|
| 95 |
+
"wind_direction_10m": "wind_direction_deg",
|
| 96 |
+
"surface_pressure": "pressure_hpa",
|
| 97 |
+
"dew_point_2m": "dew_point_c",
|
| 98 |
+
"cloud_cover": "cloud_cover_pct",
|
| 99 |
+
"cape": "cape_jkg",
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
# Restore originals expected by engineer_features (it uses raw names for clarity).
|
| 103 |
+
df = df.rename(columns={
|
| 104 |
+
"temperature_c": "temperature_2m",
|
| 105 |
+
"humidity_pct": "relative_humidity_2m",
|
| 106 |
+
"wind_speed_kmh": "wind_speed_10m",
|
| 107 |
+
"wind_direction_deg": "wind_direction_10m",
|
| 108 |
+
"pressure_hpa": "surface_pressure",
|
| 109 |
+
"dew_point_c": "dew_point_2m",
|
| 110 |
+
"cloud_cover_pct": "cloud_cover",
|
| 111 |
+
"cape_jkg": "cape",
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
before = len(df)
|
| 115 |
+
df = df.dropna(subset=[
|
| 116 |
+
"temperature_2m", "relative_humidity_2m", "precipitation",
|
| 117 |
+
"wind_speed_10m", "wind_direction_10m", "surface_pressure",
|
| 118 |
+
])
|
| 119 |
+
print(f" rows after dropna: {len(df):,} (dropped {before - len(df):,})")
|
| 120 |
+
|
| 121 |
+
print("Engineering features per site…")
|
| 122 |
+
df = engineer_features(df)
|
| 123 |
+
|
| 124 |
+
# Final renaming to the design-doc-friendly column names that the
|
| 125 |
+
# downstream training script and README expect.
|
| 126 |
+
df = df.rename(columns={
|
| 127 |
+
"temperature_2m": "temperature_c",
|
| 128 |
+
"relative_humidity_2m": "humidity_pct",
|
| 129 |
+
"wind_speed_10m": "wind_speed_kmh",
|
| 130 |
+
"wind_direction_10m": "wind_direction_deg",
|
| 131 |
+
"surface_pressure": "pressure_hpa",
|
| 132 |
+
"dew_point_2m": "dew_point_c",
|
| 133 |
+
"cloud_cover": "cloud_cover_pct",
|
| 134 |
+
"cape": "cape_jkg",
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
# Drop the one terminal row per site that lacks the t+1h label.
|
| 138 |
+
df = df.dropna(subset=["is_rain_event"]).copy()
|
| 139 |
+
df["is_rain_event"] = df["is_rain_event"].astype(int)
|
| 140 |
+
|
| 141 |
+
out = DATA_DIR / "processed.csv"
|
| 142 |
+
df.to_csv(out, index=False)
|
| 143 |
+
|
| 144 |
+
print("\n=== Processed dataset summary ===")
|
| 145 |
+
print(f" total samples : {len(df):,}")
|
| 146 |
+
print(f" sites : {df['site'].nunique()}")
|
| 147 |
+
print(f" date range : {df['time'].min()} → {df['time'].max()}")
|
| 148 |
+
print(f" class balance (Y=1) : {df['is_rain_event'].mean():.1%}")
|
| 149 |
+
print(f" saved to : {out}")
|
| 150 |
+
print("\nFirst rows of (selected cols):")
|
| 151 |
+
cols = ["site", "time", "elevation_m", "temperature_c", "humidity_pct",
|
| 152 |
+
"wind_speed_kmh", "pressure_hpa", "is_rain_event"]
|
| 153 |
+
print(df[cols].head(10).to_string(index=False))
|
| 154 |
+
|
| 155 |
+
print("\nNext step: python scripts/3_train_model.py")
|
| 156 |
+
return 0
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == "__main__":
|
| 160 |
+
raise SystemExit(main())
|
scripts/3_train_model.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Step 3 / Random Forest Training
|
| 3 |
+
================================
|
| 4 |
+
Trains a Random Forest classifier on the processed dataset using:
|
| 5 |
+
- Time-based CV (NOT random split — would leak temporal autocorrelation)
|
| 6 |
+
- class_weight='balanced' (rain is the minority class)
|
| 7 |
+
- Hold-out test = last 20 % of the time-ordered dataset
|
| 8 |
+
|
| 9 |
+
Outputs:
|
| 10 |
+
models/rf_model.pkl — fitted estimator
|
| 11 |
+
models/feature_columns.json — exact feature order used at train time
|
| 12 |
+
models/training_report.json — metrics + feature importance + meta
|
| 13 |
+
|
| 14 |
+
Run: python scripts/3_train_model.py
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import joblib
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 25 |
+
from sklearn.metrics import (
|
| 26 |
+
classification_report,
|
| 27 |
+
confusion_matrix,
|
| 28 |
+
f1_score,
|
| 29 |
+
fbeta_score,
|
| 30 |
+
precision_recall_fscore_support,
|
| 31 |
+
roc_auc_score,
|
| 32 |
+
)
|
| 33 |
+
from sklearn.model_selection import TimeSeriesSplit
|
| 34 |
+
|
| 35 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 36 |
+
DATA_DIR = ROOT / "data"
|
| 37 |
+
MODEL_DIR = ROOT / "models"
|
| 38 |
+
MODEL_DIR.mkdir(exist_ok=True)
|
| 39 |
+
|
| 40 |
+
# Features fed to the model (X). Order matters — saved alongside the model.
|
| 41 |
+
FEATURE_COLUMNS: list[str] = [
|
| 42 |
+
"elevation_m",
|
| 43 |
+
"temperature_c",
|
| 44 |
+
"humidity_pct",
|
| 45 |
+
"wind_speed_kmh",
|
| 46 |
+
"wind_direction_deg", # kept for interpretability comparison
|
| 47 |
+
"wind_u", "wind_v", # mathematically correct circular decomposition
|
| 48 |
+
"pressure_hpa",
|
| 49 |
+
"pressure_change_3h",
|
| 50 |
+
"dew_point_c",
|
| 51 |
+
"dew_point_depression",
|
| 52 |
+
"cloud_cover_pct",
|
| 53 |
+
"cape_jkg",
|
| 54 |
+
"precipitation_lag_1h",
|
| 55 |
+
"hour_sin", "hour_cos",
|
| 56 |
+
"month_sin", "month_cos",
|
| 57 |
+
]
|
| 58 |
+
TARGET = "is_rain_event"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def load_dataset() -> pd.DataFrame:
|
| 62 |
+
p = DATA_DIR / "processed.csv"
|
| 63 |
+
if not p.exists():
|
| 64 |
+
raise SystemExit("ERROR: data/processed.csv not found. "
|
| 65 |
+
"Run scripts/2_preprocess.py first.")
|
| 66 |
+
df = pd.read_csv(p, parse_dates=["time"])
|
| 67 |
+
df = df.sort_values(["site", "time"]).reset_index(drop=True)
|
| 68 |
+
return df
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def time_based_split(df: pd.DataFrame, test_frac: float = 0.2) -> tuple[pd.DataFrame, pd.DataFrame]:
|
| 72 |
+
"""Last `test_frac` of the time-ordered data per site is held out."""
|
| 73 |
+
train_parts, test_parts = [], []
|
| 74 |
+
for _, g in df.groupby("site", sort=False):
|
| 75 |
+
cut = int(len(g) * (1.0 - test_frac))
|
| 76 |
+
train_parts.append(g.iloc[:cut])
|
| 77 |
+
test_parts.append(g.iloc[cut:])
|
| 78 |
+
return pd.concat(train_parts, ignore_index=True), pd.concat(test_parts, ignore_index=True)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def crossval_score(X: np.ndarray, y: np.ndarray, n_splits: int = 5) -> list[dict]:
|
| 82 |
+
"""TimeSeriesSplit gives a fair temporal-CV estimate."""
|
| 83 |
+
tscv = TimeSeriesSplit(n_splits=n_splits)
|
| 84 |
+
fold_metrics: list[dict] = []
|
| 85 |
+
for fold, (tr, va) in enumerate(tscv.split(X), start=1):
|
| 86 |
+
model = RandomForestClassifier(
|
| 87 |
+
n_estimators=200,
|
| 88 |
+
max_depth=15,
|
| 89 |
+
min_samples_leaf=20,
|
| 90 |
+
class_weight="balanced",
|
| 91 |
+
n_jobs=-1,
|
| 92 |
+
random_state=42,
|
| 93 |
+
)
|
| 94 |
+
model.fit(X[tr], y[tr])
|
| 95 |
+
proba = model.predict_proba(X[va])[:, 1]
|
| 96 |
+
pred = (proba >= 0.5).astype(int)
|
| 97 |
+
p, r, f1, _ = precision_recall_fscore_support(y[va], pred, average="binary", zero_division=0)
|
| 98 |
+
try:
|
| 99 |
+
auc = roc_auc_score(y[va], proba)
|
| 100 |
+
except ValueError:
|
| 101 |
+
auc = float("nan")
|
| 102 |
+
f2 = fbeta_score(y[va], pred, beta=2.0, zero_division=0)
|
| 103 |
+
print(f" fold {fold}: P={p:.3f} R={r:.3f} F1={f1:.3f} F2={f2:.3f} AUC={auc:.3f}")
|
| 104 |
+
fold_metrics.append({"fold": fold, "precision": p, "recall": r,
|
| 105 |
+
"f1": f1, "f2": f2, "auc": auc})
|
| 106 |
+
return fold_metrics
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def main() -> int:
|
| 110 |
+
print("Loading processed dataset…")
|
| 111 |
+
df = load_dataset()
|
| 112 |
+
print(f" rows: {len(df):,} features: {len(FEATURE_COLUMNS)}")
|
| 113 |
+
print(f" class balance (Y=1): {df[TARGET].mean():.1%}")
|
| 114 |
+
|
| 115 |
+
print("\nTime-based train/test split (last 20% per site held out)…")
|
| 116 |
+
train_df, test_df = time_based_split(df, test_frac=0.20)
|
| 117 |
+
print(f" train: {len(train_df):,} test: {len(test_df):,}")
|
| 118 |
+
|
| 119 |
+
X_train = train_df[FEATURE_COLUMNS].to_numpy()
|
| 120 |
+
y_train = train_df[TARGET].to_numpy()
|
| 121 |
+
X_test = test_df[FEATURE_COLUMNS].to_numpy()
|
| 122 |
+
y_test = test_df[TARGET].to_numpy()
|
| 123 |
+
|
| 124 |
+
print("\nTime-series cross-validation on training fold (5 splits)…")
|
| 125 |
+
fold_metrics = crossval_score(X_train, y_train, n_splits=5)
|
| 126 |
+
|
| 127 |
+
print("\nFitting final model on full training set…")
|
| 128 |
+
model = RandomForestClassifier(
|
| 129 |
+
n_estimators=300,
|
| 130 |
+
max_depth=20,
|
| 131 |
+
min_samples_leaf=10,
|
| 132 |
+
class_weight="balanced",
|
| 133 |
+
n_jobs=-1,
|
| 134 |
+
random_state=42,
|
| 135 |
+
)
|
| 136 |
+
model.fit(X_train, y_train)
|
| 137 |
+
|
| 138 |
+
print("\nEvaluating on held-out test set…")
|
| 139 |
+
proba = model.predict_proba(X_test)[:, 1]
|
| 140 |
+
pred = (proba >= 0.5).astype(int)
|
| 141 |
+
print(classification_report(y_test, pred, target_names=["NoRain", "Rain"], digits=3))
|
| 142 |
+
cm = confusion_matrix(y_test, pred)
|
| 143 |
+
print("Confusion matrix:")
|
| 144 |
+
print(f" [[TN={cm[0,0]:>6} FP={cm[0,1]:>6}]")
|
| 145 |
+
print(f" [FN={cm[1,0]:>6} TP={cm[1,1]:>6}]]")
|
| 146 |
+
auc_test = roc_auc_score(y_test, proba)
|
| 147 |
+
f2_test = fbeta_score(y_test, pred, beta=2.0, zero_division=0)
|
| 148 |
+
print(f"AUC = {auc_test:.3f} F2 = {f2_test:.3f}")
|
| 149 |
+
|
| 150 |
+
print("\nFeature importances:")
|
| 151 |
+
fi = sorted(zip(FEATURE_COLUMNS, model.feature_importances_), key=lambda x: -x[1])
|
| 152 |
+
for name, imp in fi:
|
| 153 |
+
bar = "█" * int(imp * 200)
|
| 154 |
+
print(f" {name:<24} {imp:.4f} {bar}")
|
| 155 |
+
|
| 156 |
+
print("\nSaving artefacts…")
|
| 157 |
+
joblib.dump(model, MODEL_DIR / "rf_model.pkl")
|
| 158 |
+
with open(MODEL_DIR / "feature_columns.json", "w") as f:
|
| 159 |
+
json.dump(FEATURE_COLUMNS, f, indent=2)
|
| 160 |
+
with open(MODEL_DIR / "training_report.json", "w") as f:
|
| 161 |
+
json.dump({
|
| 162 |
+
"n_train": len(train_df),
|
| 163 |
+
"n_test": len(test_df),
|
| 164 |
+
"class_balance": float(df[TARGET].mean()),
|
| 165 |
+
"cv_fold_metrics": fold_metrics,
|
| 166 |
+
"test_metrics": {
|
| 167 |
+
"f1": float(f1_score(y_test, pred, zero_division=0)),
|
| 168 |
+
"f2": float(f2_test),
|
| 169 |
+
"auc": float(auc_test),
|
| 170 |
+
"confusion_matrix": cm.tolist(),
|
| 171 |
+
},
|
| 172 |
+
"feature_importance": {name: float(imp) for name, imp in fi},
|
| 173 |
+
}, f, indent=2)
|
| 174 |
+
|
| 175 |
+
print(f" → {MODEL_DIR/'rf_model.pkl'}")
|
| 176 |
+
print(f" → {MODEL_DIR/'feature_columns.json'}")
|
| 177 |
+
print(f" → {MODEL_DIR/'training_report.json'}")
|
| 178 |
+
print("\nNext step: uvicorn backend.main:app --reload --port 8000")
|
| 179 |
+
return 0
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
if __name__ == "__main__":
|
| 183 |
+
raise SystemExit(main())
|
scripts/4_evaluate_model.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Step 4 / Model Evaluation
|
| 3 |
+
==========================
|
| 4 |
+
Produces *publication-quality* figures that can be pasted directly into
|
| 5 |
+
the thesis (Chapter 5 — Results / Discussion). Run AFTER 3_train_model.py.
|
| 6 |
+
|
| 7 |
+
Inputs
|
| 8 |
+
------
|
| 9 |
+
models/rf_model.pkl
|
| 10 |
+
models/feature_columns.json
|
| 11 |
+
data/processed.csv
|
| 12 |
+
|
| 13 |
+
Outputs
|
| 14 |
+
-------
|
| 15 |
+
figures/01_roc_curve.png ROC + AUC
|
| 16 |
+
figures/02_pr_curve.png Precision-Recall + AP
|
| 17 |
+
figures/03_calibration_curve.png Reliability diagram + Brier score
|
| 18 |
+
figures/04_threshold_sweep.png F1 / F2 / Precision / Recall vs threshold
|
| 19 |
+
figures/05_feature_importance.png Top-20 features (horizontal bar)
|
| 20 |
+
figures/06_confusion_matrix.png Confusion matrix at optimal F2 threshold
|
| 21 |
+
figures/threshold_sweep.csv Same data as 04 in machine-readable form
|
| 22 |
+
figures/evaluation_summary.json One-shot metrics blob for the thesis
|
| 23 |
+
|
| 24 |
+
Run: python scripts/4_evaluate_model.py
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
from datetime import datetime, timezone
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import joblib
|
| 33 |
+
import matplotlib
|
| 34 |
+
import numpy as np
|
| 35 |
+
import pandas as pd
|
| 36 |
+
|
| 37 |
+
matplotlib.use("Agg")
|
| 38 |
+
import matplotlib.pyplot as plt
|
| 39 |
+
from sklearn.calibration import calibration_curve
|
| 40 |
+
from sklearn.metrics import (
|
| 41 |
+
auc,
|
| 42 |
+
average_precision_score,
|
| 43 |
+
brier_score_loss,
|
| 44 |
+
confusion_matrix,
|
| 45 |
+
f1_score,
|
| 46 |
+
fbeta_score,
|
| 47 |
+
precision_recall_curve,
|
| 48 |
+
precision_score,
|
| 49 |
+
recall_score,
|
| 50 |
+
roc_curve,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 54 |
+
MODEL_DIR = ROOT / "models"
|
| 55 |
+
DATA_DIR = ROOT / "data"
|
| 56 |
+
FIG_DIR = ROOT / "figures"
|
| 57 |
+
FIG_DIR.mkdir(exist_ok=True)
|
| 58 |
+
|
| 59 |
+
# ── Matplotlib defaults — keep figures consistent across panels ──────────
|
| 60 |
+
plt.rcParams.update({
|
| 61 |
+
"figure.figsize": (7.0, 4.5),
|
| 62 |
+
"figure.dpi": 120,
|
| 63 |
+
"savefig.dpi": 200,
|
| 64 |
+
"savefig.bbox": "tight",
|
| 65 |
+
"font.size": 11,
|
| 66 |
+
"axes.titlesize": 13,
|
| 67 |
+
"axes.labelsize": 11,
|
| 68 |
+
"legend.fontsize": 10,
|
| 69 |
+
"axes.spines.top": False,
|
| 70 |
+
"axes.spines.right": False,
|
| 71 |
+
"grid.alpha": 0.25,
|
| 72 |
+
"axes.axisbelow": True,
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ── Load artefacts ───────────────────────────────────────────────────────
|
| 77 |
+
|
| 78 |
+
def _load() -> tuple:
|
| 79 |
+
model_path = MODEL_DIR / "rf_model.pkl"
|
| 80 |
+
feats_path = MODEL_DIR / "feature_columns.json"
|
| 81 |
+
data_path = DATA_DIR / "processed.csv"
|
| 82 |
+
|
| 83 |
+
for p in (model_path, feats_path, data_path):
|
| 84 |
+
if not p.exists():
|
| 85 |
+
raise FileNotFoundError(
|
| 86 |
+
f"Missing artefact: {p}. Run scripts/3_train_model.py first."
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
model = joblib.load(model_path)
|
| 90 |
+
feat_cols = json.loads(feats_path.read_text())
|
| 91 |
+
df = pd.read_csv(data_path)
|
| 92 |
+
df["time"] = pd.to_datetime(df["time"])
|
| 93 |
+
df = df.sort_values("time").reset_index(drop=True)
|
| 94 |
+
|
| 95 |
+
# Use the last 20% as test (same split as training).
|
| 96 |
+
cut = int(len(df) * 0.80)
|
| 97 |
+
test = df.iloc[cut:].reset_index(drop=True)
|
| 98 |
+
|
| 99 |
+
X = test[feat_cols].values
|
| 100 |
+
y = test["is_rain_event"].astype(int).values
|
| 101 |
+
proba = model.predict_proba(X)[:, 1]
|
| 102 |
+
return model, feat_cols, X, y, proba, test
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ── Figure builders ──────────────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
def plot_roc(y, proba) -> dict:
|
| 108 |
+
fpr, tpr, _ = roc_curve(y, proba)
|
| 109 |
+
auc_v = auc(fpr, tpr)
|
| 110 |
+
|
| 111 |
+
fig, ax = plt.subplots()
|
| 112 |
+
ax.plot(fpr, tpr, color="#0ea5e9", linewidth=2.0, label=f"RF (AUC = {auc_v:.3f})")
|
| 113 |
+
ax.plot([0, 1], [0, 1], "--", color="#9ca3af", linewidth=1.0, label="Random baseline")
|
| 114 |
+
ax.set_xlabel("False Positive Rate")
|
| 115 |
+
ax.set_ylabel("True Positive Rate")
|
| 116 |
+
ax.set_title("ROC Curve — rain-event classifier")
|
| 117 |
+
ax.legend(loc="lower right")
|
| 118 |
+
ax.grid(True)
|
| 119 |
+
fig.savefig(FIG_DIR / "01_roc_curve.png")
|
| 120 |
+
plt.close(fig)
|
| 121 |
+
return {"auc": float(auc_v)}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def plot_pr(y, proba) -> dict:
|
| 125 |
+
pr, rc, _ = precision_recall_curve(y, proba)
|
| 126 |
+
ap = average_precision_score(y, proba)
|
| 127 |
+
base_rate = float(y.mean())
|
| 128 |
+
|
| 129 |
+
fig, ax = plt.subplots()
|
| 130 |
+
ax.plot(rc, pr, color="#10b981", linewidth=2.0, label=f"RF (AP = {ap:.3f})")
|
| 131 |
+
ax.hlines(base_rate, 0, 1, colors="#9ca3af", linestyles="--",
|
| 132 |
+
label=f"Base rate = {base_rate:.3f}")
|
| 133 |
+
ax.set_xlabel("Recall")
|
| 134 |
+
ax.set_ylabel("Precision")
|
| 135 |
+
ax.set_title("Precision–Recall Curve")
|
| 136 |
+
ax.legend(loc="lower left")
|
| 137 |
+
ax.grid(True)
|
| 138 |
+
fig.savefig(FIG_DIR / "02_pr_curve.png")
|
| 139 |
+
plt.close(fig)
|
| 140 |
+
return {"average_precision": float(ap), "base_rate": base_rate}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def plot_calibration(y, proba) -> dict:
|
| 144 |
+
frac_pos, mean_pred = calibration_curve(y, proba, n_bins=10, strategy="quantile")
|
| 145 |
+
brier = brier_score_loss(y, proba)
|
| 146 |
+
|
| 147 |
+
fig, ax = plt.subplots()
|
| 148 |
+
ax.plot([0, 1], [0, 1], "--", color="#9ca3af", linewidth=1.0,
|
| 149 |
+
label="Perfectly calibrated")
|
| 150 |
+
ax.plot(mean_pred, frac_pos, marker="o", color="#f59e0b", linewidth=2.0,
|
| 151 |
+
label=f"RF (Brier = {brier:.3f})")
|
| 152 |
+
ax.set_xlabel("Mean predicted probability")
|
| 153 |
+
ax.set_ylabel("Fraction of positives (observed)")
|
| 154 |
+
ax.set_title("Reliability Diagram — model calibration")
|
| 155 |
+
ax.legend(loc="upper left")
|
| 156 |
+
ax.grid(True)
|
| 157 |
+
fig.savefig(FIG_DIR / "03_calibration_curve.png")
|
| 158 |
+
plt.close(fig)
|
| 159 |
+
return {"brier_score": float(brier)}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def plot_threshold_sweep(y, proba) -> dict:
|
| 163 |
+
thresholds = np.linspace(0.05, 0.95, 19)
|
| 164 |
+
rows = []
|
| 165 |
+
best_f2 = (-1.0, 0.5)
|
| 166 |
+
for thr in thresholds:
|
| 167 |
+
yp = (proba >= thr).astype(int)
|
| 168 |
+
f1 = f1_score(y, yp, zero_division=0)
|
| 169 |
+
f2 = fbeta_score(y, yp, beta=2.0, zero_division=0)
|
| 170 |
+
prec = precision_score(y, yp, zero_division=0)
|
| 171 |
+
rec = recall_score(y, yp, zero_division=0)
|
| 172 |
+
rows.append({
|
| 173 |
+
"threshold": thr, "f1": f1, "f2": f2,
|
| 174 |
+
"precision": prec, "recall": rec,
|
| 175 |
+
})
|
| 176 |
+
if f2 > best_f2[0]:
|
| 177 |
+
best_f2 = (f2, thr)
|
| 178 |
+
|
| 179 |
+
sweep = pd.DataFrame(rows)
|
| 180 |
+
sweep.to_csv(FIG_DIR / "threshold_sweep.csv", index=False)
|
| 181 |
+
|
| 182 |
+
fig, ax = plt.subplots()
|
| 183 |
+
ax.plot(sweep.threshold, sweep.precision, label="Precision", color="#0ea5e9", linewidth=2.0)
|
| 184 |
+
ax.plot(sweep.threshold, sweep.recall, label="Recall", color="#10b981", linewidth=2.0)
|
| 185 |
+
ax.plot(sweep.threshold, sweep.f1, label="F1", color="#f59e0b", linewidth=1.4, linestyle="--")
|
| 186 |
+
ax.plot(sweep.threshold, sweep.f2, label="F2", color="#ef4444", linewidth=2.0)
|
| 187 |
+
ax.axvline(best_f2[1], color="#ef4444", alpha=0.25, linestyle=":")
|
| 188 |
+
ax.set_xlabel("Decision threshold")
|
| 189 |
+
ax.set_ylabel("Score")
|
| 190 |
+
ax.set_title(f"Threshold sweep — best F2 = {best_f2[0]:.3f} @ τ = {best_f2[1]:.2f}")
|
| 191 |
+
ax.legend(loc="lower left", ncols=4)
|
| 192 |
+
ax.grid(True)
|
| 193 |
+
fig.savefig(FIG_DIR / "04_threshold_sweep.png")
|
| 194 |
+
plt.close(fig)
|
| 195 |
+
return {"best_f2": float(best_f2[0]), "best_f2_threshold": float(best_f2[1])}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def plot_feature_importance(model, feat_cols, top_n: int = 20) -> dict:
|
| 199 |
+
imp = pd.Series(model.feature_importances_, index=feat_cols)
|
| 200 |
+
imp = imp.sort_values(ascending=True).tail(top_n)
|
| 201 |
+
|
| 202 |
+
fig, ax = plt.subplots(figsize=(7.0, 0.32 * len(imp) + 1.2))
|
| 203 |
+
ax.barh(imp.index, imp.values, color="#6366f1")
|
| 204 |
+
ax.set_xlabel("Importance (mean decrease in impurity)")
|
| 205 |
+
ax.set_title(f"Top {len(imp)} feature importances")
|
| 206 |
+
ax.grid(True, axis="x")
|
| 207 |
+
fig.savefig(FIG_DIR / "05_feature_importance.png")
|
| 208 |
+
plt.close(fig)
|
| 209 |
+
return {"feature_importance": imp.sort_values(ascending=False).to_dict()}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def plot_confusion(y, proba, threshold: float) -> dict:
|
| 213 |
+
yp = (proba >= threshold).astype(int)
|
| 214 |
+
cm = confusion_matrix(y, yp)
|
| 215 |
+
tn, fp, fn, tp = cm.ravel()
|
| 216 |
+
|
| 217 |
+
fig, ax = plt.subplots(figsize=(4.5, 4.0))
|
| 218 |
+
im = ax.imshow(cm, cmap="Blues")
|
| 219 |
+
for i in range(2):
|
| 220 |
+
for j in range(2):
|
| 221 |
+
ax.text(j, i, str(cm[i, j]), ha="center", va="center",
|
| 222 |
+
color="black" if cm[i, j] < cm.max() / 2 else "white",
|
| 223 |
+
fontsize=13, fontweight="bold")
|
| 224 |
+
ax.set_xticks([0, 1], ["No rain", "Rain"])
|
| 225 |
+
ax.set_yticks([0, 1], ["No rain", "Rain"])
|
| 226 |
+
ax.set_xlabel("Predicted label")
|
| 227 |
+
ax.set_ylabel("True label")
|
| 228 |
+
ax.set_title(f"Confusion matrix @ τ = {threshold:.2f}")
|
| 229 |
+
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
| 230 |
+
fig.savefig(FIG_DIR / "06_confusion_matrix.png")
|
| 231 |
+
plt.close(fig)
|
| 232 |
+
return {"tn": int(tn), "fp": int(fp), "fn": int(fn), "tp": int(tp)}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ── Main ─────────────────────────────────────────────────────────────────
|
| 236 |
+
|
| 237 |
+
def main() -> None:
|
| 238 |
+
print(f"[eval] loading artefacts from {MODEL_DIR}")
|
| 239 |
+
model, feat_cols, _, y, proba, _test = _load()
|
| 240 |
+
print(f"[eval] test set: {len(y)} samples ({int(y.sum())} positives, "
|
| 241 |
+
f"{(y.mean() * 100):.1f}% rain-event rate)")
|
| 242 |
+
|
| 243 |
+
summary = {
|
| 244 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 245 |
+
"n_test": len(y),
|
| 246 |
+
"n_positives": int(y.sum()),
|
| 247 |
+
"positive_rate": float(y.mean()),
|
| 248 |
+
"n_features": len(feat_cols),
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
summary["roc"] = plot_roc(y, proba)
|
| 252 |
+
summary["pr"] = plot_pr(y, proba)
|
| 253 |
+
summary["calibration"] = plot_calibration(y, proba)
|
| 254 |
+
sweep = plot_threshold_sweep(y, proba)
|
| 255 |
+
summary["threshold_sweep"] = sweep
|
| 256 |
+
summary["confusion"] = plot_confusion(y, proba, sweep["best_f2_threshold"])
|
| 257 |
+
top_importances = plot_feature_importance(model, feat_cols)
|
| 258 |
+
summary["top_features"] = list(top_importances["feature_importance"].keys())[:10]
|
| 259 |
+
|
| 260 |
+
out = FIG_DIR / "evaluation_summary.json"
|
| 261 |
+
out.write_text(json.dumps(summary, indent=2))
|
| 262 |
+
|
| 263 |
+
print(f"[eval] all figures written to {FIG_DIR}")
|
| 264 |
+
print(f"[eval] summary JSON: {out}")
|
| 265 |
+
print(f"[eval] best F2 = {sweep['best_f2']:.3f} at τ = {sweep['best_f2_threshold']:.2f}")
|
| 266 |
+
print(f"[eval] ROC AUC = {summary['roc']['auc']:.3f}, "
|
| 267 |
+
f"PR AP = {summary['pr']['average_precision']:.3f}, "
|
| 268 |
+
f"Brier = {summary['calibration']['brier_score']:.3f}")
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
if __name__ == "__main__":
|
| 272 |
+
main()
|
scripts/deploy_hf.sh
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# scripts/deploy_hf.sh — fully-automated deploy to Hugging Face Spaces
|
| 4 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 5 |
+
#
|
| 6 |
+
# Usage:
|
| 7 |
+
# HF_TOKEN="<your-hf-write-token>" ./scripts/deploy_hf.sh <hf-user>/<space-name>
|
| 8 |
+
#
|
| 9 |
+
# Example (token redacted — get yours at https://huggingface.co/settings/tokens):
|
| 10 |
+
# HF_TOKEN="$HF_TOKEN" ./scripts/deploy_hf.sh W1nd5pac/microclimate-x
|
| 11 |
+
#
|
| 12 |
+
# What it does (no manual steps):
|
| 13 |
+
# 1. Ensures huggingface_hub CLI is installed in .venv/
|
| 14 |
+
# 2. Authenticates with HF_TOKEN
|
| 15 |
+
# 3. Creates the Space (Docker SDK) if it doesn't exist yet
|
| 16 |
+
# 4. Uploads the whole repo (server-side LFS handles the 217 MB model)
|
| 17 |
+
# 5. Prints the live URL when the build is queued
|
| 18 |
+
#
|
| 19 |
+
# Skips:
|
| 20 |
+
# data/ figures/ tests/ .venv/ .git/ *.sqlite3 __pycache__/
|
| 21 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 22 |
+
set -euo pipefail
|
| 23 |
+
|
| 24 |
+
if [[ $# -lt 1 ]]; then
|
| 25 |
+
echo "Usage: HF_TOKEN=hf_xxx $0 <hf-user>/<space-name>"
|
| 26 |
+
echo "Example: HF_TOKEN=hf_xxx $0 W1nd5pac/microclimate-x"
|
| 27 |
+
exit 2
|
| 28 |
+
fi
|
| 29 |
+
|
| 30 |
+
REPO_ID="$1"
|
| 31 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 32 |
+
cd "$ROOT"
|
| 33 |
+
|
| 34 |
+
# Clean env so other venvs / PYTHONPATH leaks don't break us.
|
| 35 |
+
unset PYTHONPATH VIRTUAL_ENV PYTHONHOME
|
| 36 |
+
|
| 37 |
+
# ── 1. ensure .venv has huggingface_hub ──────────────────────────────────
|
| 38 |
+
if [[ ! -x ".venv/bin/hf" ]]; then
|
| 39 |
+
echo "▶ Installing huggingface_hub CLI into .venv/ …"
|
| 40 |
+
.venv/bin/pip install -q -U "huggingface_hub[cli,hf_transfer]"
|
| 41 |
+
fi
|
| 42 |
+
HF=".venv/bin/hf"
|
| 43 |
+
|
| 44 |
+
# Speed-boost for the 217 MB model upload.
|
| 45 |
+
export HF_HUB_ENABLE_HF_TRANSFER=1
|
| 46 |
+
|
| 47 |
+
# ── 2. authenticate ──────────────────────────────────────────────────────
|
| 48 |
+
if [[ -z "${HF_TOKEN:-}" ]]; then
|
| 49 |
+
if ! $HF auth whoami >/dev/null 2>&1; then
|
| 50 |
+
echo "❌ HF_TOKEN env not set and not already logged in."
|
| 51 |
+
echo " Get a Write token at https://huggingface.co/settings/tokens and run:"
|
| 52 |
+
echo " HF_TOKEN=hf_xxx $0 $REPO_ID"
|
| 53 |
+
exit 1
|
| 54 |
+
fi
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
if [[ -n "${HF_TOKEN:-}" ]]; then
|
| 58 |
+
# Re-login non-interactively so we use the supplied token (idempotent).
|
| 59 |
+
echo "▶ Authenticating as the token's owner …"
|
| 60 |
+
echo "$HF_TOKEN" | $HF auth login --token "$HF_TOKEN" --add-to-git-credential >/dev/null 2>&1 || true
|
| 61 |
+
fi
|
| 62 |
+
|
| 63 |
+
WHOAMI=$($HF auth whoami 2>/dev/null | head -1 || echo "?")
|
| 64 |
+
echo " Logged in as: $WHOAMI"
|
| 65 |
+
|
| 66 |
+
# ── 3. create the Space if missing (idempotent — 409 means "exists") ─────
|
| 67 |
+
echo "▶ Ensuring Space $REPO_ID exists (Docker SDK) …"
|
| 68 |
+
CREATE_OUTPUT=$($HF repos create "$REPO_ID" --repo-type space --space-sdk docker 2>&1 || true)
|
| 69 |
+
if echo "$CREATE_OUTPUT" | grep -q "Successfully created"; then
|
| 70 |
+
echo " Created fresh Space."
|
| 71 |
+
elif echo "$CREATE_OUTPUT" | grep -qi "already created\|409"; then
|
| 72 |
+
echo " Space already exists — will push to it."
|
| 73 |
+
else
|
| 74 |
+
echo "$CREATE_OUTPUT"
|
| 75 |
+
echo "❌ Unexpected response from 'hf repos create'. Aborting."
|
| 76 |
+
exit 1
|
| 77 |
+
fi
|
| 78 |
+
|
| 79 |
+
# ── 4. sanity-check the model exists locally ─────────────────────────────
|
| 80 |
+
MODEL="models/rf_model.pkl"
|
| 81 |
+
if [[ ! -f "$MODEL" ]]; then
|
| 82 |
+
echo "⚠️ $MODEL not found — the Space will fall back to a heuristic predictor."
|
| 83 |
+
read -r -p "Continue without the trained model? [y/N] " ans
|
| 84 |
+
[[ "$ans" =~ ^[Yy]$ ]] || exit 1
|
| 85 |
+
fi
|
| 86 |
+
|
| 87 |
+
# ── 5. upload everything ─────────────────────────────────────────────────
|
| 88 |
+
echo "▶ Uploading repo → spaces/$REPO_ID …"
|
| 89 |
+
echo " (217 MB rf_model.pkl uses HF's server-side Xet/LFS — no local LFS needed)"
|
| 90 |
+
|
| 91 |
+
DEPLOY_MSG="Deploy $(date -u +%Y-%m-%dT%H:%M:%SZ) — $(git rev-parse --short HEAD 2>/dev/null || echo local)"
|
| 92 |
+
|
| 93 |
+
# Pass 1: bulk-upload everything except the model (default: respects .gitignore
|
| 94 |
+
# which already excludes *.pkl, so the big file won't go in this pass).
|
| 95 |
+
$HF upload \
|
| 96 |
+
"$REPO_ID" \
|
| 97 |
+
. \
|
| 98 |
+
. \
|
| 99 |
+
--repo-type=space \
|
| 100 |
+
--commit-message="$DEPLOY_MSG (code)" \
|
| 101 |
+
--exclude "data/*" \
|
| 102 |
+
--exclude "figures/*" \
|
| 103 |
+
--exclude "tests/*" \
|
| 104 |
+
--exclude ".venv/*" \
|
| 105 |
+
--exclude ".local/*" \
|
| 106 |
+
--exclude ".pytest_cache/*" \
|
| 107 |
+
--exclude ".ruff_cache/*" \
|
| 108 |
+
--exclude ".mypy_cache/*" \
|
| 109 |
+
--exclude "**/__pycache__/*" \
|
| 110 |
+
--exclude "*.sqlite3" \
|
| 111 |
+
--exclude "*.sqlite3-*" \
|
| 112 |
+
--exclude "*.pyc" \
|
| 113 |
+
--exclude ".DS_Store" \
|
| 114 |
+
--exclude ".git/*" \
|
| 115 |
+
--exclude ".github/*"
|
| 116 |
+
|
| 117 |
+
# Pass 2: explicitly push the 217 MB Random Forest model. An explicit
|
| 118 |
+
# single-file path bypasses .gitignore filtering — without this step the Space
|
| 119 |
+
# falls back to the heuristic predictor and the AUC=0.871 claim won't reproduce.
|
| 120 |
+
if [[ -f "$MODEL" ]]; then
|
| 121 |
+
echo "▶ Uploading models/rf_model.pkl (217 MB) — bypassing .gitignore …"
|
| 122 |
+
$HF upload \
|
| 123 |
+
"$REPO_ID" \
|
| 124 |
+
"$MODEL" \
|
| 125 |
+
"models/rf_model.pkl" \
|
| 126 |
+
--repo-type=space \
|
| 127 |
+
--commit-message="$DEPLOY_MSG (model)"
|
| 128 |
+
fi
|
| 129 |
+
|
| 130 |
+
echo
|
| 131 |
+
echo "✅ Upload complete. Space is rebuilding now."
|
| 132 |
+
echo " Status: https://huggingface.co/spaces/$REPO_ID"
|
| 133 |
+
echo " Live URL: https://huggingface.co/spaces/$REPO_ID (≈ 3-5 min for first build)"
|
| 134 |
+
echo
|
| 135 |
+
echo "Tip: once the green Running badge shows, send the Live URL to your supervisor."
|
scripts/start_demo.sh
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# scripts/start_demo.sh — one-shot demo for supervisor showcase
|
| 4 |
+
#
|
| 5 |
+
# What it does:
|
| 6 |
+
# 1. Kills any previous demo processes (uvicorn / cloudflared)
|
| 7 |
+
# 2. Starts FastAPI on 127.0.0.1:8181 (clean env, isolated from other venvs)
|
| 8 |
+
# 3. Waits until /api/health returns 200
|
| 9 |
+
# 4. Starts a Cloudflare Quick Tunnel and prints the public URL
|
| 10 |
+
# 5. On Ctrl-C, cleanly shuts down both processes
|
| 11 |
+
#
|
| 12 |
+
# Usage:
|
| 13 |
+
# ./scripts/start_demo.sh
|
| 14 |
+
#
|
| 15 |
+
# Prereqs (already done by the agent on this machine):
|
| 16 |
+
# - .venv/ Python 3.9 venv with all deps installed
|
| 17 |
+
# - .local/bin/cloudflared (macOS arm64, downloaded from GitHub releases)
|
| 18 |
+
# - models/rf_model.pkl (217 MB, real ERA5-trained Random Forest)
|
| 19 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 20 |
+
set -euo pipefail
|
| 21 |
+
|
| 22 |
+
PORT="${PORT:-8181}"
|
| 23 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 24 |
+
LOG_DIR="${TMPDIR:-/tmp}"
|
| 25 |
+
UVICORN_LOG="$LOG_DIR/mcx-uvicorn.log"
|
| 26 |
+
TUNNEL_LOG="$LOG_DIR/mcx-tunnel.log"
|
| 27 |
+
|
| 28 |
+
cd "$ROOT"
|
| 29 |
+
|
| 30 |
+
# ── 1. Kill leftovers from a previous run ────────────────────────────────
|
| 31 |
+
pkill -f "uvicorn backend.main:app.*--port $PORT" 2>/dev/null || true
|
| 32 |
+
pkill -f "cloudflared tunnel --url http://127.0.0.1:$PORT" 2>/dev/null || true
|
| 33 |
+
sleep 1
|
| 34 |
+
|
| 35 |
+
# ── 2. Start FastAPI in the background ───────────────────────────────────
|
| 36 |
+
echo "▶ Starting FastAPI on http://127.0.0.1:$PORT …"
|
| 37 |
+
env -u PYTHONPATH -u VIRTUAL_ENV -u PYTHONHOME \
|
| 38 |
+
".venv/bin/python" -m uvicorn backend.main:app \
|
| 39 |
+
--host 127.0.0.1 --port "$PORT" \
|
| 40 |
+
> "$UVICORN_LOG" 2>&1 &
|
| 41 |
+
UVICORN_PID=$!
|
| 42 |
+
|
| 43 |
+
cleanup() {
|
| 44 |
+
echo
|
| 45 |
+
echo "▶ Shutting down (uvicorn=$UVICORN_PID, cloudflared=${CF_PID:-n/a})…"
|
| 46 |
+
[[ -n "${CF_PID:-}" ]] && kill "$CF_PID" 2>/dev/null || true
|
| 47 |
+
kill "$UVICORN_PID" 2>/dev/null || true
|
| 48 |
+
wait 2>/dev/null || true
|
| 49 |
+
echo "✓ Stopped. Logs preserved at:"
|
| 50 |
+
echo " $UVICORN_LOG"
|
| 51 |
+
echo " $TUNNEL_LOG"
|
| 52 |
+
}
|
| 53 |
+
trap cleanup EXIT INT TERM
|
| 54 |
+
|
| 55 |
+
# ── 3. Wait for /api/health ──────────────────────────────────────────────
|
| 56 |
+
printf " waiting for ML model load "
|
| 57 |
+
for _ in $(seq 1 40); do
|
| 58 |
+
if curl -sf --max-time 1 --noproxy '*' "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then
|
| 59 |
+
echo " ✓"
|
| 60 |
+
break
|
| 61 |
+
fi
|
| 62 |
+
printf "."
|
| 63 |
+
sleep 1
|
| 64 |
+
done
|
| 65 |
+
|
| 66 |
+
if ! curl -sf --max-time 1 --noproxy '*' "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then
|
| 67 |
+
echo
|
| 68 |
+
echo "❌ FastAPI did not become ready in 40 s. Last log lines:"
|
| 69 |
+
tail -20 "$UVICORN_LOG"
|
| 70 |
+
exit 1
|
| 71 |
+
fi
|
| 72 |
+
|
| 73 |
+
HEALTH=$(curl -s --noproxy '*' "http://127.0.0.1:$PORT/api/health")
|
| 74 |
+
ML_LOADED=$(echo "$HEALTH" | python3 -c 'import json,sys; print(json.load(sys.stdin)["ml_loaded"])' 2>/dev/null || echo "?")
|
| 75 |
+
echo " ML model loaded: $ML_LOADED (response: ${HEALTH:0:80}…)"
|
| 76 |
+
echo
|
| 77 |
+
|
| 78 |
+
# ── 4. Start Cloudflare Quick Tunnel ─────────────────────────────────────
|
| 79 |
+
echo "▶ Opening Cloudflare Quick Tunnel …"
|
| 80 |
+
echo " (your public URL will print below as 'https://*.trycloudflare.com')"
|
| 81 |
+
echo " ─────────────────────────────────────────────────────────────────"
|
| 82 |
+
|
| 83 |
+
# Run cloudflared in foreground so the user sees the URL and can Ctrl-C.
|
| 84 |
+
./.local/bin/cloudflared tunnel --url "http://127.0.0.1:$PORT" 2>&1 | tee "$TUNNEL_LOG" &
|
| 85 |
+
CF_PID=$!
|
| 86 |
+
wait "$CF_PID"
|