Compare commits
3
Commits
ce08312c0f
...
97a6694ab2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a6694ab2 | ||
|
|
5ad8e4eac3 | ||
|
|
6f4a86edbb |
+52
-319
@@ -1,4 +1,13 @@
|
|||||||
name: CI/CD Pipeline - Northern Thailand Ping River Monitor
|
name: CI
|
||||||
|
|
||||||
|
# What this checks, on every push and PR to master:
|
||||||
|
# 1. formatting contract (black + isort, config in pyproject.toml)
|
||||||
|
# 2. flake8 hard-error gate (syntax, undefined names)
|
||||||
|
# 3. the pytest suite (synthetic data, no DB/network; ~1 min)
|
||||||
|
# Docker build / staging / production / perf jobs from the original template
|
||||||
|
# were removed: there is no registry, no staging host, and production is a
|
||||||
|
# systemd unit deployed by `git pull` on the server (docs/FLOOD_FORECASTING.md
|
||||||
|
# section 6, scripts/install.sh). Re-add a job when the thing it deploys exists.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,337 +15,61 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
schedule:
|
schedule:
|
||||||
# Run tests daily at 2 AM UTC
|
# daily, catches dependency drift / upstream API changes in the tests
|
||||||
- cron: '0 2 * * *'
|
- cron: "0 2 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: '3.11'
|
PYTHON_VERSION: "3.11" # pandas 2.0.3 ships no 3.12 wheels; psycopg2-binary 2.9.9 breaks on 3.13
|
||||||
REGISTRY: git.b4l.co.th
|
|
||||||
IMAGE_NAME: b4l/northern-thailand-ping-river-monitor
|
|
||||||
# GitHub token for better rate limits and authentication
|
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Test job
|
lint:
|
||||||
|
name: Format & lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Install tools
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip --root-user-action=ignore
|
||||||
|
pip install --root-user-action=ignore black==23.11.0 isort==5.12.0 flake8==6.1.0
|
||||||
|
|
||||||
|
- name: black
|
||||||
|
run: black --check --diff src/ *.py
|
||||||
|
|
||||||
|
- name: isort
|
||||||
|
run: isort --check-only --diff src/ *.py
|
||||||
|
|
||||||
|
- name: flake8 (errors only)
|
||||||
|
run: flake8 src/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Test Suite
|
name: Test suite
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.11'] # pandas 2.0.3 ships no 3.12 wheels; widen after upgrading pandas
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- uses: actions/setup-python@v5
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
- name: Cache pip dependencies
|
cache-dependency-path: |
|
||||||
uses: actions/cache@v3
|
requirements.txt
|
||||||
with:
|
requirements-dev.txt
|
||||||
path: ~/.cache/pip
|
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pip-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip --root-user-action=ignore
|
python -m pip install --upgrade pip --root-user-action=ignore
|
||||||
pip install --root-user-action=ignore -r requirements.txt
|
pip install --root-user-action=ignore -r requirements.txt
|
||||||
pip install --root-user-action=ignore -r requirements-dev.txt
|
pip install --root-user-action=ignore pytest==7.4.3 pytest-asyncio==0.21.1
|
||||||
|
|
||||||
- name: Lint with flake8
|
- name: pytest
|
||||||
run: |
|
|
||||||
flake8 src/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
||||||
flake8 src/ --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics
|
|
||||||
|
|
||||||
- name: Type check with mypy (advisory)
|
|
||||||
run: |
|
|
||||||
# 86 pre-existing errors; blocking typing gate deferred until the debt is paid down
|
|
||||||
mypy src/ --ignore-missing-imports || true
|
|
||||||
|
|
||||||
- name: Format check with black
|
|
||||||
run: |
|
|
||||||
black --check src/ *.py
|
|
||||||
|
|
||||||
- name: Import sort check
|
|
||||||
run: |
|
|
||||||
isort --check-only src/ *.py
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
run: |
|
|
||||||
python tests/test_integration.py
|
|
||||||
|
|
||||||
- name: Run station management tests
|
|
||||||
run: |
|
|
||||||
python tests/test_station_management.py
|
|
||||||
|
|
||||||
- name: Test application startup
|
|
||||||
run: |
|
|
||||||
timeout 10s python run.py --test || true
|
|
||||||
|
|
||||||
- name: Security scan with bandit
|
|
||||||
run: |
|
|
||||||
bandit -r src/ -f json -o bandit-report.json || true
|
|
||||||
|
|
||||||
- name: Upload test artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: test-results-${{ matrix.python-version }}
|
|
||||||
path: |
|
|
||||||
bandit-report.json
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Code quality job
|
|
||||||
code-quality:
|
|
||||||
name: Code Quality
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip --root-user-action=ignore
|
|
||||||
pip install --root-user-action=ignore -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run safety check
|
|
||||||
run: |
|
|
||||||
safety check -r requirements.txt --json --output safety-report.json || true
|
|
||||||
|
|
||||||
- name: Run bandit security scan
|
|
||||||
run: |
|
|
||||||
bandit -r src/ -f json -o bandit-report.json || true
|
|
||||||
|
|
||||||
- name: Upload security reports
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: security-reports
|
|
||||||
path: |
|
|
||||||
safety-report.json
|
|
||||||
bandit-report.json
|
|
||||||
|
|
||||||
# Build Docker image
|
|
||||||
build:
|
|
||||||
name: Build Docker Image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: test
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=sha,prefix={{branch}}-
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
DB_TYPE: sqlite
|
||||||
|
run: pytest -q -p no:cacheprovider
|
||||||
- name: Test Docker image
|
|
||||||
run: |
|
|
||||||
docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} python run.py --test
|
|
||||||
|
|
||||||
# Integration test with services
|
|
||||||
integration-test:
|
|
||||||
name: Integration Test with Services
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
|
|
||||||
services:
|
|
||||||
victoriametrics:
|
|
||||||
image: victoriametrics/victoria-metrics:latest
|
|
||||||
ports:
|
|
||||||
- 8428:8428
|
|
||||||
options: >-
|
|
||||||
--health-cmd "wget --quiet --tries=1 --spider http://localhost:8428/health"
|
|
||||||
--health-interval 30s
|
|
||||||
--health-timeout 10s
|
|
||||||
--health-retries 3
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Wait for VictoriaMetrics
|
|
||||||
run: |
|
|
||||||
timeout 60s bash -c 'until curl -f http://localhost:8428/health; do sleep 2; done'
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip --root-user-action=ignore
|
|
||||||
pip install --root-user-action=ignore -r requirements.txt
|
|
||||||
|
|
||||||
- name: Test with VictoriaMetrics
|
|
||||||
env:
|
|
||||||
DB_TYPE: victoriametrics
|
|
||||||
VM_HOST: localhost
|
|
||||||
VM_PORT: 8428
|
|
||||||
run: |
|
|
||||||
python run.py --test
|
|
||||||
|
|
||||||
- name: Start API server
|
|
||||||
env:
|
|
||||||
DB_TYPE: victoriametrics
|
|
||||||
VM_HOST: localhost
|
|
||||||
VM_PORT: 8428
|
|
||||||
run: |
|
|
||||||
python run.py --web-api &
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
- name: Test API endpoints
|
|
||||||
run: |
|
|
||||||
curl -f http://localhost:8000/health
|
|
||||||
curl -f http://localhost:8000/stations
|
|
||||||
curl -f http://localhost:8000/metrics
|
|
||||||
|
|
||||||
# Deploy to staging (only on develop branch)
|
|
||||||
deploy-staging:
|
|
||||||
name: Deploy to Staging
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [test, build, integration-test]
|
|
||||||
if: github.ref == 'refs/heads/develop'
|
|
||||||
environment:
|
|
||||||
name: staging
|
|
||||||
url: https://staging.ping-river-monitor.b4l.co.th
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Deploy to staging
|
|
||||||
run: |
|
|
||||||
echo "Deploying to staging environment..."
|
|
||||||
# Add your staging deployment commands here
|
|
||||||
# Example: kubectl, docker-compose, or webhook call
|
|
||||||
|
|
||||||
- name: Health check staging
|
|
||||||
run: |
|
|
||||||
sleep 30
|
|
||||||
curl -f https://staging.ping-river-monitor.b4l.co.th/health
|
|
||||||
|
|
||||||
# Deploy to production (only on main branch, manual approval)
|
|
||||||
deploy-production:
|
|
||||||
name: Deploy to Production
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [test, build, integration-test]
|
|
||||||
if: github.ref == 'refs/heads/master'
|
|
||||||
environment:
|
|
||||||
name: production
|
|
||||||
url: https://ping-river-monitor.b4l.co.th
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Deploy to production
|
|
||||||
run: |
|
|
||||||
echo "Deploying to production environment..."
|
|
||||||
# Add your production deployment commands here
|
|
||||||
|
|
||||||
- name: Health check production
|
|
||||||
run: |
|
|
||||||
sleep 30
|
|
||||||
curl -f https://ping-river-monitor.b4l.co.th/health
|
|
||||||
|
|
||||||
- name: Notify deployment
|
|
||||||
run: |
|
|
||||||
echo "✅ Production deployment successful!"
|
|
||||||
echo "🌐 URL: https://ping-river-monitor.b4l.co.th"
|
|
||||||
echo "📊 Grafana: https://grafana.ping-river-monitor.b4l.co.th"
|
|
||||||
|
|
||||||
# Performance test (only on main branch)
|
|
||||||
performance-test:
|
|
||||||
name: Performance Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: deploy-production
|
|
||||||
if: github.ref == 'refs/heads/master'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install Apache Bench
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y apache2-utils
|
|
||||||
|
|
||||||
- name: Performance test API endpoints
|
|
||||||
run: |
|
|
||||||
# Test health endpoint
|
|
||||||
ab -n 100 -c 10 https://ping-river-monitor.b4l.co.th/health
|
|
||||||
|
|
||||||
# Test stations endpoint
|
|
||||||
ab -n 50 -c 5 https://ping-river-monitor.b4l.co.th/stations
|
|
||||||
|
|
||||||
# Test metrics endpoint
|
|
||||||
ab -n 50 -c 5 https://ping-river-monitor.b4l.co.th/metrics
|
|
||||||
|
|
||||||
# Cleanup old artifacts
|
|
||||||
cleanup:
|
|
||||||
name: Cleanup
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: always()
|
|
||||||
needs: [test, build, integration-test]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Clean up old Docker images
|
|
||||||
run: |
|
|
||||||
echo "Cleaning up old Docker images..."
|
|
||||||
# Add cleanup commands for old images/artifacts
|
|
||||||
|
|||||||
+74
-342
@@ -1,367 +1,99 @@
|
|||||||
name: Documentation
|
name: Docs
|
||||||
|
|
||||||
|
# Checks that the documentation the project actually ships stays consistent:
|
||||||
|
# - every relative link / image path in docs/*.md and README.md resolves
|
||||||
|
# inside the repo (external URLs are NOT fetched: localhost examples,
|
||||||
|
# rate-limited hosts and the Tailscale-era links made that gate permanently
|
||||||
|
# red, and a 200 on a curl --head proves nothing about a doc anyway)
|
||||||
|
# - the FastAPI app imports and its OpenAPI schema is exportable (that is
|
||||||
|
# the reference at https://water.buildfor.life/docs)
|
||||||
|
# The previous Sphinx/apidoc jobs produced artifacts nobody read and were
|
||||||
|
# removed. Reference docs live in docs/*.md; the public overview is at
|
||||||
|
# https://buildfor.life/docs/tooling/ping-river-monitor/.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, develop]
|
branches: [master, develop]
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- "docs/**"
|
||||||
- 'README.md'
|
- "README.md"
|
||||||
- 'CONTRIBUTING.md'
|
- "CONTRIBUTING.md"
|
||||||
- 'src/**/*.py'
|
- "src/web_api.py"
|
||||||
|
- "src/schemas.py"
|
||||||
|
- ".gitea/workflows/docs.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- "docs/**"
|
||||||
- 'README.md'
|
- "README.md"
|
||||||
- 'CONTRIBUTING.md'
|
- "CONTRIBUTING.md"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: '3.11'
|
PYTHON_VERSION: "3.11"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Validate documentation
|
docs:
|
||||||
validate-docs:
|
name: Validate documentation
|
||||||
name: Validate Documentation
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Relative links and images resolve
|
||||||
uses: actions/setup-python@v4
|
run: |
|
||||||
with:
|
python3 - <<'PY'
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
import re, sys, pathlib
|
||||||
|
root = pathlib.Path(".")
|
||||||
- name: Install documentation tools
|
files = [root / "README.md", root / "CONTRIBUTING.md", *root.glob("docs/**/*.md")]
|
||||||
run: |
|
link = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||||
python -m pip install --upgrade pip
|
bad = []
|
||||||
pip install -r requirements.txt
|
for md in files:
|
||||||
pip install sphinx sphinx-rtd-theme sphinx-autodoc-typehints
|
if not md.exists():
|
||||||
pip install markdown-link-check || true
|
continue
|
||||||
|
for m in link.finditer(md.read_text(encoding="utf-8")):
|
||||||
- name: Check markdown links
|
target = m.group(1)
|
||||||
run: |
|
if target.startswith(("http://", "https://", "mailto:", "#")):
|
||||||
echo "🔗 Checking markdown links..."
|
continue
|
||||||
find . -name "*.md" -not -path "./.git/*" -not -path "./node_modules/*" | while read file; do
|
path = target.split("#", 1)[0]
|
||||||
echo "Checking $file"
|
if not path:
|
||||||
# Basic link validation (you can enhance this)
|
continue
|
||||||
grep -o 'http[s]*://[^)]*' "$file" | while read url; do
|
resolved = (md.parent / path).resolve()
|
||||||
if curl -s --head "$url" | head -n 1 | grep -q "200 OK"; then
|
if not resolved.exists():
|
||||||
echo "✅ $url"
|
bad.append(f"{md}: {target}")
|
||||||
else
|
if bad:
|
||||||
echo "❌ $url (in $file)"
|
print("Broken relative links:")
|
||||||
fi
|
print("\n".join(" " + b for b in bad))
|
||||||
done
|
sys.exit(1)
|
||||||
done
|
print(f"checked {len(files)} files, all relative links resolve")
|
||||||
|
PY
|
||||||
- name: Validate README structure
|
|
||||||
run: |
|
- uses: actions/setup-python@v5
|
||||||
echo "📋 Validating README structure..."
|
|
||||||
|
|
||||||
required_sections=(
|
|
||||||
"# Northern Thailand Ping River Monitor"
|
|
||||||
"## Features"
|
|
||||||
"## Quick Start"
|
|
||||||
"## Installation"
|
|
||||||
"## Usage"
|
|
||||||
"## API Endpoints"
|
|
||||||
"## Docker"
|
|
||||||
"## Contributing"
|
|
||||||
"## License"
|
|
||||||
)
|
|
||||||
|
|
||||||
for section in "${required_sections[@]}"; do
|
|
||||||
if grep -q "$section" README.md; then
|
|
||||||
echo "✅ Found: $section"
|
|
||||||
else
|
|
||||||
echo "❌ Missing: $section"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Check documentation completeness
|
|
||||||
run: |
|
|
||||||
echo "📚 Checking documentation completeness..."
|
|
||||||
|
|
||||||
# Check if all Python modules have docstrings
|
|
||||||
python -c "
|
|
||||||
import ast
|
|
||||||
import os
|
|
||||||
|
|
||||||
def check_docstrings(filepath):
|
|
||||||
with open(filepath, 'r', encoding='utf-8') as f:
|
|
||||||
tree = ast.parse(f.read())
|
|
||||||
|
|
||||||
missing_docstrings = []
|
|
||||||
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.AsyncFunctionDef)):
|
|
||||||
if not ast.get_docstring(node):
|
|
||||||
missing_docstrings.append(f'{node.name} in {filepath}')
|
|
||||||
|
|
||||||
return missing_docstrings
|
|
||||||
|
|
||||||
all_missing = []
|
|
||||||
for root, dirs, files in os.walk('src'):
|
|
||||||
for file in files:
|
|
||||||
if file.endswith('.py') and not file.startswith('__'):
|
|
||||||
filepath = os.path.join(root, file)
|
|
||||||
missing = check_docstrings(filepath)
|
|
||||||
all_missing.extend(missing)
|
|
||||||
|
|
||||||
if all_missing:
|
|
||||||
print('⚠️ Missing docstrings:')
|
|
||||||
for item in all_missing[:10]: # Show first 10
|
|
||||||
print(f' - {item}')
|
|
||||||
if len(all_missing) > 10:
|
|
||||||
print(f' ... and {len(all_missing) - 10} more')
|
|
||||||
else:
|
|
||||||
print('✅ All functions and classes have docstrings')
|
|
||||||
"
|
|
||||||
|
|
||||||
# Generate API documentation
|
|
||||||
generate-api-docs:
|
|
||||||
name: Generate API Documentation
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: requirements.txt
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip --root-user-action=ignore
|
||||||
pip install -r requirements.txt
|
pip install --root-user-action=ignore -r requirements.txt
|
||||||
|
|
||||||
- name: Generate OpenAPI spec
|
- name: OpenAPI schema exports
|
||||||
|
env:
|
||||||
|
DB_TYPE: sqlite
|
||||||
run: |
|
run: |
|
||||||
echo "📝 Generating OpenAPI specification..."
|
python - <<'PY'
|
||||||
python -c "
|
|
||||||
import json
|
import json
|
||||||
import sys
|
from src.web_api import app
|
||||||
sys.path.insert(0, 'src')
|
spec = app.openapi()
|
||||||
|
paths = sorted(spec["paths"])
|
||||||
|
required = {"/forecast", "/measurements/latest", "/measurements/history/{station_code}", "/stations", "/api/stats", "/health"}
|
||||||
|
missing = required - set(paths)
|
||||||
|
assert not missing, f"documented endpoints missing from the app: {missing}"
|
||||||
|
json.dump(spec, open("openapi.json", "w"), indent=1)
|
||||||
|
print(f"{len(paths)} paths; schema written to openapi.json")
|
||||||
|
PY
|
||||||
|
|
||||||
try:
|
- uses: actions/upload-artifact@v3
|
||||||
from web_api import app
|
|
||||||
openapi_spec = app.openapi()
|
|
||||||
|
|
||||||
with open('openapi.json', 'w') as f:
|
|
||||||
json.dump(openapi_spec, f, indent=2)
|
|
||||||
|
|
||||||
print('✅ OpenAPI spec generated: openapi.json')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'❌ Failed to generate OpenAPI spec: {e}')
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Generate API documentation
|
|
||||||
run: |
|
|
||||||
echo "📖 Generating API documentation..."
|
|
||||||
|
|
||||||
# Create API documentation from OpenAPI spec
|
|
||||||
if [ -f openapi.json ]; then
|
|
||||||
cat > api-docs.md << 'EOF'
|
|
||||||
# API Documentation
|
|
||||||
|
|
||||||
This document describes the REST API endpoints for the Northern Thailand Ping River Monitor.
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
|
|
||||||
- Production: `https://ping-river-monitor.b4l.co.th`
|
|
||||||
- Staging: `https://staging.ping-river-monitor.b4l.co.th`
|
|
||||||
- Development: `http://localhost:8000`
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Currently, the API does not require authentication. This may change in future versions.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Extract endpoints from OpenAPI spec
|
|
||||||
python -c "
|
|
||||||
import json
|
|
||||||
|
|
||||||
with open('openapi.json', 'r') as f:
|
|
||||||
spec = json.load(f)
|
|
||||||
|
|
||||||
for path, methods in spec.get('paths', {}).items():
|
|
||||||
for method, details in methods.items():
|
|
||||||
print(f'### {method.upper()} {path}')
|
|
||||||
print()
|
|
||||||
print(details.get('summary', 'No description available'))
|
|
||||||
print()
|
|
||||||
if 'parameters' in details:
|
|
||||||
print('**Parameters:**')
|
|
||||||
for param in details['parameters']:
|
|
||||||
print(f'- `{param[\"name\"]}` ({param.get(\"in\", \"query\")}): {param.get(\"description\", \"No description\")}')
|
|
||||||
print()
|
|
||||||
print('---')
|
|
||||||
print()
|
|
||||||
" >> api-docs.md
|
|
||||||
|
|
||||||
echo "✅ API documentation generated: api-docs.md"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload documentation artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
with:
|
||||||
name: documentation-${{ github.run_number }}
|
name: openapi-${{ github.run_number }}
|
||||||
path: |
|
path: openapi.json
|
||||||
openapi.json
|
|
||||||
api-docs.md
|
|
||||||
|
|
||||||
# Build Sphinx documentation
|
|
||||||
build-sphinx-docs:
|
|
||||||
name: Build Sphinx Documentation
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install sphinx sphinx-rtd-theme sphinx-autodoc-typehints
|
|
||||||
|
|
||||||
- name: Create Sphinx configuration
|
|
||||||
run: |
|
|
||||||
mkdir -p docs/sphinx
|
|
||||||
|
|
||||||
cat > docs/sphinx/conf.py << 'EOF'
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, os.path.abspath('../../src'))
|
|
||||||
|
|
||||||
project = 'Northern Thailand Ping River Monitor'
|
|
||||||
copyright = '2025, Ping River Monitor Team'
|
|
||||||
author = 'Ping River Monitor Team'
|
|
||||||
version = '3.1.3'
|
|
||||||
release = '3.1.3'
|
|
||||||
|
|
||||||
extensions = [
|
|
||||||
'sphinx.ext.autodoc',
|
|
||||||
'sphinx.ext.viewcode',
|
|
||||||
'sphinx.ext.napoleon',
|
|
||||||
'sphinx_autodoc_typehints',
|
|
||||||
]
|
|
||||||
|
|
||||||
templates_path = ['_templates']
|
|
||||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
|
||||||
|
|
||||||
html_theme = 'sphinx_rtd_theme'
|
|
||||||
html_static_path = ['_static']
|
|
||||||
|
|
||||||
autodoc_default_options = {
|
|
||||||
'members': True,
|
|
||||||
'member-order': 'bysource',
|
|
||||||
'special-members': '__init__',
|
|
||||||
'undoc-members': True,
|
|
||||||
'exclude-members': '__weakref__'
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cat > docs/sphinx/index.rst << 'EOF'
|
|
||||||
Northern Thailand Ping River Monitor Documentation
|
|
||||||
================================================
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 2
|
|
||||||
:caption: Contents:
|
|
||||||
|
|
||||||
modules
|
|
||||||
|
|
||||||
Indices and tables
|
|
||||||
==================
|
|
||||||
|
|
||||||
* :ref:`genindex`
|
|
||||||
* :ref:`modindex`
|
|
||||||
* :ref:`search`
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Generate module documentation
|
|
||||||
run: |
|
|
||||||
cd docs/sphinx
|
|
||||||
sphinx-apidoc -o . ../../src
|
|
||||||
|
|
||||||
- name: Build documentation
|
|
||||||
run: |
|
|
||||||
cd docs/sphinx
|
|
||||||
sphinx-build -b html . _build/html
|
|
||||||
|
|
||||||
- name: Upload Sphinx documentation
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: sphinx-docs-${{ github.run_number }}
|
|
||||||
path: docs/sphinx/_build/html/
|
|
||||||
|
|
||||||
# Documentation summary
|
|
||||||
docs-summary:
|
|
||||||
name: Documentation Summary
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [validate-docs, generate-api-docs, build-sphinx-docs]
|
|
||||||
if: always()
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Generate documentation summary
|
|
||||||
run: |
|
|
||||||
echo "# 📚 Documentation Build Summary" > docs-summary.md
|
|
||||||
echo "" >> docs-summary.md
|
|
||||||
echo "**Build Date:** $(date -u)" >> docs-summary.md
|
|
||||||
echo "**Repository:** ${{ github.repository }}" >> docs-summary.md
|
|
||||||
echo "**Commit:** ${{ github.sha }}" >> docs-summary.md
|
|
||||||
echo "" >> docs-summary.md
|
|
||||||
|
|
||||||
echo "## 📊 Results" >> docs-summary.md
|
|
||||||
echo "" >> docs-summary.md
|
|
||||||
|
|
||||||
if [ "${{ needs.validate-docs.result }}" = "success" ]; then
|
|
||||||
echo "- ✅ **Documentation Validation**: Passed" >> docs-summary.md
|
|
||||||
else
|
|
||||||
echo "- ❌ **Documentation Validation**: Failed" >> docs-summary.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${{ needs.generate-api-docs.result }}" = "success" ]; then
|
|
||||||
echo "- ✅ **API Documentation**: Generated" >> docs-summary.md
|
|
||||||
else
|
|
||||||
echo "- ❌ **API Documentation**: Failed" >> docs-summary.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${{ needs.build-sphinx-docs.result }}" = "success" ]; then
|
|
||||||
echo "- ✅ **Sphinx Documentation**: Built" >> docs-summary.md
|
|
||||||
else
|
|
||||||
echo "- ❌ **Sphinx Documentation**: Failed" >> docs-summary.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "" >> docs-summary.md
|
|
||||||
echo "## 🔗 Available Documentation" >> docs-summary.md
|
|
||||||
echo "" >> docs-summary.md
|
|
||||||
echo "- [README.md](../README.md)" >> docs-summary.md
|
|
||||||
echo "- [API Documentation](../docs/)" >> docs-summary.md
|
|
||||||
echo "- [Contributing Guide](../CONTRIBUTING.md)" >> docs-summary.md
|
|
||||||
|
|
||||||
cat docs-summary.md
|
|
||||||
|
|
||||||
- name: Upload documentation summary
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: docs-summary-${{ github.run_number }}
|
|
||||||
path: docs-summary.md
|
|
||||||
|
|||||||
@@ -23,18 +23,16 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3
|
language_version: python3
|
||||||
args: ['--line-length=120']
|
|
||||||
|
|
||||||
# Import sorting with isort
|
# Import sorting with isort
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/pycqa/isort
|
||||||
rev: 5.12.0
|
rev: 5.12.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
args: ['--profile', 'black', '--line-length', '120']
|
|
||||||
|
|
||||||
# Linting with flake8
|
# Linting with flake8
|
||||||
- repo: https://github.com/pycqa/flake8
|
- repo: https://github.com/pycqa/flake8
|
||||||
rev: 6.1.0
|
rev: 6.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
args: ['--max-line-length=120', '--extend-ignore=E203,W503']
|
args: ['--max-line-length=100', '--extend-ignore=E203,W503']
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A comprehensive real-time water level monitoring system for the Ping River Basin
|
|||||||
|
|
||||||
**Live dashboard: [water.buildfor.life](https://water.buildfor.life/)** — water levels, discharge, rainfall and 6/12/24 h flood forecasts for Chiang Mai, in English and Thai. Background: [Teaching a Model to See the Ping River Rise 13 Hours Early](https://buildfor.life/blog/ping-river-monitor/).
|
**Live dashboard: [water.buildfor.life](https://water.buildfor.life/)** — water levels, discharge, rainfall and 6/12/24 h flood forecasts for Chiang Mai, in English and Thai. Background: [Teaching a Model to See the Ping River Rise 13 Hours Early](https://buildfor.life/blog/ping-river-monitor/).
|
||||||
|
|
||||||
[](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [](https://python.org) [](https://fastapi.tiangolo.com) [](https://docker.com) [](LICENSE) [](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/releases)
|
[](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/actions) [](https://python.org) [](https://fastapi.tiangolo.com) [](https://docker.com) [](LICENSE) [](https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor/releases)
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 522 KiB |
@@ -128,3 +128,16 @@ where = ["src"]
|
|||||||
|
|
||||||
[tool.setuptools.package-dir]
|
[tool.setuptools.package-dir]
|
||||||
"" = "src"
|
"" = "src"
|
||||||
|
|
||||||
|
# One formatting contract for CI, pre-commit and editors. Black's default 88
|
||||||
|
# columns; isort in black-compatible mode. Run `make format` before committing.
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ["py311"]
|
||||||
|
extend-exclude = '/(\.venv|venv|models|\.claude-flow|\.swarm)/'
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
line_length = 88
|
||||||
|
known_first_party = ["src"]
|
||||||
|
skip_gitignore = true
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Serve the working-copy dashboard locally with API calls proxied to the
|
||||||
|
live server, so browser-side changes can be checked against real data
|
||||||
|
before deploy. Usage: python scripts/dev_proxy.py [port]"""
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
UPSTREAM = "https://water.buildfor.life"
|
||||||
|
STATIC = Path(__file__).resolve().parents[1] / "src" / "static"
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/" or self.path.startswith("/?"):
|
||||||
|
body = (STATIC / "dashboard.html").read_bytes()
|
||||||
|
self._send(200, "text/html; charset=utf-8", body)
|
||||||
|
return
|
||||||
|
if self.path.startswith("/static/"):
|
||||||
|
f = STATIC / self.path[len("/static/"):].split("?")[0]
|
||||||
|
if f.is_file():
|
||||||
|
ctype = "application/json" if f.suffix in (".json", ".geojson") else "application/octet-stream"
|
||||||
|
self._send(200, ctype, f.read_bytes())
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
UPSTREAM + self.path,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (dev_proxy; +https://buildfor.life)", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
self._send(r.status, r.headers.get("Content-Type", "application/json"), r.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
self._send(e.code, "application/json", e.read())
|
||||||
|
|
||||||
|
def _send(self, code, ctype, body):
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
|
||||||
|
print(f"http://localhost:{port}/ (API -> {UPSTREAM})")
|
||||||
|
http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||||
+7
-3
@@ -12,9 +12,13 @@ __description__ = "Northern Thailand Ping River Monitoring System"
|
|||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .database_adapters import DatabaseAdapter, create_database_adapter
|
from .database_adapters import DatabaseAdapter, create_database_adapter
|
||||||
from .exceptions import (APIConnectionError, ConfigurationError,
|
from .exceptions import (
|
||||||
DatabaseConnectionError, DataValidationError,
|
APIConnectionError,
|
||||||
WaterMonitorException)
|
ConfigurationError,
|
||||||
|
DatabaseConnectionError,
|
||||||
|
DataValidationError,
|
||||||
|
WaterMonitorException,
|
||||||
|
)
|
||||||
from .models import DatabaseConfig, StationInfo, WaterMeasurement
|
from .models import DatabaseConfig, StationInfo, WaterMeasurement
|
||||||
from .water_scraper_v3 import EnhancedWaterMonitorScraper
|
from .water_scraper_v3 import EnhancedWaterMonitorScraper
|
||||||
|
|
||||||
|
|||||||
@@ -767,9 +767,7 @@ class SQLAdapter(DatabaseAdapter):
|
|||||||
return hours_by_day
|
return hours_by_day
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(
|
logging.error(f"Error querying {self.db_type.upper()} recorded hours: {e}")
|
||||||
f"Error querying {self.db_type.upper()} recorded hours: {e}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_database_stats(self) -> Optional[Dict]:
|
def get_database_stats(self) -> Optional[Dict]:
|
||||||
@@ -814,9 +812,7 @@ class SQLAdapter(DatabaseAdapter):
|
|||||||
# the DISTINCT day-hour slots and coverage cannot exceed 100%
|
# the DISTINCT day-hour slots and coverage cannot exceed 100%
|
||||||
first_slot = first_ts.replace(minute=0, second=0, microsecond=0)
|
first_slot = first_ts.replace(minute=0, second=0, microsecond=0)
|
||||||
last_slot = last_ts.replace(minute=0, second=0, microsecond=0)
|
last_slot = last_ts.replace(minute=0, second=0, microsecond=0)
|
||||||
expected_hours = (
|
expected_hours = int((last_slot - first_slot).total_seconds() // 3600) + 1
|
||||||
int((last_slot - first_slot).total_seconds() // 3600) + 1
|
|
||||||
)
|
|
||||||
recorded_hours = int(row[4])
|
recorded_hours = int(row[4])
|
||||||
coverage_percent = round(100.0 * recorded_hours / expected_hours, 1)
|
coverage_percent = round(100.0 * recorded_hours / expected_hours, 1)
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -17,9 +17,9 @@ import time
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from .hii_collector import (
|
from .hii_collector import (
|
||||||
|
PING_BASIN_CODE,
|
||||||
HiiClient,
|
HiiClient,
|
||||||
HiiStore,
|
HiiStore,
|
||||||
PING_BASIN_CODE,
|
|
||||||
_parse_datetime,
|
_parse_datetime,
|
||||||
_to_float,
|
_to_float,
|
||||||
)
|
)
|
||||||
@@ -145,9 +145,7 @@ def backfill(
|
|||||||
station_rows += store.save_waterlevel_history(sid, rows)
|
station_rows += store.save_waterlevel_history(sid, rows)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
totals["errors"] += 1
|
totals["errors"] += 1
|
||||||
logger.warning(
|
logger.warning(f"{label}: {chunk_start}..{chunk_end} failed: {e}")
|
||||||
f"{label}: {chunk_start}..{chunk_end} failed: {e}"
|
|
||||||
)
|
|
||||||
time.sleep(sleep_seconds)
|
time.sleep(sleep_seconds)
|
||||||
totals["rows"] += station_rows
|
totals["rows"] += station_rows
|
||||||
logger.info(f"{label} (id {sid}): {station_rows} rows saved")
|
logger.info(f"{label} (id {sid}): {station_rows} rows saved")
|
||||||
|
|||||||
@@ -66,9 +66,7 @@ def rid_code_from_oldcode(oldcode: Optional[str]) -> Optional[str]:
|
|||||||
return match.group(1) if match else None
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
def parse_rain_records(
|
def parse_rain_records(payload: Dict, basin_code: int = PING_BASIN_CODE) -> List[Dict]:
|
||||||
payload: Dict, basin_code: int = PING_BASIN_CODE
|
|
||||||
) -> List[Dict]:
|
|
||||||
"""Extract per-station rainfall rows from a rain_24h payload."""
|
"""Extract per-station rainfall rows from a rain_24h payload."""
|
||||||
records = []
|
records = []
|
||||||
for row in payload.get("data") or []:
|
for row in payload.get("data") or []:
|
||||||
@@ -186,9 +184,7 @@ class HiiStore:
|
|||||||
def __init__(self, connection_string: str, db_type: str):
|
def __init__(self, connection_string: str, db_type: str):
|
||||||
self.db_type = db_type.lower()
|
self.db_type = db_type.lower()
|
||||||
if self.db_type not in ("sqlite", "postgresql", "mysql"):
|
if self.db_type not in ("sqlite", "postgresql", "mysql"):
|
||||||
raise ValueError(
|
raise ValueError(f"HII collection requires a SQL database, got '{db_type}'")
|
||||||
f"HII collection requires a SQL database, got '{db_type}'"
|
|
||||||
)
|
|
||||||
self.connection_string = connection_string
|
self.connection_string = connection_string
|
||||||
self.engine = None
|
self.engine = None
|
||||||
|
|
||||||
@@ -400,9 +396,7 @@ class HiiStore:
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now()
|
||||||
station_sql = self._upsert(
|
station_sql = self._upsert(station_table, ["id"], station_cols + ["updated_at"])
|
||||||
station_table, ["id"], station_cols + ["updated_at"]
|
|
||||||
)
|
|
||||||
measurement_sql = self._upsert(
|
measurement_sql = self._upsert(
|
||||||
measurement_table, ["station_id", "timestamp"], measurement_cols
|
measurement_table, ["station_id", "timestamp"], measurement_cols
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-3
@@ -61,9 +61,7 @@ def load_daily(
|
|||||||
params["start"] = start
|
params["start"] = start
|
||||||
engine = create_engine(resolved, pool_pre_ping=True)
|
engine = create_engine(resolved, pool_pre_ping=True)
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
daily = pd.read_sql(
|
daily = pd.read_sql(text(query + " ORDER BY date"), conn, params=params)
|
||||||
text(query + " ORDER BY date"), conn, params=params
|
|
||||||
)
|
|
||||||
daily["date"] = pd.to_datetime(daily["date"])
|
daily["date"] = pd.to_datetime(daily["date"])
|
||||||
daily = daily.set_index("date")
|
daily = daily.set_index("date")
|
||||||
for col in DAM_COLUMNS:
|
for col in DAM_COLUMNS:
|
||||||
|
|||||||
+1
-3
@@ -213,9 +213,7 @@ def fill_from_hii(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
fills.append(fill)
|
fills.append(fill)
|
||||||
logger.info(
|
logger.info(f"HII gap-fill {code}: +{len(fill)} hours (offset {offset:.3f} m)")
|
||||||
f"HII gap-fill {code}: +{len(fill)} hours (offset {offset:.3f} m)"
|
|
||||||
)
|
|
||||||
if not fills:
|
if not fills:
|
||||||
return df
|
return df
|
||||||
return _normalize_long(pd.concat([df] + fills, ignore_index=True))
|
return _normalize_long(pd.concat([df] + fills, ignore_index=True))
|
||||||
|
|||||||
+64
-39
@@ -62,10 +62,17 @@ EXTRA_RAIN_FEATURES = ("rain_fc48",)
|
|||||||
class Variant:
|
class Variant:
|
||||||
"""A trainable candidate producing (pred_abs, sigma_per_row) on test rows."""
|
"""A trainable candidate producing (pred_abs, sigma_per_row) on test rows."""
|
||||||
|
|
||||||
def __init__(self, name: str, target: str, weighted: bool = False,
|
def __init__(
|
||||||
quantile: bool = False, use_rain: bool = False,
|
self,
|
||||||
use_dam: bool = False, use_fc48: bool = False,
|
name: str,
|
||||||
qsigma: bool = False):
|
target: str,
|
||||||
|
weighted: bool = False,
|
||||||
|
quantile: bool = False,
|
||||||
|
use_rain: bool = False,
|
||||||
|
use_dam: bool = False,
|
||||||
|
use_fc48: bool = False,
|
||||||
|
qsigma: bool = False,
|
||||||
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.target = target # 'abs' or 'rise'
|
self.target = target # 'abs' or 'rise'
|
||||||
self.weighted = weighted
|
self.weighted = weighted
|
||||||
@@ -78,9 +85,7 @@ class Variant:
|
|||||||
# sigma-independent) and quantile heads ONLY for a per-row sigma.
|
# sigma-independent) and quantile heads ONLY for a per-row sigma.
|
||||||
self.qsigma = qsigma
|
self.qsigma = qsigma
|
||||||
|
|
||||||
def fit_predict(
|
def fit_predict(self, X_tr, y_abs_tr, X_te) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
self, X_tr, y_abs_tr, X_te
|
|
||||||
) -> Tuple[np.ndarray, np.ndarray]:
|
|
||||||
if not self.use_rain:
|
if not self.use_rain:
|
||||||
drop = [c for c in features.RAIN_FEATURES if c in X_tr.columns]
|
drop = [c for c in features.RAIN_FEATURES if c in X_tr.columns]
|
||||||
X_tr = X_tr.drop(columns=drop)
|
X_tr = X_tr.drop(columns=drop)
|
||||||
@@ -135,24 +140,30 @@ VARIANTS: Dict[str, Variant] = {
|
|||||||
"baseline_abs": Variant("baseline_abs", target="abs"),
|
"baseline_abs": Variant("baseline_abs", target="abs"),
|
||||||
"rise": Variant("rise", target="rise"),
|
"rise": Variant("rise", target="rise"),
|
||||||
"rise_weighted": Variant("rise_weighted", target="rise", weighted=True),
|
"rise_weighted": Variant("rise_weighted", target="rise", weighted=True),
|
||||||
"rise_quantile": Variant("rise_quantile", target="rise", weighted=True,
|
"rise_quantile": Variant(
|
||||||
quantile=True),
|
"rise_quantile", target="rise", weighted=True, quantile=True
|
||||||
|
),
|
||||||
"rise_rain": Variant("rise_rain", target="rise", use_rain=True),
|
"rise_rain": Variant("rise_rain", target="rise", use_rain=True),
|
||||||
"rise_rain_dam": Variant("rise_rain_dam", target="rise", use_rain=True,
|
"rise_rain_dam": Variant(
|
||||||
use_dam=True),
|
"rise_rain_dam", target="rise", use_rain=True, use_dam=True
|
||||||
|
),
|
||||||
"rise_dam": Variant("rise_dam", target="rise", use_dam=True),
|
"rise_dam": Variant("rise_dam", target="rise", use_dam=True),
|
||||||
# 2026-09-12 experiments on top of the deployed rise_rain configuration:
|
# 2026-09-12 experiments on top of the deployed rise_rain configuration:
|
||||||
# per-row sigma from quantile heads (the served sigma sits on the 0.15
|
# per-row sigma from quantile heads (the served sigma sits on the 0.15
|
||||||
# floor at every P.1 horizon, so stage probabilities are constant-
|
# floor at every P.1 horizon, so stage probabilities are constant-
|
||||||
# calibrated), and a longer forecast-rain window for the 24 h horizon.
|
# calibrated), and a longer forecast-rain window for the 24 h horizon.
|
||||||
"rise_rain_quantile": Variant("rise_rain_quantile", target="rise",
|
"rise_rain_quantile": Variant(
|
||||||
weighted=True, quantile=True, use_rain=True),
|
"rise_rain_quantile", target="rise", weighted=True, quantile=True, use_rain=True
|
||||||
"rise_rain_quantile_uw": Variant("rise_rain_quantile_uw", target="rise",
|
),
|
||||||
quantile=True, use_rain=True),
|
"rise_rain_quantile_uw": Variant(
|
||||||
"rise_rain_fc48": Variant("rise_rain_fc48", target="rise", use_rain=True,
|
"rise_rain_quantile_uw", target="rise", quantile=True, use_rain=True
|
||||||
use_fc48=True),
|
),
|
||||||
"rise_rain_qsigma": Variant("rise_rain_qsigma", target="rise", use_rain=True,
|
"rise_rain_fc48": Variant(
|
||||||
qsigma=True),
|
"rise_rain_fc48", target="rise", use_rain=True, use_fc48=True
|
||||||
|
),
|
||||||
|
"rise_rain_qsigma": Variant(
|
||||||
|
"rise_rain_qsigma", target="rise", use_rain=True, qsigma=True
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Dam variants are opt-in by name: they require dam columns that only exist
|
# Dam variants are opt-in by name: they require dam columns that only exist
|
||||||
@@ -160,8 +171,11 @@ VARIANTS: Dict[str, Variant] = {
|
|||||||
# the 2026-08-13 ablation concluded them a negative result. The 2026-09-12
|
# the 2026-08-13 ablation concluded them a negative result. The 2026-09-12
|
||||||
# experiments are opt-in too (see their results in docs/FLOOD_FORECASTING.md).
|
# experiments are opt-in too (see their results in docs/FLOOD_FORECASTING.md).
|
||||||
DEFAULT_VARIANTS = [
|
DEFAULT_VARIANTS = [
|
||||||
k for k, v in VARIANTS.items()
|
k
|
||||||
if not v.use_dam and not v.use_fc48 and not v.qsigma
|
for k, v in VARIANTS.items()
|
||||||
|
if not v.use_dam
|
||||||
|
and not v.use_fc48
|
||||||
|
and not v.qsigma
|
||||||
and not (v.quantile and v.use_rain)
|
and not (v.quantile and v.use_rain)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -212,19 +226,21 @@ def _first_alert_lead(
|
|||||||
window = p.loc[start : crossing + pd.Timedelta(hours=24)]
|
window = p.loc[start : crossing + pd.Timedelta(hours=24)]
|
||||||
if len(window) < 2:
|
if len(window) < 2:
|
||||||
return None
|
return None
|
||||||
alert = (window >= ALERT_P) & (window.shift(-1) >= ALERT_P) & (
|
alert = (
|
||||||
|
(window >= ALERT_P)
|
||||||
|
& (window.shift(-1) >= ALERT_P)
|
||||||
|
& (
|
||||||
(window.index.to_series().shift(-1) - window.index.to_series())
|
(window.index.to_series().shift(-1) - window.index.to_series())
|
||||||
<= pd.Timedelta(hours=2)
|
<= pd.Timedelta(hours=2)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
hits = window.index[alert.fillna(False)]
|
hits = window.index[alert.fillna(False)]
|
||||||
if len(hits) == 0:
|
if len(hits) == 0:
|
||||||
return None
|
return None
|
||||||
return float((crossing - hits[0]).total_seconds() / 3600.0)
|
return float((crossing - hits[0]).total_seconds() / 3600.0)
|
||||||
|
|
||||||
|
|
||||||
def _false_alarm_episodes(
|
def _false_alarm_episodes(p: pd.Series, observed: pd.Series, thr: float) -> int:
|
||||||
p: pd.Series, observed: pd.Series, thr: float
|
|
||||||
) -> int:
|
|
||||||
"""Alert episodes with no observed >=thr within +/- FALSE_ALARM_GRACE_H."""
|
"""Alert episodes with no observed >=thr within +/- FALSE_ALARM_GRACE_H."""
|
||||||
alert_hours = p[p >= ALERT_P].index
|
alert_hours = p[p >= ALERT_P].index
|
||||||
if len(alert_hours) == 0:
|
if len(alert_hours) == 0:
|
||||||
@@ -295,7 +311,9 @@ def evaluate_station(
|
|||||||
tr = (X_all.index <= train_end) & y_abs.notna()
|
tr = (X_all.index <= train_end) & y_abs.notna()
|
||||||
te = (X_all.index >= test_lo) & (X_all.index <= test_hi)
|
te = (X_all.index >= test_lo) & (X_all.index <= test_hi)
|
||||||
if tr.sum() < 5000 or te.sum() < 500:
|
if tr.sum() < 5000 or te.sum() < 500:
|
||||||
logger.info(f"{station} {year}: skipped (train {tr.sum()}, test {te.sum()})")
|
logger.info(
|
||||||
|
f"{station} {year}: skipped (train {tr.sum()}, test {te.sum()})"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
X_tr, X_te = X_all.loc[tr], X_all.loc[te]
|
X_tr, X_te = X_all.loc[tr], X_all.loc[te]
|
||||||
@@ -371,9 +389,7 @@ def evaluate_station(
|
|||||||
)
|
)
|
||||||
fold["variants"][name] = {
|
fold["variants"][name] = {
|
||||||
"mae": float(errors.mean()) if len(errors) else None,
|
"mae": float(errors.mean()) if len(errors) else None,
|
||||||
"mae_above_2p5": (
|
"mae_above_2p5": (float(errors[high].mean()) if high.any() else None),
|
||||||
float(errors[high].mean()) if high.any() else None
|
|
||||||
),
|
|
||||||
"brier_warn": brier,
|
"brier_warn": brier,
|
||||||
"events": event_rows,
|
"events": event_rows,
|
||||||
"false_alarm_episodes": _false_alarm_episodes(
|
"false_alarm_episodes": _false_alarm_episodes(
|
||||||
@@ -394,7 +410,8 @@ def summarize(results: Dict) -> str:
|
|||||||
lines.append(header)
|
lines.append(header)
|
||||||
for fold in results["folds"]:
|
for fold in results["folds"]:
|
||||||
for name, m in fold["variants"].items():
|
for name, m in fold["variants"].items():
|
||||||
events = " ".join(
|
events = (
|
||||||
|
" ".join(
|
||||||
f"[{e['crossing'][:10]}: "
|
f"[{e['crossing'][:10]}: "
|
||||||
f"{'—' if e['lead_h'] is None else format(e['lead_h'], '+.0f')}h"
|
f"{'—' if e['lead_h'] is None else format(e['lead_h'], '+.0f')}h"
|
||||||
+ (
|
+ (
|
||||||
@@ -404,7 +421,9 @@ def summarize(results: Dict) -> str:
|
|||||||
)
|
)
|
||||||
+ "]"
|
+ "]"
|
||||||
for e in m["events"]
|
for e in m["events"]
|
||||||
) or "no events"
|
)
|
||||||
|
or "no events"
|
||||||
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{name:16} {fold['year']:>5} "
|
f"{name:16} {fold['year']:>5} "
|
||||||
f"{m['mae'] if m['mae'] is not None else float('nan'):6.3f} "
|
f"{m['mae'] if m['mae'] is not None else float('nan'):6.3f} "
|
||||||
@@ -421,16 +440,22 @@ def main(argv=None) -> int:
|
|||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--stations", default="P.1")
|
parser.add_argument("--stations", default="P.1")
|
||||||
parser.add_argument("--db-url", default=None)
|
parser.add_argument("--db-url", default=None)
|
||||||
parser.add_argument("--variants", default=None,
|
parser.add_argument("--variants", default=None, help="comma list; default all")
|
||||||
help="comma list; default all")
|
|
||||||
parser.add_argument("--out", default="models/eval_variants.json")
|
parser.add_argument("--out", default="models/eval_variants.json")
|
||||||
parser.add_argument("--no-rain", action="store_true",
|
parser.add_argument(
|
||||||
help="skip loading the Open-Meteo rain series")
|
"--no-rain", action="store_true", help="skip loading the Open-Meteo rain series"
|
||||||
parser.add_argument("--no-dam", action="store_true",
|
)
|
||||||
help="skip loading the Mae Ngat reservoir series")
|
parser.add_argument(
|
||||||
parser.add_argument("--from-cache", action="store_true",
|
"--no-dam",
|
||||||
|
action="store_true",
|
||||||
|
help="skip loading the Mae Ngat reservoir series",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--from-cache",
|
||||||
|
action="store_true",
|
||||||
help="offline: read models/cache/ only (no DB, no API, "
|
help="offline: read models/cache/ only (no DB, no API, "
|
||||||
"no Open-Meteo refresh) -- reproducible reruns")
|
"no Open-Meteo refresh) -- reproducible reruns",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
+7
-4
@@ -65,7 +65,12 @@ def load_gauge_mean(
|
|||||||
"AND s.longitude BETWEEN :lon_lo AND :lon_hi "
|
"AND s.longitude BETWEEN :lon_lo AND :lon_hi "
|
||||||
"AND m.rain_1h IS NOT NULL"
|
"AND m.rain_1h IS NOT NULL"
|
||||||
)
|
)
|
||||||
params = {"lat_lo": lat_lo, "lat_hi": lat_hi, "lon_lo": lon_lo, "lon_hi": lon_hi}
|
params = {
|
||||||
|
"lat_lo": lat_lo,
|
||||||
|
"lat_hi": lat_hi,
|
||||||
|
"lon_lo": lon_lo,
|
||||||
|
"lon_hi": lon_hi,
|
||||||
|
}
|
||||||
if start is not None:
|
if start is not None:
|
||||||
query += " AND m.timestamp >= :start"
|
query += " AND m.timestamp >= :start"
|
||||||
params["start"] = pd.Timestamp(start).to_pydatetime()
|
params["start"] = pd.Timestamp(start).to_pydatetime()
|
||||||
@@ -98,9 +103,7 @@ def compare_with_openmeteo(
|
|||||||
Both are summed over trailing `window_h` so single-hour timing offsets
|
Both are summed over trailing `window_h` so single-hour timing offsets
|
||||||
(gauges report at :00, the model's hour is an interval) do not dominate.
|
(gauges report at :00, the model's hour is an interval) do not dominate.
|
||||||
"""
|
"""
|
||||||
joined = pd.concat(
|
joined = pd.concat({"gauge": gauge, "openmeteo": openmeteo}, axis=1).dropna()
|
||||||
{"gauge": gauge, "openmeteo": openmeteo}, axis=1
|
|
||||||
).dropna()
|
|
||||||
if joined.empty:
|
if joined.empty:
|
||||||
return {"overlap_hours": 0}
|
return {"overlap_hours": 0}
|
||||||
g = joined["gauge"].rolling(window_h, min_periods=window_h).sum()
|
g = joined["gauge"].rolling(window_h, min_periods=window_h).sum()
|
||||||
|
|||||||
+7
-5
@@ -182,14 +182,18 @@ def _model_forecast(
|
|||||||
)
|
)
|
||||||
p_warning = _sigmoid_probability(predicted_max, warn_thr, sigma_h)
|
p_warning = _sigmoid_probability(predicted_max, warn_thr, sigma_h)
|
||||||
if warn_head is not None:
|
if warn_head is not None:
|
||||||
p_warning = max(p_warning, float(warn_head.predict_proba(feature_row)[0][1]))
|
p_warning = max(
|
||||||
|
p_warning, float(warn_head.predict_proba(feature_row)[0][1])
|
||||||
|
)
|
||||||
|
|
||||||
danger_head = (
|
danger_head = (
|
||||||
None if thresholds_stale else bundle["heads"].get(f"danger_{horizon_h}")
|
None if thresholds_stale else bundle["heads"].get(f"danger_{horizon_h}")
|
||||||
)
|
)
|
||||||
p_danger = _sigmoid_probability(predicted_max, danger_thr, sigma_h)
|
p_danger = _sigmoid_probability(predicted_max, danger_thr, sigma_h)
|
||||||
if danger_head is not None:
|
if danger_head is not None:
|
||||||
p_danger = max(p_danger, float(danger_head.predict_proba(feature_row)[0][1]))
|
p_danger = max(
|
||||||
|
p_danger, float(danger_head.predict_proba(feature_row)[0][1])
|
||||||
|
)
|
||||||
|
|
||||||
p_warning = _clip_probability(p_warning)
|
p_warning = _clip_probability(p_warning)
|
||||||
p_danger = min(_clip_probability(p_danger), p_warning)
|
p_danger = min(_clip_probability(p_danger), p_warning)
|
||||||
@@ -400,6 +404,4 @@ def get_latest_forecasts(
|
|||||||
logger.warning("dam state unavailable; dam features will be NaN")
|
logger.warning("dam state unavailable; dam features will be NaN")
|
||||||
dam = pd.DataFrame()
|
dam = pd.DataFrame()
|
||||||
|
|
||||||
return get_forecasts(
|
return get_forecasts(readings_by_station, models_dir=models_dir, rain=rain, dam=dam)
|
||||||
readings_by_station, models_dir=models_dir, rain=rain, dam=dam
|
|
||||||
)
|
|
||||||
|
|||||||
+3
-8
@@ -121,12 +121,8 @@ def load_history(
|
|||||||
cursor = fetch_from.date()
|
cursor = fetch_from.date()
|
||||||
try:
|
try:
|
||||||
while cursor <= end:
|
while cursor <= end:
|
||||||
chunk_end = min(
|
chunk_end = min(datetime.date(cursor.year, 12, 31), end)
|
||||||
datetime.date(cursor.year, 12, 31), end
|
chunks.append(fetch_history(cursor.isoformat(), chunk_end.isoformat()))
|
||||||
)
|
|
||||||
chunks.append(
|
|
||||||
fetch_history(cursor.isoformat(), chunk_end.isoformat())
|
|
||||||
)
|
|
||||||
cursor = datetime.date(cursor.year + 1, 1, 1)
|
cursor = datetime.date(cursor.year + 1, 1, 1)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f"Open-Meteo history fetch failed: {error}")
|
logger.warning(f"Open-Meteo history fetch failed: {error}")
|
||||||
@@ -204,8 +200,7 @@ def save_to_db(df: pd.DataFrame, engine, db_type: str) -> int:
|
|||||||
cols = ["timestamp"] + point_cols + ["catchment_mean"]
|
cols = ["timestamp"] + point_cols + ["catchment_mean"]
|
||||||
placeholders = ", ".join(f":{c}" for c in cols)
|
placeholders = ", ".join(f":{c}" for c in cols)
|
||||||
updates = ", ".join(
|
updates = ", ".join(
|
||||||
f"{c} = "
|
f"{c} = " + (f"VALUES({c})" if db_type == "mysql" else f"EXCLUDED.{c}")
|
||||||
+ (f"VALUES({c})" if db_type == "mysql" else f"EXCLUDED.{c}")
|
|
||||||
for c in cols[1:]
|
for c in cols[1:]
|
||||||
)
|
)
|
||||||
if db_type == "mysql":
|
if db_type == "mysql":
|
||||||
|
|||||||
+2
-3
@@ -53,6 +53,7 @@ class RainUnavailableError(RuntimeError):
|
|||||||
overwrite the deployed v3 artifacts without anyone noticing.
|
overwrite the deployed v3 artifacts without anyone noticing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
HGB_PARAMS = {
|
HGB_PARAMS = {
|
||||||
"max_iter": 300,
|
"max_iter": 300,
|
||||||
"learning_rate": 0.06,
|
"learning_rate": 0.06,
|
||||||
@@ -518,9 +519,7 @@ def train_all(
|
|||||||
"training v3-style bundles WITHOUT dam features"
|
"training v3-style bundles WITHOUT dam features"
|
||||||
)
|
)
|
||||||
if dam_frame is not None:
|
if dam_frame is not None:
|
||||||
logger.info(
|
logger.info(f"dam series: {dam_frame.index.min()} .. {dam_frame.index.max()}")
|
||||||
f"dam series: {dam_frame.index.min()} .. {dam_frame.index.max()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run-level version: v4 only if some requested station actually receives
|
# Run-level version: v4 only if some requested station actually receives
|
||||||
# dam columns (they are gated to DAM_STATIONS; per-bundle versions are
|
# dam columns (they are gated to DAM_STATIONS; per-bundle versions are
|
||||||
|
|||||||
@@ -402,9 +402,7 @@ class RidReservoirStore:
|
|||||||
measure_row = {
|
measure_row = {
|
||||||
c: _bounded(record.get(c), _MEASURE_BOUNDS[c]) for c in measure_cols
|
c: _bounded(record.get(c), _MEASURE_BOUNDS[c]) for c in measure_cols
|
||||||
}
|
}
|
||||||
measure_row.update(
|
measure_row.update({"dam_id": record["dam_id"], "date": record["date"]})
|
||||||
{"dam_id": record["dam_id"], "date": record["date"]}
|
|
||||||
)
|
|
||||||
measurements.append(measure_row)
|
measurements.append(measure_row)
|
||||||
try:
|
try:
|
||||||
with self.engine.begin() as conn:
|
with self.engine.begin() as conn:
|
||||||
@@ -495,9 +493,7 @@ def backfill(
|
|||||||
if not store.engine and not store.connect():
|
if not store.engine and not store.connect():
|
||||||
logger.error("backfill aborted: database connection failed")
|
logger.error("backfill aborted: database connection failed")
|
||||||
return 0
|
return 0
|
||||||
span = [
|
span = [start + datetime.timedelta(days=i) for i in range((end - start).days + 1)]
|
||||||
start + datetime.timedelta(days=i) for i in range((end - start).days + 1)
|
|
||||||
]
|
|
||||||
present = store.present_dates(start, end)
|
present = store.present_dates(start, end)
|
||||||
targets = [d for d in span if d not in present]
|
targets = [d for d in span if d not in present]
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
+175
-53
@@ -33,6 +33,57 @@
|
|||||||
--red: #cc4b37;
|
--red: #cc4b37;
|
||||||
--border: #dce7e3;
|
--border: #dce7e3;
|
||||||
--shadow: 0 16px 40px rgba(23, 57, 67, .10);
|
--shadow: 0 16px 40px rgba(23, 57, 67, .10);
|
||||||
|
--surface: #ffffff; /* buttons, inputs, overlays */
|
||||||
|
--surface-2: #f1f7f5; /* hover rows, popup metrics */
|
||||||
|
--surface-3: #f7fbfa; /* outlook box */
|
||||||
|
--overlay: rgba(255,255,255,.93);
|
||||||
|
--overlay-border: rgba(207,224,218,.9);
|
||||||
|
--map-bg: #dcebea;
|
||||||
|
--glow: rgba(56, 180, 213, .12);
|
||||||
|
--map-filter: none;
|
||||||
|
--chart-grid: rgba(19,43,53,.08);
|
||||||
|
--mint-ink: #146644;
|
||||||
|
--mint-border: #b6dfc9;
|
||||||
|
--ok-bg: #e2f3ea; --ok-ink: #0c5138; --ok-border: #bfe3d2;
|
||||||
|
--watch-bg: #fdf1dc; --watch-ink: #6b4a05; --watch-border: #f0d9a8;
|
||||||
|
--danger-bg: #fbe3de; --danger-ink: #7c1d10; --danger-border: #f2c0b6;
|
||||||
|
--demo-bg: #fdeeda; --demo-ink: #9a6200;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
/* Dark theme: same hues, inverted lightness. Flow/rain/marker colours
|
||||||
|
on the map are data encodings and stay identical in both themes. */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--ink: #e6eef1;
|
||||||
|
--muted: #97a9b0;
|
||||||
|
--paper: #0f1a1f;
|
||||||
|
--card: #16252c;
|
||||||
|
--river: #3fb3d8;
|
||||||
|
--river-light: #5ccbe8;
|
||||||
|
--mint: #143b2c;
|
||||||
|
--green: #3fb07f;
|
||||||
|
--amber: #e6a83a;
|
||||||
|
--red: #e46a55;
|
||||||
|
--border: #27393f;
|
||||||
|
--shadow: 0 16px 40px rgba(0, 0, 0, .45);
|
||||||
|
--surface: #1c2c33;
|
||||||
|
--surface-2: #213238;
|
||||||
|
--surface-3: #1a2a30;
|
||||||
|
--overlay: rgba(22,37,44,.92);
|
||||||
|
--overlay-border: rgba(64,86,94,.9);
|
||||||
|
--map-bg: #1a262b;
|
||||||
|
--glow: rgba(63, 179, 216, .10);
|
||||||
|
/* OSM tiles are light; invert + rotate keeps roads/labels legible
|
||||||
|
while the water/river overlays (drawn in SVG, unfiltered) keep
|
||||||
|
their real colours. */
|
||||||
|
--map-filter: invert(1) hue-rotate(180deg) brightness(.92) contrast(.9) saturate(.75);
|
||||||
|
--chart-grid: rgba(230,238,241,.10);
|
||||||
|
--mint-ink: #7fd7ac;
|
||||||
|
--mint-border: #2a5c45;
|
||||||
|
--ok-bg: #143b2c; --ok-ink: #9fe3c3; --ok-border: #2a6b4d;
|
||||||
|
--watch-bg: #3d2f0d; --watch-ink: #f3d489; --watch-border: #6b4f14;
|
||||||
|
--danger-bg: #421c15; --danger-ink: #ffb3a6; --danger-border: #7a2f22;
|
||||||
|
--demo-bg: #3d2f0d; --demo-ink: #f3d489;
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; min-height: 100%; }
|
html, body { margin: 0; min-height: 100%; }
|
||||||
@@ -43,7 +94,7 @@
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 8% 0%, rgba(56, 180, 213, .12), transparent 25rem),
|
radial-gradient(circle at 8% 0%, var(--glow), transparent 25rem),
|
||||||
var(--paper);
|
var(--paper);
|
||||||
/* Thai families first so Thai text uses a proper Thai face where one
|
/* Thai families first so Thai text uses a proper Thai face where one
|
||||||
is installed (Android/iOS/Windows all ship one); Latin falls
|
is installed (Android/iOS/Windows all ship one); Latin falls
|
||||||
@@ -60,7 +111,13 @@
|
|||||||
html[lang="th"] body { line-break: normal; overflow-wrap: anywhere; }
|
html[lang="th"] body { line-break: normal; overflow-wrap: anywhere; }
|
||||||
html[lang="th"] .stat-value, html[lang="th"] .flow-value { overflow-wrap: normal; }
|
html[lang="th"] .stat-value, html[lang="th"] .flow-value { overflow-wrap: normal; }
|
||||||
.lang-toggle { padding: 9px 12px; font-size: .78rem; font-weight: 800; white-space: nowrap; }
|
.lang-toggle { padding: 9px 12px; font-size: .78rem; font-weight: 800; white-space: nowrap; }
|
||||||
.lang-toggle[data-active-lang="th"] { background: var(--mint); border-color: #b6dfc9; color: #146644; }
|
.lang-toggle[data-active-lang="th"] { background: var(--mint); border-color: var(--mint-border); color: var(--mint-ink); }
|
||||||
|
.theme-toggle { padding: 9px 11px; font-size: .95rem; line-height: 1; }
|
||||||
|
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--card); color: var(--ink); }
|
||||||
|
.leaflet-container a.leaflet-popup-close-button { color: var(--muted); }
|
||||||
|
.leaflet-bar a, .leaflet-control-attribution { background: var(--surface); color: var(--ink); border-color: var(--border); }
|
||||||
|
.leaflet-control-attribution a { color: var(--river); }
|
||||||
|
[data-theme="dark"] .leaflet-control-attribution { background: var(--overlay); }
|
||||||
.shell { max-width: 1500px; margin: 0 auto; padding: 24px; }
|
.shell { max-width: 1500px; margin: 0 auto; padding: 24px; }
|
||||||
header {
|
header {
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 20px;
|
display: flex; align-items: center; justify-content: space-between; gap: 20px;
|
||||||
@@ -78,16 +135,16 @@
|
|||||||
.header-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
.header-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
.live-pill {
|
.live-pill {
|
||||||
display: flex; gap: 8px; align-items: center; padding: 9px 13px; border-radius: 999px;
|
display: flex; gap: 8px; align-items: center; padding: 9px 13px; border-radius: 999px;
|
||||||
background: var(--mint); color: #146644; font-weight: 750; font-size: .8rem;
|
background: var(--mint); color: var(--mint-ink); font-weight: 750; font-size: .8rem;
|
||||||
}
|
}
|
||||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22a66c; box-shadow: 0 0 0 5px rgba(34,166,108,.12); }
|
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22a66c; box-shadow: 0 0 0 5px rgba(34,166,108,.12); }
|
||||||
.live-pill.demo { background: #fdeeda; color: #9a6200; }
|
.live-pill.demo { background: var(--demo-bg); color: var(--demo-ink); }
|
||||||
.live-pill.demo .live-dot { background: #e6a23c; box-shadow: 0 0 0 5px rgba(230,162,60,.15); }
|
.live-pill.demo .live-dot { background: #e6a23c; box-shadow: 0 0 0 5px rgba(230,162,60,.15); }
|
||||||
button {
|
button {
|
||||||
border: 1px solid var(--border); border-radius: 11px; background: white; color: var(--ink);
|
border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--ink);
|
||||||
padding: 10px 14px; cursor: pointer; font-weight: 700; box-shadow: 0 3px 10px rgba(22,52,62,.05);
|
padding: 10px 14px; cursor: pointer; font-weight: 700; box-shadow: 0 3px 10px rgba(22,52,62,.05);
|
||||||
}
|
}
|
||||||
button:hover { border-color: #a9c4bb; transform: translateY(-1px); }
|
button:hover { border-color: var(--river-light); transform: translateY(-1px); }
|
||||||
button:disabled { opacity: .55; cursor: wait; transform: none; }
|
button:disabled { opacity: .55; cursor: wait; transform: none; }
|
||||||
.stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-bottom: 14px; }
|
.stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-bottom: 14px; }
|
||||||
.stat {
|
.stat {
|
||||||
@@ -97,16 +154,19 @@
|
|||||||
.stat-label { color: var(--muted); text-transform: uppercase; letter-spacing: .09em; font-size: .68rem; font-weight: 800; }
|
.stat-label { color: var(--muted); text-transform: uppercase; letter-spacing: .09em; font-size: .68rem; font-weight: 800; }
|
||||||
.stat-value { margin-top: 9px; font-size: 1.65rem; font-weight: 800; letter-spacing: -.04em; white-space: nowrap; }
|
.stat-value { margin-top: 9px; font-size: 1.65rem; font-weight: 800; letter-spacing: -.04em; white-space: nowrap; }
|
||||||
.stat-note { color: var(--muted); margin-top: 3px; font-size: .77rem; }
|
.stat-note { color: var(--muted); margin-top: 3px; font-size: .77rem; }
|
||||||
|
.stat.stale { border-color: var(--red); background: rgba(204,75,55,.08); }
|
||||||
|
.stat.stale .stat-value, .stat.stale .stat-note { color: var(--red); }
|
||||||
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; min-height: 640px; }
|
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; min-height: 640px; }
|
||||||
.map-card, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; }
|
.map-card, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; }
|
||||||
.map-card { position: relative; }
|
.map-card { position: relative; }
|
||||||
#station-map { height: 640px; width: 100%; background: #dcebea; }
|
#station-map { height: 640px; width: 100%; background: var(--map-bg); }
|
||||||
|
.leaflet-tile-pane { filter: var(--map-filter); }
|
||||||
.map-overlay {
|
.map-overlay {
|
||||||
position: absolute; z-index: 500; top: 16px; left: 52px; right: 16px;
|
position: absolute; z-index: 500; top: 16px; left: 52px; right: 16px;
|
||||||
display: flex; justify-content: space-between; align-items: flex-start; pointer-events: none;
|
display: flex; justify-content: space-between; align-items: flex-start; pointer-events: none;
|
||||||
}
|
}
|
||||||
.map-heading, .legend {
|
.map-heading, .legend {
|
||||||
background: rgba(255,255,255,.93); backdrop-filter: blur(9px); border: 1px solid rgba(207,224,218,.9);
|
background: var(--overlay); backdrop-filter: blur(9px); border: 1px solid var(--overlay-border);
|
||||||
border-radius: 13px; padding: 11px 13px; box-shadow: 0 7px 20px rgba(22,58,68,.12);
|
border-radius: 13px; padding: 11px 13px; box-shadow: 0 7px 20px rgba(22,58,68,.12);
|
||||||
pointer-events: auto; /* .map-overlay disables events; re-enable for the legend's rain toggle */
|
pointer-events: auto; /* .map-overlay disables events; re-enable for the legend's rain toggle */
|
||||||
}
|
}
|
||||||
@@ -128,7 +188,7 @@
|
|||||||
width: 100%; display: grid; grid-template-columns: 40px minmax(0,1fr) auto; gap: 10px; align-items: center;
|
width: 100%; display: grid; grid-template-columns: 40px minmax(0,1fr) auto; gap: 10px; align-items: center;
|
||||||
padding: 11px; border: 0; border-radius: 12px; box-shadow: none; text-align: left; background: transparent;
|
padding: 11px; border: 0; border-radius: 12px; box-shadow: none; text-align: left; background: transparent;
|
||||||
}
|
}
|
||||||
.station-row:hover { background: #f1f7f5; transform: none; }
|
.station-row:hover { background: var(--surface-2); transform: none; }
|
||||||
.station-code { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 11px; color: white; font-size: .68rem; font-weight: 850; }
|
.station-code { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 11px; color: white; font-size: .68rem; font-weight: 850; }
|
||||||
.station-code.long { font-size: .55rem; word-break: break-all; line-height: 1.15; padding: 2px; text-align: center; }
|
.station-code.long { font-size: .55rem; word-break: break-all; line-height: 1.15; padding: 2px; text-align: center; }
|
||||||
.station-name { overflow: hidden; }
|
.station-name { overflow: hidden; }
|
||||||
@@ -137,9 +197,9 @@
|
|||||||
.station-name span { color: var(--muted); font-size: .68rem; margin-top: 3px; }
|
.station-name span { color: var(--muted); font-size: .68rem; margin-top: 3px; }
|
||||||
.flow-value { text-align: right; font-size: .82rem; font-weight: 800; }
|
.flow-value { text-align: right; font-size: .82rem; font-weight: 800; }
|
||||||
.flow-value span { display: block; color: var(--muted); font-size: .61rem; font-weight: 650; margin-top: 2px; }
|
.flow-value span { display: block; color: var(--muted); font-size: .61rem; font-weight: 650; margin-top: 2px; }
|
||||||
.loading-panel, .error-panel { position: absolute; z-index: 600; inset: 0; display: grid; place-items: center; background: rgba(243,247,245,.88); }
|
.loading-panel, .error-panel { position: absolute; z-index: 600; inset: 0; display: grid; place-items: center; background: var(--overlay); }
|
||||||
.loading-card { background: white; padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
|
.loading-card { background: var(--card); padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
|
||||||
.error-panel { display: none; color: #8d2f22; text-align: center; padding: 25px; }
|
.error-panel { display: none; color: var(--red); text-align: center; padding: 25px; }
|
||||||
.marker-wrap { background: none; border: 0; }
|
.marker-wrap { background: none; border: 0; }
|
||||||
.flow-marker {
|
.flow-marker {
|
||||||
--marker-color: #087da5; --marker-size: 26px;
|
--marker-color: #087da5; --marker-size: 26px;
|
||||||
@@ -179,7 +239,7 @@
|
|||||||
.risk-chips { display: flex; gap: 6px; }
|
.risk-chips { display: flex; gap: 6px; }
|
||||||
.risk-chip { flex: 1; text-align: center; border-radius: 8px; padding: 5px 4px; font-size: .64rem; font-weight: 800; color: white; }
|
.risk-chip { flex: 1; text-align: center; border-radius: 8px; padding: 5px 4px; font-size: .64rem; font-weight: 800; color: white; }
|
||||||
.risk-chip span { display: block; font-weight: 650; font-size: .58rem; opacity: .85; }
|
.risk-chip span { display: block; font-weight: 650; font-size: .58rem; opacity: .85; }
|
||||||
.p1-outlook { border: 1px solid var(--border); border-left: 4px solid var(--river); border-radius: 12px; padding: 12px 14px; margin-top: 14px; background: #f7fbfa; }
|
.p1-outlook { border: 1px solid var(--border); border-left: 4px solid var(--river); border-radius: 12px; padding: 12px 14px; margin-top: 14px; background: var(--surface-3); }
|
||||||
.p1-outlook-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.p1-outlook-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
.p1-outlook-head strong { font-size: .88rem; }
|
.p1-outlook-head strong { font-size: .88rem; }
|
||||||
.p1-peak { color: var(--muted); font-size: .76rem; }
|
.p1-peak { color: var(--muted); font-size: .76rem; }
|
||||||
@@ -193,16 +253,16 @@
|
|||||||
.popup h3 { margin: 4px 0 2px; font-size: 1rem; }
|
.popup h3 { margin: 4px 0 2px; font-size: 1rem; }
|
||||||
.popup-th { color: var(--muted); font-size: .74rem; }
|
.popup-th { color: var(--muted); font-size: .74rem; }
|
||||||
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
|
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
|
||||||
.popup-metric { background: #f1f7f5; padding: 8px; border-radius: 9px; }
|
.popup-metric { background: var(--surface-2); padding: 8px; border-radius: 9px; }
|
||||||
.popup-metric span { display: block; color: var(--muted); font-size: .62rem; }
|
.popup-metric span { display: block; color: var(--muted); font-size: .62rem; }
|
||||||
.popup-metric strong { display: block; margin-top: 2px; font-size: .86rem; }
|
.popup-metric strong { display: block; margin-top: 2px; font-size: .86rem; }
|
||||||
.popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; }
|
.popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; }
|
||||||
/* Verdict banner: light tinted backgrounds with dark ink (contrast-safe) */
|
/* Verdict banner: light tinted backgrounds with dark ink (contrast-safe) */
|
||||||
#flood-verdict.ok { background: #e2f3ea; color: #0c5138; border-color: #bfe3d2; }
|
#flood-verdict.ok { background: var(--ok-bg); color: var(--ok-ink); border-color: var(--ok-border); }
|
||||||
#flood-verdict.watch { background: #fdf1dc; color: #6b4a05; border-color: #f0d9a8; }
|
#flood-verdict.watch { background: var(--watch-bg); color: var(--watch-ink); border-color: var(--watch-border); }
|
||||||
#flood-verdict.danger { background: #fbe3de; color: #7c1d10; border-color: #f2c0b6; }
|
#flood-verdict.danger { background: var(--danger-bg); color: var(--danger-ink); border-color: var(--danger-border); }
|
||||||
#legend-toggle {
|
#legend-toggle {
|
||||||
display: none; pointer-events: auto; background: rgba(255,255,255,.93); border: 1px solid rgba(207,224,218,.9);
|
display: none; pointer-events: auto; background: var(--overlay); border: 1px solid var(--overlay-border);
|
||||||
border-radius: 11px; padding: 8px 12px; font-size: .72rem; font-weight: 800; color: var(--ink);
|
border-radius: 11px; padding: 8px 12px; font-size: .72rem; font-weight: 800; color: var(--ink);
|
||||||
box-shadow: 0 7px 20px rgba(22,58,68,.12);
|
box-shadow: 0 7px 20px rgba(22,58,68,.12);
|
||||||
}
|
}
|
||||||
@@ -275,6 +335,7 @@
|
|||||||
<div class="live-pill" id="live-pill"><span class="live-dot"></span> <span id="live-pill-text" data-i18n="pill.live">LIVE DATA</span></div>
|
<div class="live-pill" id="live-pill"><span class="live-dot"></span> <span id="live-pill-text" data-i18n="pill.live">LIVE DATA</span></div>
|
||||||
<button id="refresh-button" type="button" data-i18n="action.refresh">↻ Refresh</button>
|
<button id="refresh-button" type="button" data-i18n="action.refresh">↻ Refresh</button>
|
||||||
<button id="lang-toggle" class="lang-toggle" type="button" data-active-lang="en" aria-label="Switch to Thai">ไทย</button>
|
<button id="lang-toggle" class="lang-toggle" type="button" data-active-lang="en" aria-label="Switch to Thai">ไทย</button>
|
||||||
|
<button id="theme-toggle" class="theme-toggle" type="button" data-i18n-aria="theme.toggle" aria-label="Switch to dark mode" title="Switch to dark mode">🌙</button>
|
||||||
<button id="replay-2024" type="button" data-i18n="replay.start">▶ Replay Oct 2024 flood</button>
|
<button id="replay-2024" type="button" data-i18n="replay.start">▶ Replay Oct 2024 flood</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -321,7 +382,7 @@
|
|||||||
<div class="legend-row"><i class="swatch" style="background:#2a1668;border-radius:50%"></i> <span data-i18n="legend.rain.extreme">Extreme > 150</span></div>
|
<div class="legend-row"><i class="swatch" style="background:#2a1668;border-radius:50%"></i> <span data-i18n="legend.rain.extreme">Extreme > 150</span></div>
|
||||||
<div class="legend-title" style="margin-top:10px" data-i18n="legend.other">Other markers</div>
|
<div class="legend-title" style="margin-top:10px" data-i18n="legend.other">Other markers</div>
|
||||||
<div class="legend-row"><i class="swatch" style="background:#7b8f94"></i> <span data-i18n="legend.other.nodata">Gauge · no recent data</span></div>
|
<div class="legend-row"><i class="swatch" style="background:#7b8f94"></i> <span data-i18n="legend.other.nodata">Gauge · no recent data</span></div>
|
||||||
<div class="legend-row"><span style="font-weight:800;color:#0f6844">+</span> <span data-i18n="legend.other.sensor">Water-level sensor · colour = % of bank height</span></div>
|
<div class="legend-row"><span style="font-weight:800;color:var(--green)">+</span> <span data-i18n="legend.other.sensor">Water-level sensor · colour = % of bank height</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,7 +393,7 @@
|
|||||||
|
|
||||||
<aside class="side-card">
|
<aside class="side-card">
|
||||||
<div class="side-head"><h2 data-i18n="side.title">Current station flow</h2><p data-i18n="side.subtitle">Select a station to locate it and load its history</p>
|
<div class="side-head"><h2 data-i18n="side.title">Current station flow</h2><p data-i18n="side.subtitle">Select a station to locate it and load its history</p>
|
||||||
<input type="search" id="station-search" data-i18n-placeholder="search.placeholder" data-i18n-aria="search.aria" placeholder="Search stations · code, name, river…" aria-label="Search stations" style="margin-top:10px;width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:white;font-size:.8rem">
|
<input type="search" id="station-search" data-i18n-placeholder="search.placeholder" data-i18n-aria="search.aria" placeholder="Search stations · code, name, river…" aria-label="Search stations" style="margin-top:10px;width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--ink);font-size:.8rem">
|
||||||
</div>
|
</div>
|
||||||
<div class="station-list" id="river-flow" aria-live="polite"></div>
|
<div class="station-list" id="river-flow" aria-live="polite"></div>
|
||||||
<div class="side-head" id="sensors-head" style="cursor:pointer" role="button" tabindex="0" aria-expanded="false" aria-controls="thaiwater-sensors" data-i18n-title="sensors.tip" title="Show / hide the ThaiWater/HII station list"><h2><span data-i18n="sensors.title">Additional basin stations</span> <span id="sensors-arrow" style="color:var(--muted);font-size:.8rem">▸</span></h2><p id="thaiwater-count" data-i18n="sensors.loading">Loading ThaiWater/HII stations…</p></div>
|
<div class="side-head" id="sensors-head" style="cursor:pointer" role="button" tabindex="0" aria-expanded="false" aria-controls="thaiwater-sensors" data-i18n-title="sensors.tip" title="Show / hide the ThaiWater/HII station list"><h2><span data-i18n="sensors.title">Additional basin stations</span> <span id="sensors-arrow" style="color:var(--muted);font-size:.8rem">▸</span></h2><p id="thaiwater-count" data-i18n="sensors.loading">Loading ThaiWater/HII stations…</p></div>
|
||||||
@@ -363,10 +424,10 @@
|
|||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
|
||||||
<div><h2 id="history-title" style="margin:0;font-size:1rem" data-i18n="history.title">Station history</h2><p id="history-status" class="subtitle" data-i18n="history.status">Select a station to load the last 7 days</p></div>
|
<div><h2 id="history-title" style="margin:0;font-size:1rem" data-i18n="history.title">Station history</h2><p id="history-status" class="subtitle" data-i18n="history.status">Select a station to load the last 7 days</p></div>
|
||||||
<div class="history-controls" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
<div class="history-controls" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<select id="history-range" style="padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:white"><option value="24" data-i18n="range.24h">Last 24 hours</option><option value="168" selected data-i18n="range.7d">Last 7 days</option><option value="720" data-i18n="range.30d">Last 30 days</option><option value="2160" data-i18n="range.90d">Last 90 days</option><option value="876000" data-i18n="range.all">All time</option><option value="custom" hidden data-i18n="range.custom">Custom range</option></select>
|
<select id="history-range" style="padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--ink)"><option value="24" data-i18n="range.24h">Last 24 hours</option><option value="168" selected data-i18n="range.7d">Last 7 days</option><option value="720" data-i18n="range.30d">Last 30 days</option><option value="2160" data-i18n="range.90d">Last 90 days</option><option value="876000" data-i18n="range.all">All time</option><option value="custom" hidden data-i18n="range.custom">Custom range</option></select>
|
||||||
<input type="date" id="history-start" data-i18n-title="range.from" title="From date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:white">
|
<input type="date" id="history-start" data-i18n-title="range.from" title="From date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--ink)">
|
||||||
<span style="color:var(--muted)">–</span>
|
<span style="color:var(--muted)">–</span>
|
||||||
<input type="date" id="history-end" data-i18n-title="range.to" title="To date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:white">
|
<input type="date" id="history-end" data-i18n-title="range.to" title="To date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--ink)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="height:260px;margin-top:14px;overflow:hidden;position:relative"><canvas id="history-chart" data-i18n-aria="aria.chart" aria-label="Historical water level and discharge chart" style="display:block"></canvas><div id="history-placeholder" data-i18n="history.placeholder" style="position:absolute;inset:0;display:grid;place-items:center;color:var(--muted);font-size:.85rem;text-align:center;padding:20px">Click any station on the map or in the list to see its history</div></div>
|
<div style="height:260px;margin-top:14px;overflow:hidden;position:relative"><canvas id="history-chart" data-i18n-aria="aria.chart" aria-label="Historical water level and discharge chart" style="display:block"></canvas><div id="history-placeholder" data-i18n="history.placeholder" style="position:absolute;inset:0;display:grid;place-items:center;color:var(--muted);font-size:.85rem;text-align:center;padding:20px">Click any station on the map or in the list to see its history</div></div>
|
||||||
@@ -407,6 +468,8 @@
|
|||||||
'app.title': 'Ping River Live Monitor',
|
'app.title': 'Ping River Live Monitor',
|
||||||
'app.subtitle': 'Current water level and discharge across Northern Thailand',
|
'app.subtitle': 'Current water level and discharge across Northern Thailand',
|
||||||
'pill.live': 'LIVE DATA',
|
'pill.live': 'LIVE DATA',
|
||||||
|
'theme.toggle': 'Toggle dark mode',
|
||||||
|
'pill.stale': '⚠ STALE FEED',
|
||||||
'pill.replay': '⏪ 2024 REPLAY',
|
'pill.replay': '⏪ 2024 REPLAY',
|
||||||
'pill.sim': '⚠ SIMULATION',
|
'pill.sim': '⚠ SIMULATION',
|
||||||
'action.refresh': '↻ Refresh',
|
'action.refresh': '↻ Refresh',
|
||||||
@@ -439,7 +502,9 @@
|
|||||||
'stat.stress.tip': 'How full the river channel is at the busiest gauge — 100% means water reaches the top of the bank',
|
'stat.stress.tip': 'How full the river channel is at the busiest gauge — 100% means water reaches the top of the bank',
|
||||||
'stat.updated': 'Last updated',
|
'stat.updated': 'Last updated',
|
||||||
'stat.updated.note': 'Loading latest readings',
|
'stat.updated.note': 'Loading latest readings',
|
||||||
'stat.updated.ago': (date, mins) => `${date} · ${mins} min ago`,
|
'stat.updated.ago': (date, mins) => `${date} · ${mins} min ago (ICT)`,
|
||||||
|
'stat.updated.agoh': (date, hours) => `${date} · ${hours} h ago (ICT)`,
|
||||||
|
'stat.updated.stale': (date, hours) => `⚠ Feed stale · last reading ${date}, ${hours} h ago`,
|
||||||
'stat.updated.none': 'No timestamp available',
|
'stat.updated.none': 'No timestamp available',
|
||||||
'map.title': 'Station flow map',
|
'map.title': 'Station flow map',
|
||||||
'map.subtitle': 'River width, colour & dash speed follow live discharge',
|
'map.subtitle': 'River width, colour & dash speed follow live discharge',
|
||||||
@@ -579,6 +644,8 @@
|
|||||||
'app.title': 'ติดตามระดับน้ำปิงแบบเรียลไทม์',
|
'app.title': 'ติดตามระดับน้ำปิงแบบเรียลไทม์',
|
||||||
'app.subtitle': 'ระดับน้ำและอัตราการไหลปัจจุบันทั่วภาคเหนือของประเทศไทย',
|
'app.subtitle': 'ระดับน้ำและอัตราการไหลปัจจุบันทั่วภาคเหนือของประเทศไทย',
|
||||||
'pill.live': 'ข้อมูลสด',
|
'pill.live': 'ข้อมูลสด',
|
||||||
|
'theme.toggle': 'สลับโหมดมืด/สว่าง',
|
||||||
|
'pill.stale': '⚠ ข้อมูลไม่อัปเดต',
|
||||||
'pill.replay': '⏪ ย้อนเหตุการณ์ 2567',
|
'pill.replay': '⏪ ย้อนเหตุการณ์ 2567',
|
||||||
'pill.sim': '⚠ การจำลอง',
|
'pill.sim': '⚠ การจำลอง',
|
||||||
'action.refresh': '↻ รีเฟรช',
|
'action.refresh': '↻ รีเฟรช',
|
||||||
@@ -611,7 +678,9 @@
|
|||||||
'stat.stress.tip': 'ระดับความเต็มของลำน้ำที่สถานีที่มีน้ำมากที่สุด — 100% หมายถึงน้ำถึงระดับตลิ่ง',
|
'stat.stress.tip': 'ระดับความเต็มของลำน้ำที่สถานีที่มีน้ำมากที่สุด — 100% หมายถึงน้ำถึงระดับตลิ่ง',
|
||||||
'stat.updated': 'อัปเดตล่าสุด',
|
'stat.updated': 'อัปเดตล่าสุด',
|
||||||
'stat.updated.note': 'กำลังโหลดข้อมูลล่าสุด',
|
'stat.updated.note': 'กำลังโหลดข้อมูลล่าสุด',
|
||||||
'stat.updated.ago': (date, mins) => `${date} · ${mins} นาทีที่แล้ว`,
|
'stat.updated.ago': (date, mins) => `${date} · ${mins} นาทีที่แล้ว (เวลาไทย)`,
|
||||||
|
'stat.updated.agoh': (date, hours) => `${date} · ${hours} ชั่วโมงที่แล้ว (เวลาไทย)`,
|
||||||
|
'stat.updated.stale': (date, hours) => `⚠ ข้อมูลไม่อัปเดต · ค่าล่าสุด ${date}, ${hours} ชั่วโมงที่แล้ว`,
|
||||||
'stat.updated.none': 'ไม่มีข้อมูลเวลา',
|
'stat.updated.none': 'ไม่มีข้อมูลเวลา',
|
||||||
'map.title': 'แผนที่การไหลของน้ำ',
|
'map.title': 'แผนที่การไหลของน้ำ',
|
||||||
'map.subtitle': 'ความกว้าง สี และความเร็วเส้นประของแม่น้ำแสดงอัตราการไหลจริง',
|
'map.subtitle': 'ความกว้าง สี และความเร็วเส้นประของแม่น้ำแสดงอัตราการไหลจริง',
|
||||||
@@ -801,6 +870,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme: explicit choice persists; otherwise follow the OS and track its changes.
|
||||||
|
const THEME_KEY = 'ping-river-theme';
|
||||||
|
const osDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
function currentTheme() { return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; }
|
||||||
|
function applyTheme(theme, persist) {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
const button = $('theme-toggle');
|
||||||
|
if (button) {
|
||||||
|
button.textContent = theme === 'dark' ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
if (persist) { try { localStorage.setItem(THEME_KEY, theme); } catch (e) { /* private mode */ } }
|
||||||
|
// Chart.js reads colours at construction: rebuild the open chart
|
||||||
|
if (state.historyChart && state.selectedStation) loadHistory(state.selectedStation);
|
||||||
|
}
|
||||||
|
(() => {
|
||||||
|
let saved = null;
|
||||||
|
try { saved = localStorage.getItem(THEME_KEY); } catch (e) { /* private mode */ }
|
||||||
|
applyTheme(saved === 'dark' || saved === 'light' ? saved : (osDark.matches ? 'dark' : 'light'), false);
|
||||||
|
osDark.addEventListener('change', (e) => {
|
||||||
|
let pinned = null;
|
||||||
|
try { pinned = localStorage.getItem(THEME_KEY); } catch (err) { /* ignore */ }
|
||||||
|
if (!pinned) applyTheme(e.matches ? 'dark' : 'light', false);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||||
|
|
||||||
function setLang(lang) {
|
function setLang(lang) {
|
||||||
state.lang = lang;
|
state.lang = lang;
|
||||||
try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ }
|
try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ }
|
||||||
@@ -841,6 +936,25 @@
|
|||||||
// what the replay label ("ต.ค. 2567") already says — pinning Gregorian here
|
// what the replay label ("ต.ค. 2567") already says — pinning Gregorian here
|
||||||
// put two different year systems on the same screen.
|
// put two different year systems on the same screen.
|
||||||
function loc() { return state.lang === 'th' ? 'th-TH' : 'en-GB'; }
|
function loc() { return state.lang === 'th' ? 'th-TH' : 'en-GB'; }
|
||||||
|
// Every timestamp the API emits is Asia/Bangkok wall-clock WITHOUT an
|
||||||
|
// offset ("2026-09-12T02:00:00"). new Date() on such a string uses the
|
||||||
|
// browser's own zone, so a viewer in Europe read a 02:00 ICT reading as
|
||||||
|
// five hours in the future and saw "0 min ago" forever. Pin the offset
|
||||||
|
// here and always format with timeZone: TZ so the site shows river time
|
||||||
|
// no matter where it is opened.
|
||||||
|
const TZ = 'Asia/Bangkok';
|
||||||
|
const STALE_AFTER_MIN = 180;
|
||||||
|
function parseTs(value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
const text = String(value).trim();
|
||||||
|
const naive = /^\d{4}-\d\d-\d\d[T ]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(text);
|
||||||
|
const date = new Date(naive ? text.replace(' ', 'T') + '+07:00' : text);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
// Bucket keys in river-local time (a Bangkok calendar day, not a UTC one)
|
||||||
|
const dayKey = (value) => parseTs(value).toLocaleDateString('en-CA', { timeZone: TZ });
|
||||||
|
const hourKey = (value) => `${dayKey(value)}-${parseTs(value).toLocaleTimeString('en-GB', { timeZone: TZ, hour: '2-digit' })}`;
|
||||||
// Metre abbreviation: "ม." reads as a unit in Thai, "m" mid-sentence does not.
|
// Metre abbreviation: "ม." reads as a unit in Thai, "m" mid-sentence does not.
|
||||||
function metres(value, digits = 2) {
|
function metres(value, digits = 2) {
|
||||||
return `${Number(value).toFixed(digits)} ${t('unit.m')}`;
|
return `${Number(value).toFixed(digits)} ${t('unit.m')}`;
|
||||||
@@ -885,7 +999,7 @@
|
|||||||
const mm = r.rain_24h == null ? null : Number(r.rain_24h);
|
const mm = r.rain_24h == null ? null : Number(r.rain_24h);
|
||||||
const bin = rainBin(mm);
|
const bin = rainBin(mm);
|
||||||
const name = (state.lang === 'th' ? r.name_th || r.name_en : r.name_en || r.name_th) || r.oldcode || `Station ${r.station_id}`;
|
const name = (state.lang === 'th' ? r.name_th || r.name_en : r.name_en || r.name_th) || r.oldcode || `Station ${r.station_id}`;
|
||||||
const time = r.timestamp ? new Date(r.timestamp).toLocaleString(loc(), { dateStyle: 'medium', timeStyle: 'short' }) : t('popup.noreading');
|
const time = r.timestamp ? parseTs(r.timestamp).toLocaleString(loc(), { timeZone: TZ, dateStyle: 'medium', timeStyle: 'short' }) : t('popup.noreading');
|
||||||
L.circleMarker([r.latitude, r.longitude], {
|
L.circleMarker([r.latitude, r.longitude], {
|
||||||
radius: bin.radius, color: '#ffffff', weight: 1.5,
|
radius: bin.radius, color: '#ffffff', weight: 1.5,
|
||||||
fillColor: bin.color, fillOpacity: bin.opacity ?? .85
|
fillColor: bin.color, fillOpacity: bin.opacity ?? .85
|
||||||
@@ -912,7 +1026,7 @@
|
|||||||
const latest = new Map();
|
const latest = new Map();
|
||||||
measurements.forEach((item) => {
|
measurements.forEach((item) => {
|
||||||
const prior = latest.get(item.station_code);
|
const prior = latest.get(item.station_code);
|
||||||
if (!prior || new Date(item.timestamp) > new Date(prior.timestamp)) latest.set(item.station_code, item);
|
if (!prior || parseTs(item.timestamp) > parseTs(prior.timestamp)) latest.set(item.station_code, item);
|
||||||
});
|
});
|
||||||
return latest;
|
return latest;
|
||||||
}
|
}
|
||||||
@@ -945,7 +1059,7 @@
|
|||||||
function buildPopup(station, measurement) {
|
function buildPopup(station, measurement) {
|
||||||
const flow = measurement ? measurement.discharge : null;
|
const flow = measurement ? measurement.discharge : null;
|
||||||
const level = measurement ? measurement.water_level : null;
|
const level = measurement ? measurement.water_level : null;
|
||||||
const time = measurement ? new Date(measurement.timestamp).toLocaleString(loc(), { dateStyle: 'medium', timeStyle: 'short' }) : t('popup.noreading');
|
const time = measurement ? parseTs(measurement.timestamp).toLocaleString(loc(), { timeZone: TZ, dateStyle: 'medium', timeStyle: 'short' }) : t('popup.noreading');
|
||||||
// Station names are bilingual in the data: lead with the reader's language
|
// Station names are bilingual in the data: lead with the reader's language
|
||||||
const primary = state.lang === 'th' ? station.thai_name : station.english_name;
|
const primary = state.lang === 'th' ? station.thai_name : station.english_name;
|
||||||
const secondary = state.lang === 'th' ? station.english_name : station.thai_name;
|
const secondary = state.lang === 'th' ? station.english_name : station.thai_name;
|
||||||
@@ -1125,8 +1239,7 @@
|
|||||||
const downsample = (data) => {
|
const downsample = (data) => {
|
||||||
const buckets = {};
|
const buckets = {};
|
||||||
data.forEach((row) => {
|
data.forEach((row) => {
|
||||||
const date = new Date(row.timestamp);
|
const key = dayKey(row.timestamp);
|
||||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`;
|
|
||||||
if (!buckets[key]) buckets[key] = { ts: row.timestamp, discharge: [], level: [] };
|
if (!buckets[key]) buckets[key] = { ts: row.timestamp, discharge: [], level: [] };
|
||||||
const b = buckets[key];
|
const b = buckets[key];
|
||||||
if (row.discharge != null) b.discharge.push(row.discharge);
|
if (row.discharge != null) b.discharge.push(row.discharge);
|
||||||
@@ -1147,12 +1260,7 @@
|
|||||||
const fr = await fetch(`/api/forecast/history/${encodeURIComponent(stationCode)}?${query}&horizon=24`);
|
const fr = await fetch(`/api/forecast/history/${encodeURIComponent(stationCode)}?${query}&horizon=24`);
|
||||||
const forecastRows = fr.ok ? await fr.json() : [];
|
const forecastRows = fr.ok ? await fr.json() : [];
|
||||||
if (forecastRows.length) {
|
if (forecastRows.length) {
|
||||||
const keyOf = (value) => {
|
const keyOf = (value) => rows.length > 2000 ? dayKey(value) : hourKey(value);
|
||||||
const d = new Date(value);
|
|
||||||
return rows.length > 2000
|
|
||||||
? `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`
|
|
||||||
: `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${d.getUTCHours()}`;
|
|
||||||
};
|
|
||||||
const byKey = new Map();
|
const byKey = new Map();
|
||||||
forecastRows.forEach((r) => {
|
forecastRows.forEach((r) => {
|
||||||
if (r.predicted_max_level == null) return;
|
if (r.predicted_max_level == null) return;
|
||||||
@@ -1177,7 +1285,7 @@
|
|||||||
state.historyChart = new Chart($('history-chart'), {
|
state.historyChart = new Chart($('history-chart'), {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: sampled.map((row) => new Date(row.timestamp).toLocaleString(loc(), { timeZone: 'Asia/Bangkok', month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })),
|
labels: sampled.map((row) => parseTs(row.timestamp).toLocaleString(loc(), { timeZone: TZ, month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })),
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label: t('chart.discharge'), data: sampled.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 },
|
{ label: t('chart.discharge'), data: sampled.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 },
|
||||||
{ label: t('chart.level'), data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 },
|
{ label: t('chart.level'), data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 },
|
||||||
@@ -1187,13 +1295,14 @@
|
|||||||
options: {
|
options: {
|
||||||
responsive: true, maintainAspectRatio: false, animation: { duration: 0 },
|
responsive: true, maintainAspectRatio: false, animation: { duration: 0 },
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
color: cssVar('--ink'),
|
||||||
scales: {
|
scales: {
|
||||||
flow: { type: 'linear', position: 'left' },
|
flow: { type: 'linear', position: 'left', grid: { color: cssVar('--chart-grid') }, ticks: { color: cssVar('--muted') } },
|
||||||
level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false } },
|
level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false }, ticks: { color: cssVar('--muted') } },
|
||||||
x: { ticks: { maxTicksLimit: 12 } }
|
x: { ticks: { maxTicksLimit: 12, color: cssVar('--muted') }, grid: { color: cssVar('--chart-grid') } }
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: true },
|
legend: { display: true, labels: { color: cssVar('--ink') } },
|
||||||
floodBands: { enabled: true }
|
floodBands: { enabled: true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1343,7 +1452,7 @@
|
|||||||
function setP1Level(value, at, opts) {
|
function setP1Level(value, at, opts) {
|
||||||
const options = opts || {};
|
const options = opts || {};
|
||||||
if (value == null || Number.isNaN(Number(value))) return;
|
if (value == null || Number.isNaN(Number(value))) return;
|
||||||
const stamp = at ? new Date(at).getTime() : null;
|
const stamp = at ? (parseTs(at)?.getTime() ?? NaN) : null;
|
||||||
const known = Number.isFinite(stamp) ? stamp : null;
|
const known = Number.isFinite(stamp) ? stamp : null;
|
||||||
if (!options.force && known != null && state.p1NowAt != null && known < state.p1NowAt) {
|
if (!options.force && known != null && state.p1NowAt != null && known < state.p1NowAt) {
|
||||||
return; // an older snapshot must not overwrite a newer one
|
return; // an older snapshot must not overwrite a newer one
|
||||||
@@ -1383,7 +1492,7 @@
|
|||||||
|
|
||||||
function renderSummary(stations, readings) {
|
function renderSummary(stations, readings) {
|
||||||
const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean);
|
const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean);
|
||||||
const timestamps = current.map((m) => new Date(m.timestamp)).filter((date) => !Number.isNaN(date.getTime()));
|
const timestamps = current.map((m) => parseTs(m.timestamp)).filter(Boolean);
|
||||||
const latest = timestamps.length ? new Date(Math.max(...timestamps.map((date) => date.getTime()))) : null;
|
const latest = timestamps.length ? new Date(Math.max(...timestamps.map((date) => date.getTime()))) : null;
|
||||||
$('station-count').textContent = `${current.length} / ${stations.length}`;
|
$('station-count').textContent = `${current.length} / ${stations.length}`;
|
||||||
if (state.hiiReportingCount != null) {
|
if (state.hiiReportingCount != null) {
|
||||||
@@ -1403,12 +1512,24 @@
|
|||||||
const worst = stressed.length ? stressed.reduce((max, item) => item.percent > max.percent ? item : max) : null;
|
const worst = stressed.length ? stressed.reduce((max, item) => item.percent > max.percent ? item : max) : null;
|
||||||
$('peak-flow').textContent = worst ? `${worst.percent.toFixed(0)}%` : '—';
|
$('peak-flow').textContent = worst ? `${worst.percent.toFixed(0)}%` : '—';
|
||||||
$('peak-station').textContent = worst ? t('stat.stress.capacity', worst.code) : t('stat.stress.nodata');
|
$('peak-station').textContent = worst ? t('stat.stress.capacity', worst.code) : t('stat.stress.nodata');
|
||||||
$('last-updated').textContent = latest ? latest.toLocaleTimeString(loc(), { hour: '2-digit', minute: '2-digit' }) : '—';
|
$('last-updated').textContent = latest ? latest.toLocaleTimeString(loc(), { timeZone: TZ, hour: '2-digit', minute: '2-digit' }) : '—';
|
||||||
|
const tile = $('last-updated').closest('.stat');
|
||||||
if (latest) {
|
if (latest) {
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000));
|
const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000));
|
||||||
$('data-age').textContent = t('stat.updated.ago',
|
const day = latest.toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short' });
|
||||||
latest.toLocaleDateString(loc(), { day: 'numeric', month: 'short' }), minutes);
|
// RID publishes hourly and the scrape runs hourly, so anything past
|
||||||
} else $('data-age').textContent = t('stat.updated.none');
|
// ~3 h means the feed or the collector has stopped: say so loudly
|
||||||
|
// instead of letting "6000 min ago" pass as a number.
|
||||||
|
const stale = minutes >= STALE_AFTER_MIN;
|
||||||
|
$('data-age').textContent = stale
|
||||||
|
? t('stat.updated.stale', day, Math.round(minutes / 60))
|
||||||
|
: minutes >= 120 ? t('stat.updated.agoh', day, Math.round(minutes / 60))
|
||||||
|
: t('stat.updated.ago', day, minutes);
|
||||||
|
tile.classList.toggle('stale', stale);
|
||||||
|
if (!state.replayTimer && (state.liveMode === 'live' || state.liveMode === 'stale')) {
|
||||||
|
setLiveIndicator(stale ? 'stale' : 'live', stale ? 'pill.stale' : 'pill.live');
|
||||||
|
}
|
||||||
|
} else { $('data-age').textContent = t('stat.updated.none'); tile.classList.remove('stale'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
@@ -1655,13 +1776,13 @@
|
|||||||
// force: replay frames are 2024 timestamps, older than anything live
|
// force: replay frames are 2024 timestamps, older than anything live
|
||||||
if (p1Level != null) setP1Level(p1Level, null, { force: true });
|
if (p1Level != null) setP1Level(p1Level, null, { force: true });
|
||||||
restyleFloodZones();
|
restyleFloodZones();
|
||||||
const ts = new Date(data.timestamps[frame]);
|
const ts = parseTs(data.timestamps[frame]);
|
||||||
// minute included: a lone "17" reads as a year in Thai output
|
// minute included: a lone "17" reads as a year in Thai output
|
||||||
const when = ts.toLocaleString(loc(), { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
const when = ts.toLocaleString(loc(), { timeZone: TZ, day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
const p1Text = state.p1Now == null ? '—' : metres(state.p1Now);
|
const p1Text = state.p1Now == null ? '—' : metres(state.p1Now);
|
||||||
const basinFlow = Math.round(totalFlow).toLocaleString(loc());
|
const basinFlow = Math.round(totalFlow).toLocaleString(loc());
|
||||||
$('p1-peak').textContent = t('replay.peak', when, p1Text, basinFlow);
|
$('p1-peak').textContent = t('replay.peak', when, p1Text, basinFlow);
|
||||||
$('replay-clock-time').textContent = ts.toLocaleString(loc(), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
$('replay-clock-time').textContent = ts.toLocaleString(loc(), { timeZone: TZ, day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
$('replay-clock-sub').textContent = t('replay.clock.sub', p1Text, basinFlow);
|
$('replay-clock-sub').textContent = t('replay.clock.sub', p1Text, basinFlow);
|
||||||
// model track: what the forecast system (trained pre-flood) said at this moment
|
// model track: what the forecast system (trained pre-flood) said at this moment
|
||||||
const model = data.model || {};
|
const model = data.model || {};
|
||||||
@@ -1850,9 +1971,9 @@
|
|||||||
`<div class="risk-chips">${chips}</div>`;
|
`<div class="risk-chips">${chips}</div>`;
|
||||||
grid.appendChild(cardEl);
|
grid.appendChild(cardEl);
|
||||||
});
|
});
|
||||||
const asOf = rows[0].as_of ? new Date(rows[0].as_of).toLocaleString(loc(), { dateStyle: 'medium', timeStyle: 'short' }) : null;
|
const asOf = rows[0].as_of ? parseTs(rows[0].as_of).toLocaleString(loc(), { timeZone: TZ, dateStyle: 'medium', timeStyle: 'short' }) : null;
|
||||||
const modelRow = rows.find((r) => r.source === 'model');
|
const modelRow = rows.find((r) => r.source === 'model');
|
||||||
const trainedAt = modelRow?.trained_at ? new Date(modelRow.trained_at).toLocaleDateString(loc(), { day: 'numeric', month: 'short' }) : null;
|
const trainedAt = modelRow?.trained_at ? parseTs(modelRow.trained_at).toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short' }) : null;
|
||||||
const modelInfo = modelRow?.model_version
|
const modelInfo = modelRow?.model_version
|
||||||
? t('forecast.status.model', modelRow.model_version, trainedAt ? t('forecast.status.trained', trainedAt) : '')
|
? t('forecast.status.model', modelRow.model_version, trainedAt ? t('forecast.status.trained', trainedAt) : '')
|
||||||
: '';
|
: '';
|
||||||
@@ -1888,7 +2009,7 @@
|
|||||||
const strip = $('db-stats');
|
const strip = $('db-stats');
|
||||||
try {
|
try {
|
||||||
state.lastStats = stats;
|
state.lastStats = stats;
|
||||||
const fmtDate = (value) => new Date(value).toLocaleDateString(loc(), { day: 'numeric', month: 'short', year: 'numeric' });
|
const fmtDate = (value) => parseTs(value).toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
state.dbFirstDate = String(stats.first_timestamp).slice(0, 10); // feeds the All-time date range
|
state.dbFirstDate = String(stats.first_timestamp).slice(0, 10); // feeds the All-time date range
|
||||||
$('db-total').textContent = Number(stats.total_measurements).toLocaleString(loc());
|
$('db-total').textContent = Number(stats.total_measurements).toLocaleString(loc());
|
||||||
if (stats.rid_measurements != null) {
|
if (stats.rid_measurements != null) {
|
||||||
@@ -1924,6 +2045,7 @@
|
|||||||
: t('forecast.expand', count);
|
: t('forecast.expand', count);
|
||||||
});
|
});
|
||||||
$('lang-toggle').addEventListener('click', () => setLang(state.lang === 'th' ? 'en' : 'th'));
|
$('lang-toggle').addEventListener('click', () => setLang(state.lang === 'th' ? 'en' : 'th'));
|
||||||
|
$('theme-toggle').addEventListener('click', () => applyTheme(currentTheme() === 'dark' ? 'light' : 'dark', true));
|
||||||
$('station-search').addEventListener('input', applyStationSearch);
|
$('station-search').addEventListener('input', applyStationSearch);
|
||||||
$('sensors-head').addEventListener('click', () => setSensorsOpen(!state.sensorsOpen));
|
$('sensors-head').addEventListener('click', () => setSensorsOpen(!state.sensorsOpen));
|
||||||
$('sensors-head').addEventListener('keydown', (event) => {
|
$('sensors-head').addEventListener('keydown', (event) => {
|
||||||
|
|||||||
@@ -632,9 +632,7 @@ class EnhancedWaterMonitorScraper:
|
|||||||
if data:
|
if data:
|
||||||
if self.save_to_database(data):
|
if self.save_to_database(data):
|
||||||
filled_count += len(data)
|
filled_count += len(data)
|
||||||
logger.info(
|
logger.info(f"Filled {len(data)} measurements for {fetch_date}")
|
||||||
f"Filled {len(data)} measurements for {fetch_date}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to save data for {fetch_date}")
|
logger.warning(f"Failed to save data for {fetch_date}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+17
-16
@@ -374,9 +374,7 @@ async def background_scraping_task():
|
|||||||
hii_counts = await asyncio.get_event_loop().run_in_executor(
|
hii_counts = await asyncio.get_event_loop().run_in_executor(
|
||||||
None, hii_collector.run_cycle
|
None, hii_collector.run_cycle
|
||||||
)
|
)
|
||||||
set_gauge(
|
set_gauge("hii_rainfall_rows_saved", hii_counts["rainfall"])
|
||||||
"hii_rainfall_rows_saved", hii_counts["rainfall"]
|
|
||||||
)
|
|
||||||
set_gauge(
|
set_gauge(
|
||||||
"hii_waterlevel_rows_saved", hii_counts["waterlevel"]
|
"hii_waterlevel_rows_saved", hii_counts["waterlevel"]
|
||||||
)
|
)
|
||||||
@@ -521,7 +519,9 @@ _STATIC_DIR = os.path.dirname(_DASHBOARD_HTML_PATH)
|
|||||||
|
|
||||||
@app.get("/robots.txt", include_in_schema=False)
|
@app.get("/robots.txt", include_in_schema=False)
|
||||||
async def robots_txt():
|
async def robots_txt():
|
||||||
return FileResponse(os.path.join(_STATIC_DIR, "robots.txt"), media_type="text/plain")
|
return FileResponse(
|
||||||
|
os.path.join(_STATIC_DIR, "robots.txt"), media_type="text/plain"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/llms.txt", include_in_schema=False)
|
@app.get("/llms.txt", include_in_schema=False)
|
||||||
@@ -1022,8 +1022,12 @@ async def get_hii_rainfall_catchment(
|
|||||||
|
|
||||||
engine = _hii_engine()
|
engine = _hii_engine()
|
||||||
if engine is None:
|
if engine is None:
|
||||||
return {"box": hii_rain.CATCHMENT_BOX, "gauge": [], "openmeteo": [],
|
return {
|
||||||
"comparison_24h_sums": {"overlap_hours": 0}}
|
"box": hii_rain.CATCHMENT_BOX,
|
||||||
|
"gauge": [],
|
||||||
|
"openmeteo": [],
|
||||||
|
"comparison_24h_sums": {"overlap_hours": 0},
|
||||||
|
}
|
||||||
gauge = hii_rain.load_gauge_mean(start=pd.Timestamp(start), engine=engine)
|
gauge = hii_rain.load_gauge_mean(start=pd.Timestamp(start), engine=engine)
|
||||||
openmeteo = None
|
openmeteo = None
|
||||||
try:
|
try:
|
||||||
@@ -1050,7 +1054,10 @@ async def get_hii_rainfall_catchment(
|
|||||||
if s is None:
|
if s is None:
|
||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
{"timestamp": ts.isoformat(), "rain_mm": None if pd.isna(v) else round(float(v), 2)}
|
{
|
||||||
|
"timestamp": ts.isoformat(),
|
||||||
|
"rain_mm": None if pd.isna(v) else round(float(v), 2),
|
||||||
|
}
|
||||||
for ts, v in s.items()
|
for ts, v in s.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1121,9 +1128,7 @@ async def get_postgres_history(
|
|||||||
return cached[1]
|
return cached[1]
|
||||||
try:
|
try:
|
||||||
db_config = Config.get_database_config()
|
db_config = Config.get_database_config()
|
||||||
end_time = (
|
end_time = datetime.combine(end, datetime.max.time()) if end else datetime.now()
|
||||||
datetime.combine(end, datetime.max.time()) if end else datetime.now()
|
|
||||||
)
|
|
||||||
start_time = (
|
start_time = (
|
||||||
datetime.combine(start, datetime.min.time())
|
datetime.combine(start, datetime.min.time())
|
||||||
if start
|
if start
|
||||||
@@ -1216,17 +1221,13 @@ async def get_forecast_history(
|
|||||||
store = app_state.get("forecast_store")
|
store = app_state.get("forecast_store")
|
||||||
if not store:
|
if not store:
|
||||||
return []
|
return []
|
||||||
end_dt = (
|
end_dt = datetime.combine(end, datetime.max.time()) if end else datetime.now()
|
||||||
datetime.combine(end, datetime.max.time()) if end else datetime.now()
|
|
||||||
)
|
|
||||||
start_dt = (
|
start_dt = (
|
||||||
datetime.combine(start, datetime.min.time())
|
datetime.combine(start, datetime.min.time())
|
||||||
if start
|
if start
|
||||||
else end_dt - timedelta(hours=hours)
|
else end_dt - timedelta(hours=hours)
|
||||||
)
|
)
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(store.fetch, station_code, start_dt, end_dt, horizon)
|
||||||
store.fetch, station_code, start_dt, end_dt, horizon
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/measurements/latest", response_model=List[MeasurementResponse])
|
@app.get("/measurements/latest", response_model=List[MeasurementResponse])
|
||||||
|
|||||||
Reference in New Issue
Block a user