Compare commits
3
Commits
ce08312c0f
...
97a6694ab2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a6694ab2 | ||
|
|
5ad8e4eac3 | ||
|
|
6f4a86edbb |
+60
-327
@@ -1,342 +1,75 @@
|
||||
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:
|
||||
push:
|
||||
branches: [ master, develop ]
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
branches: [master]
|
||||
schedule:
|
||||
# Run tests daily at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
# daily, catches dependency drift / upstream API changes in the tests
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.11'
|
||||
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 }}
|
||||
PYTHON_VERSION: "3.11" # pandas 2.0.3 ships no 3.12 wheels; psycopg2-binary 2.9.9 breaks on 3.13
|
||||
|
||||
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:
|
||||
name: Test Suite
|
||||
name: Test suite
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11'] # pandas 2.0.3 ships no 3.12 wheels; widen after upgrading pandas
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
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-dev.txt
|
||||
|
||||
- name: Lint with flake8
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# 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
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements.txt
|
||||
requirements-dev.txt
|
||||
|
||||
# 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:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Test Docker image
|
||||
run: |
|
||||
docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} python run.py --test
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
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 pytest==7.4.3 pytest-asyncio==0.21.1
|
||||
|
||||
# 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
|
||||
- name: pytest
|
||||
env:
|
||||
DB_TYPE: sqlite
|
||||
run: pytest -q -p no:cacheprovider
|
||||
|
||||
+83
-351
@@ -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:
|
||||
push:
|
||||
branches: [ master, develop ]
|
||||
branches: [master, develop]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'src/**/*.py'
|
||||
- "docs/**"
|
||||
- "README.md"
|
||||
- "CONTRIBUTING.md"
|
||||
- "src/web_api.py"
|
||||
- "src/schemas.py"
|
||||
- ".gitea/workflows/docs.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- "docs/**"
|
||||
- "README.md"
|
||||
- "CONTRIBUTING.md"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.11'
|
||||
PYTHON_VERSION: "3.11"
|
||||
|
||||
jobs:
|
||||
# Validate documentation
|
||||
validate-docs:
|
||||
name: Validate Documentation
|
||||
docs:
|
||||
name: Validate 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 documentation tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install sphinx sphinx-rtd-theme sphinx-autodoc-typehints
|
||||
pip install markdown-link-check || true
|
||||
|
||||
- name: Check markdown links
|
||||
run: |
|
||||
echo "🔗 Checking markdown links..."
|
||||
find . -name "*.md" -not -path "./.git/*" -not -path "./node_modules/*" | while read file; do
|
||||
echo "Checking $file"
|
||||
# Basic link validation (you can enhance this)
|
||||
grep -o 'http[s]*://[^)]*' "$file" | while read url; do
|
||||
if curl -s --head "$url" | head -n 1 | grep -q "200 OK"; then
|
||||
echo "✅ $url"
|
||||
else
|
||||
echo "❌ $url (in $file)"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
- name: Validate README structure
|
||||
run: |
|
||||
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')
|
||||
"
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# 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:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Generate OpenAPI spec
|
||||
run: |
|
||||
echo "📝 Generating OpenAPI specification..."
|
||||
python -c "
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
try:
|
||||
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:
|
||||
name: documentation-${{ github.run_number }}
|
||||
path: |
|
||||
openapi.json
|
||||
api-docs.md
|
||||
- name: Relative links and images resolve
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import re, sys, pathlib
|
||||
root = pathlib.Path(".")
|
||||
files = [root / "README.md", root / "CONTRIBUTING.md", *root.glob("docs/**/*.md")]
|
||||
link = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||
bad = []
|
||||
for md in files:
|
||||
if not md.exists():
|
||||
continue
|
||||
for m in link.finditer(md.read_text(encoding="utf-8")):
|
||||
target = m.group(1)
|
||||
if target.startswith(("http://", "https://", "mailto:", "#")):
|
||||
continue
|
||||
path = target.split("#", 1)[0]
|
||||
if not path:
|
||||
continue
|
||||
resolved = (md.parent / path).resolve()
|
||||
if not resolved.exists():
|
||||
bad.append(f"{md}: {target}")
|
||||
if bad:
|
||||
print("Broken relative links:")
|
||||
print("\n".join(" " + b for b in bad))
|
||||
sys.exit(1)
|
||||
print(f"checked {len(files)} files, all relative links resolve")
|
||||
PY
|
||||
|
||||
# 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/
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
# 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
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip --root-user-action=ignore
|
||||
pip install --root-user-action=ignore -r requirements.txt
|
||||
|
||||
- name: OpenAPI schema exports
|
||||
env:
|
||||
DB_TYPE: sqlite
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from src.web_api import app
|
||||
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
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: openapi-${{ github.run_number }}
|
||||
path: openapi.json
|
||||
|
||||
@@ -23,18 +23,16 @@ repos:
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3
|
||||
args: ['--line-length=120']
|
||||
|
||||
# Import sorting with isort
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ['--profile', 'black', '--line-length', '120']
|
||||
|
||||
# Linting with flake8
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 6.1.0
|
||||
hooks:
|
||||
- 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/).
|
||||
|
||||
[](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
|
||||
|
||||
|
||||
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]
|
||||
"" = "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 .database_adapters import DatabaseAdapter, create_database_adapter
|
||||
from .exceptions import (APIConnectionError, ConfigurationError,
|
||||
DatabaseConnectionError, DataValidationError,
|
||||
WaterMonitorException)
|
||||
from .exceptions import (
|
||||
APIConnectionError,
|
||||
ConfigurationError,
|
||||
DatabaseConnectionError,
|
||||
DataValidationError,
|
||||
WaterMonitorException,
|
||||
)
|
||||
from .models import DatabaseConfig, StationInfo, WaterMeasurement
|
||||
from .water_scraper_v3 import EnhancedWaterMonitorScraper
|
||||
|
||||
|
||||
@@ -767,9 +767,7 @@ class SQLAdapter(DatabaseAdapter):
|
||||
return hours_by_day
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error querying {self.db_type.upper()} recorded hours: {e}"
|
||||
)
|
||||
logging.error(f"Error querying {self.db_type.upper()} recorded hours: {e}")
|
||||
return None
|
||||
|
||||
def get_database_stats(self) -> Optional[Dict]:
|
||||
@@ -814,9 +812,7 @@ class SQLAdapter(DatabaseAdapter):
|
||||
# the DISTINCT day-hour slots and coverage cannot exceed 100%
|
||||
first_slot = first_ts.replace(minute=0, second=0, microsecond=0)
|
||||
last_slot = last_ts.replace(minute=0, second=0, microsecond=0)
|
||||
expected_hours = (
|
||||
int((last_slot - first_slot).total_seconds() // 3600) + 1
|
||||
)
|
||||
expected_hours = int((last_slot - first_slot).total_seconds() // 3600) + 1
|
||||
recorded_hours = int(row[4])
|
||||
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 .hii_collector import (
|
||||
PING_BASIN_CODE,
|
||||
HiiClient,
|
||||
HiiStore,
|
||||
PING_BASIN_CODE,
|
||||
_parse_datetime,
|
||||
_to_float,
|
||||
)
|
||||
@@ -145,9 +145,7 @@ def backfill(
|
||||
station_rows += store.save_waterlevel_history(sid, rows)
|
||||
except Exception as e:
|
||||
totals["errors"] += 1
|
||||
logger.warning(
|
||||
f"{label}: {chunk_start}..{chunk_end} failed: {e}"
|
||||
)
|
||||
logger.warning(f"{label}: {chunk_start}..{chunk_end} failed: {e}")
|
||||
time.sleep(sleep_seconds)
|
||||
totals["rows"] += station_rows
|
||||
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
|
||||
|
||||
|
||||
def parse_rain_records(
|
||||
payload: Dict, basin_code: int = PING_BASIN_CODE
|
||||
) -> List[Dict]:
|
||||
def parse_rain_records(payload: Dict, basin_code: int = PING_BASIN_CODE) -> List[Dict]:
|
||||
"""Extract per-station rainfall rows from a rain_24h payload."""
|
||||
records = []
|
||||
for row in payload.get("data") or []:
|
||||
@@ -186,9 +184,7 @@ class HiiStore:
|
||||
def __init__(self, connection_string: str, db_type: str):
|
||||
self.db_type = db_type.lower()
|
||||
if self.db_type not in ("sqlite", "postgresql", "mysql"):
|
||||
raise ValueError(
|
||||
f"HII collection requires a SQL database, got '{db_type}'"
|
||||
)
|
||||
raise ValueError(f"HII collection requires a SQL database, got '{db_type}'")
|
||||
self.connection_string = connection_string
|
||||
self.engine = None
|
||||
|
||||
@@ -400,9 +396,7 @@ class HiiStore:
|
||||
from sqlalchemy import text
|
||||
|
||||
now = datetime.datetime.now()
|
||||
station_sql = self._upsert(
|
||||
station_table, ["id"], station_cols + ["updated_at"]
|
||||
)
|
||||
station_sql = self._upsert(station_table, ["id"], station_cols + ["updated_at"])
|
||||
measurement_sql = self._upsert(
|
||||
measurement_table, ["station_id", "timestamp"], measurement_cols
|
||||
)
|
||||
|
||||
+1
-3
@@ -61,9 +61,7 @@ def load_daily(
|
||||
params["start"] = start
|
||||
engine = create_engine(resolved, pool_pre_ping=True)
|
||||
with engine.connect() as conn:
|
||||
daily = pd.read_sql(
|
||||
text(query + " ORDER BY date"), conn, params=params
|
||||
)
|
||||
daily = pd.read_sql(text(query + " ORDER BY date"), conn, params=params)
|
||||
daily["date"] = pd.to_datetime(daily["date"])
|
||||
daily = daily.set_index("date")
|
||||
for col in DAM_COLUMNS:
|
||||
|
||||
+1
-3
@@ -213,9 +213,7 @@ def fill_from_hii(
|
||||
}
|
||||
)
|
||||
fills.append(fill)
|
||||
logger.info(
|
||||
f"HII gap-fill {code}: +{len(fill)} hours (offset {offset:.3f} m)"
|
||||
)
|
||||
logger.info(f"HII gap-fill {code}: +{len(fill)} hours (offset {offset:.3f} m)")
|
||||
if not fills:
|
||||
return df
|
||||
return _normalize_long(pd.concat([df] + fills, ignore_index=True))
|
||||
|
||||
+76
-51
@@ -62,10 +62,17 @@ EXTRA_RAIN_FEATURES = ("rain_fc48",)
|
||||
class Variant:
|
||||
"""A trainable candidate producing (pred_abs, sigma_per_row) on test rows."""
|
||||
|
||||
def __init__(self, name: str, target: str, weighted: bool = False,
|
||||
quantile: bool = False, use_rain: bool = False,
|
||||
use_dam: bool = False, use_fc48: bool = False,
|
||||
qsigma: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
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.target = target # 'abs' or 'rise'
|
||||
self.weighted = weighted
|
||||
@@ -78,9 +85,7 @@ class Variant:
|
||||
# sigma-independent) and quantile heads ONLY for a per-row sigma.
|
||||
self.qsigma = qsigma
|
||||
|
||||
def fit_predict(
|
||||
self, X_tr, y_abs_tr, X_te
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
def fit_predict(self, X_tr, y_abs_tr, X_te) -> Tuple[np.ndarray, np.ndarray]:
|
||||
if not self.use_rain:
|
||||
drop = [c for c in features.RAIN_FEATURES if c in X_tr.columns]
|
||||
X_tr = X_tr.drop(columns=drop)
|
||||
@@ -135,24 +140,30 @@ VARIANTS: Dict[str, Variant] = {
|
||||
"baseline_abs": Variant("baseline_abs", target="abs"),
|
||||
"rise": Variant("rise", target="rise"),
|
||||
"rise_weighted": Variant("rise_weighted", target="rise", weighted=True),
|
||||
"rise_quantile": Variant("rise_quantile", target="rise", weighted=True,
|
||||
quantile=True),
|
||||
"rise_quantile": Variant(
|
||||
"rise_quantile", target="rise", weighted=True, quantile=True
|
||||
),
|
||||
"rise_rain": Variant("rise_rain", target="rise", use_rain=True),
|
||||
"rise_rain_dam": Variant("rise_rain_dam", target="rise", use_rain=True,
|
||||
use_dam=True),
|
||||
"rise_rain_dam": Variant(
|
||||
"rise_rain_dam", target="rise", use_rain=True, 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:
|
||||
# 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-
|
||||
# calibrated), and a longer forecast-rain window for the 24 h horizon.
|
||||
"rise_rain_quantile": Variant("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_fc48": Variant("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),
|
||||
"rise_rain_quantile": Variant(
|
||||
"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_fc48": Variant(
|
||||
"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
|
||||
@@ -160,8 +171,11 @@ VARIANTS: Dict[str, Variant] = {
|
||||
# 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).
|
||||
DEFAULT_VARIANTS = [
|
||||
k for k, v in VARIANTS.items()
|
||||
if not v.use_dam and not v.use_fc48 and not v.qsigma
|
||||
k
|
||||
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)
|
||||
]
|
||||
|
||||
@@ -209,12 +223,16 @@ def _first_alert_lead(
|
||||
start = crossing - pd.Timedelta(hours=72)
|
||||
if window_start_floor is not None and window_start_floor > start:
|
||||
start = window_start_floor
|
||||
window = p.loc[start: crossing + pd.Timedelta(hours=24)]
|
||||
window = p.loc[start : crossing + pd.Timedelta(hours=24)]
|
||||
if len(window) < 2:
|
||||
return None
|
||||
alert = (window >= ALERT_P) & (window.shift(-1) >= ALERT_P) & (
|
||||
(window.index.to_series().shift(-1) - window.index.to_series())
|
||||
<= pd.Timedelta(hours=2)
|
||||
alert = (
|
||||
(window >= ALERT_P)
|
||||
& (window.shift(-1) >= ALERT_P)
|
||||
& (
|
||||
(window.index.to_series().shift(-1) - window.index.to_series())
|
||||
<= pd.Timedelta(hours=2)
|
||||
)
|
||||
)
|
||||
hits = window.index[alert.fillna(False)]
|
||||
if len(hits) == 0:
|
||||
@@ -222,9 +240,7 @@ def _first_alert_lead(
|
||||
return float((crossing - hits[0]).total_seconds() / 3600.0)
|
||||
|
||||
|
||||
def _false_alarm_episodes(
|
||||
p: pd.Series, observed: pd.Series, thr: float
|
||||
) -> int:
|
||||
def _false_alarm_episodes(p: pd.Series, observed: pd.Series, thr: float) -> int:
|
||||
"""Alert episodes with no observed >=thr within +/- FALSE_ALARM_GRACE_H."""
|
||||
alert_hours = p[p >= ALERT_P].index
|
||||
if len(alert_hours) == 0:
|
||||
@@ -295,7 +311,9 @@ def evaluate_station(
|
||||
tr = (X_all.index <= train_end) & y_abs.notna()
|
||||
te = (X_all.index >= test_lo) & (X_all.index <= test_hi)
|
||||
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
|
||||
|
||||
X_tr, X_te = X_all.loc[tr], X_all.loc[te]
|
||||
@@ -371,9 +389,7 @@ def evaluate_station(
|
||||
)
|
||||
fold["variants"][name] = {
|
||||
"mae": float(errors.mean()) if len(errors) else None,
|
||||
"mae_above_2p5": (
|
||||
float(errors[high].mean()) if high.any() else None
|
||||
),
|
||||
"mae_above_2p5": (float(errors[high].mean()) if high.any() else None),
|
||||
"brier_warn": brier,
|
||||
"events": event_rows,
|
||||
"false_alarm_episodes": _false_alarm_episodes(
|
||||
@@ -394,17 +410,20 @@ def summarize(results: Dict) -> str:
|
||||
lines.append(header)
|
||||
for fold in results["folds"]:
|
||||
for name, m in fold["variants"].items():
|
||||
events = " ".join(
|
||||
f"[{e['crossing'][:10]}: "
|
||||
f"{'—' if e['lead_h'] is None else format(e['lead_h'], '+.0f')}h"
|
||||
+ (
|
||||
f" | {e['peak_pred_24h_before'] - e['peak_level']:+.2f}"
|
||||
if e["peak_pred_24h_before"] is not None
|
||||
else ""
|
||||
events = (
|
||||
" ".join(
|
||||
f"[{e['crossing'][:10]}: "
|
||||
f"{'—' if e['lead_h'] is None else format(e['lead_h'], '+.0f')}h"
|
||||
+ (
|
||||
f" | {e['peak_pred_24h_before'] - e['peak_level']:+.2f}"
|
||||
if e["peak_pred_24h_before"] is not None
|
||||
else ""
|
||||
)
|
||||
+ "]"
|
||||
for e in m["events"]
|
||||
)
|
||||
+ "]"
|
||||
for e in m["events"]
|
||||
) or "no events"
|
||||
or "no events"
|
||||
)
|
||||
lines.append(
|
||||
f"{name:16} {fold['year']:>5} "
|
||||
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.add_argument("--stations", default="P.1")
|
||||
parser.add_argument("--db-url", default=None)
|
||||
parser.add_argument("--variants", default=None,
|
||||
help="comma list; default all")
|
||||
parser.add_argument("--variants", default=None, help="comma list; default all")
|
||||
parser.add_argument("--out", default="models/eval_variants.json")
|
||||
parser.add_argument("--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("--from-cache", action="store_true",
|
||||
help="offline: read models/cache/ only (no DB, no API, "
|
||||
"no Open-Meteo refresh) -- reproducible reruns")
|
||||
parser.add_argument(
|
||||
"--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(
|
||||
"--from-cache",
|
||||
action="store_true",
|
||||
help="offline: read models/cache/ only (no DB, no API, "
|
||||
"no Open-Meteo refresh) -- reproducible reruns",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
|
||||
+7
-4
@@ -65,7 +65,12 @@ def load_gauge_mean(
|
||||
"AND s.longitude BETWEEN :lon_lo AND :lon_hi "
|
||||
"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:
|
||||
query += " AND m.timestamp >= :start"
|
||||
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
|
||||
(gauges report at :00, the model's hour is an interval) do not dominate.
|
||||
"""
|
||||
joined = pd.concat(
|
||||
{"gauge": gauge, "openmeteo": openmeteo}, axis=1
|
||||
).dropna()
|
||||
joined = pd.concat({"gauge": gauge, "openmeteo": openmeteo}, axis=1).dropna()
|
||||
if joined.empty:
|
||||
return {"overlap_hours": 0}
|
||||
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)
|
||||
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 = (
|
||||
None if thresholds_stale else bundle["heads"].get(f"danger_{horizon_h}")
|
||||
)
|
||||
p_danger = _sigmoid_probability(predicted_max, danger_thr, sigma_h)
|
||||
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_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")
|
||||
dam = pd.DataFrame()
|
||||
|
||||
return get_forecasts(
|
||||
readings_by_station, models_dir=models_dir, rain=rain, dam=dam
|
||||
)
|
||||
return get_forecasts(readings_by_station, models_dir=models_dir, rain=rain, dam=dam)
|
||||
|
||||
+4
-9
@@ -121,12 +121,8 @@ def load_history(
|
||||
cursor = fetch_from.date()
|
||||
try:
|
||||
while cursor <= end:
|
||||
chunk_end = min(
|
||||
datetime.date(cursor.year, 12, 31), end
|
||||
)
|
||||
chunks.append(
|
||||
fetch_history(cursor.isoformat(), chunk_end.isoformat())
|
||||
)
|
||||
chunk_end = min(datetime.date(cursor.year, 12, 31), end)
|
||||
chunks.append(fetch_history(cursor.isoformat(), chunk_end.isoformat()))
|
||||
cursor = datetime.date(cursor.year + 1, 1, 1)
|
||||
except Exception as error:
|
||||
logger.warning(f"Open-Meteo history fetch failed: {error}")
|
||||
@@ -174,7 +170,7 @@ def backfill_db(engine, db_type: str, chunk_rows: int = 5000) -> int:
|
||||
return 0
|
||||
total = 0
|
||||
for start in range(0, len(history), chunk_rows):
|
||||
part = history.iloc[start: start + chunk_rows]
|
||||
part = history.iloc[start : start + chunk_rows]
|
||||
total += save_to_db(part, engine, db_type)
|
||||
logger.info(f"openmeteo_rain backfill: {total}/{len(history)} rows")
|
||||
return total
|
||||
@@ -204,8 +200,7 @@ def save_to_db(df: pd.DataFrame, engine, db_type: str) -> int:
|
||||
cols = ["timestamp"] + point_cols + ["catchment_mean"]
|
||||
placeholders = ", ".join(f":{c}" for c in cols)
|
||||
updates = ", ".join(
|
||||
f"{c} = "
|
||||
+ (f"VALUES({c})" if db_type == "mysql" else f"EXCLUDED.{c}")
|
||||
f"{c} = " + (f"VALUES({c})" if db_type == "mysql" else f"EXCLUDED.{c}")
|
||||
for c in cols[1:]
|
||||
)
|
||||
if db_type == "mysql":
|
||||
|
||||
+2
-3
@@ -53,6 +53,7 @@ class RainUnavailableError(RuntimeError):
|
||||
overwrite the deployed v3 artifacts without anyone noticing.
|
||||
"""
|
||||
|
||||
|
||||
HGB_PARAMS = {
|
||||
"max_iter": 300,
|
||||
"learning_rate": 0.06,
|
||||
@@ -518,9 +519,7 @@ def train_all(
|
||||
"training v3-style bundles WITHOUT dam features"
|
||||
)
|
||||
if dam_frame is not None:
|
||||
logger.info(
|
||||
f"dam series: {dam_frame.index.min()} .. {dam_frame.index.max()}"
|
||||
)
|
||||
logger.info(f"dam series: {dam_frame.index.min()} .. {dam_frame.index.max()}")
|
||||
|
||||
# Run-level version: v4 only if some requested station actually receives
|
||||
# dam columns (they are gated to DAM_STATIONS; per-bundle versions are
|
||||
|
||||
@@ -402,9 +402,7 @@ class RidReservoirStore:
|
||||
measure_row = {
|
||||
c: _bounded(record.get(c), _MEASURE_BOUNDS[c]) for c in measure_cols
|
||||
}
|
||||
measure_row.update(
|
||||
{"dam_id": record["dam_id"], "date": record["date"]}
|
||||
)
|
||||
measure_row.update({"dam_id": record["dam_id"], "date": record["date"]})
|
||||
measurements.append(measure_row)
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
@@ -495,9 +493,7 @@ def backfill(
|
||||
if not store.engine and not store.connect():
|
||||
logger.error("backfill aborted: database connection failed")
|
||||
return 0
|
||||
span = [
|
||||
start + datetime.timedelta(days=i) for i in range((end - start).days + 1)
|
||||
]
|
||||
span = [start + datetime.timedelta(days=i) for i in range((end - start).days + 1)]
|
||||
present = store.present_dates(start, end)
|
||||
targets = [d for d in span if d not in present]
|
||||
logger.info(
|
||||
|
||||
+175
-53
@@ -33,6 +33,57 @@
|
||||
--red: #cc4b37;
|
||||
--border: #dce7e3;
|
||||
--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; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
@@ -43,7 +94,7 @@
|
||||
color: var(--ink);
|
||||
overflow-x: hidden;
|
||||
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);
|
||||
/* Thai families first so Thai text uses a proper Thai face where one
|
||||
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"] .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[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; }
|
||||
header {
|
||||
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; }
|
||||
.live-pill {
|
||||
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-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); }
|
||||
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);
|
||||
}
|
||||
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; }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-bottom: 14px; }
|
||||
.stat {
|
||||
@@ -97,16 +154,19 @@
|
||||
.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-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; }
|
||||
.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; }
|
||||
#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 {
|
||||
position: absolute; z-index: 500; top: 16px; left: 52px; right: 16px;
|
||||
display: flex; justify-content: space-between; align-items: flex-start; pointer-events: none;
|
||||
}
|
||||
.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);
|
||||
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;
|
||||
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.long { font-size: .55rem; word-break: break-all; line-height: 1.15; padding: 2px; text-align: center; }
|
||||
.station-name { overflow: hidden; }
|
||||
@@ -137,9 +197,9 @@
|
||||
.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 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-card { background: white; padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
|
||||
.error-panel { display: none; color: #8d2f22; text-align: center; padding: 25px; }
|
||||
.loading-panel, .error-panel { position: absolute; z-index: 600; inset: 0; display: grid; place-items: center; background: var(--overlay); }
|
||||
.loading-card { background: var(--card); padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
|
||||
.error-panel { display: none; color: var(--red); text-align: center; padding: 25px; }
|
||||
.marker-wrap { background: none; border: 0; }
|
||||
.flow-marker {
|
||||
--marker-color: #087da5; --marker-size: 26px;
|
||||
@@ -179,7 +239,7 @@
|
||||
.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 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 strong { font-size: .88rem; }
|
||||
.p1-peak { color: var(--muted); font-size: .76rem; }
|
||||
@@ -193,16 +253,16 @@
|
||||
.popup h3 { margin: 4px 0 2px; font-size: 1rem; }
|
||||
.popup-th { color: var(--muted); font-size: .74rem; }
|
||||
.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 strong { display: block; margin-top: 2px; font-size: .86rem; }
|
||||
.popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; }
|
||||
/* Verdict banner: light tinted backgrounds with dark ink (contrast-safe) */
|
||||
#flood-verdict.ok { background: #e2f3ea; color: #0c5138; border-color: #bfe3d2; }
|
||||
#flood-verdict.watch { background: #fdf1dc; color: #6b4a05; border-color: #f0d9a8; }
|
||||
#flood-verdict.danger { background: #fbe3de; color: #7c1d10; border-color: #f2c0b6; }
|
||||
#flood-verdict.ok { background: var(--ok-bg); color: var(--ok-ink); border-color: var(--ok-border); }
|
||||
#flood-verdict.watch { background: var(--watch-bg); color: var(--watch-ink); border-color: var(--watch-border); }
|
||||
#flood-verdict.danger { background: var(--danger-bg); color: var(--danger-ink); border-color: var(--danger-border); }
|
||||
#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);
|
||||
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>
|
||||
<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="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>
|
||||
</div>
|
||||
</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-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"><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>
|
||||
@@ -332,7 +393,7 @@
|
||||
|
||||
<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>
|
||||
<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 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>
|
||||
@@ -363,10 +424,10 @@
|
||||
<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 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>
|
||||
<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">
|
||||
<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:var(--surface);color:var(--ink)">
|
||||
<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 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.subtitle': 'Current water level and discharge across Northern Thailand',
|
||||
'pill.live': 'LIVE DATA',
|
||||
'theme.toggle': 'Toggle dark mode',
|
||||
'pill.stale': '⚠ STALE FEED',
|
||||
'pill.replay': '⏪ 2024 REPLAY',
|
||||
'pill.sim': '⚠ SIMULATION',
|
||||
'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.updated': 'Last updated',
|
||||
'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',
|
||||
'map.title': 'Station flow map',
|
||||
'map.subtitle': 'River width, colour & dash speed follow live discharge',
|
||||
@@ -579,6 +644,8 @@
|
||||
'app.title': 'ติดตามระดับน้ำปิงแบบเรียลไทม์',
|
||||
'app.subtitle': 'ระดับน้ำและอัตราการไหลปัจจุบันทั่วภาคเหนือของประเทศไทย',
|
||||
'pill.live': 'ข้อมูลสด',
|
||||
'theme.toggle': 'สลับโหมดมืด/สว่าง',
|
||||
'pill.stale': '⚠ ข้อมูลไม่อัปเดต',
|
||||
'pill.replay': '⏪ ย้อนเหตุการณ์ 2567',
|
||||
'pill.sim': '⚠ การจำลอง',
|
||||
'action.refresh': '↻ รีเฟรช',
|
||||
@@ -611,7 +678,9 @@
|
||||
'stat.stress.tip': 'ระดับความเต็มของลำน้ำที่สถานีที่มีน้ำมากที่สุด — 100% หมายถึงน้ำถึงระดับตลิ่ง',
|
||||
'stat.updated': 'อัปเดตล่าสุด',
|
||||
'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': 'ไม่มีข้อมูลเวลา',
|
||||
'map.title': 'แผนที่การไหลของน้ำ',
|
||||
'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) {
|
||||
state.lang = lang;
|
||||
try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ }
|
||||
@@ -841,6 +936,25 @@
|
||||
// what the replay label ("ต.ค. 2567") already says — pinning Gregorian here
|
||||
// put two different year systems on the same screen.
|
||||
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.
|
||||
function metres(value, digits = 2) {
|
||||
return `${Number(value).toFixed(digits)} ${t('unit.m')}`;
|
||||
@@ -885,7 +999,7 @@
|
||||
const mm = r.rain_24h == null ? null : Number(r.rain_24h);
|
||||
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 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], {
|
||||
radius: bin.radius, color: '#ffffff', weight: 1.5,
|
||||
fillColor: bin.color, fillOpacity: bin.opacity ?? .85
|
||||
@@ -912,7 +1026,7 @@
|
||||
const latest = new Map();
|
||||
measurements.forEach((item) => {
|
||||
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;
|
||||
}
|
||||
@@ -945,7 +1059,7 @@
|
||||
function buildPopup(station, measurement) {
|
||||
const flow = measurement ? measurement.discharge : 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
|
||||
const primary = state.lang === 'th' ? station.thai_name : station.english_name;
|
||||
const secondary = state.lang === 'th' ? station.english_name : station.thai_name;
|
||||
@@ -1125,8 +1239,7 @@
|
||||
const downsample = (data) => {
|
||||
const buckets = {};
|
||||
data.forEach((row) => {
|
||||
const date = new Date(row.timestamp);
|
||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`;
|
||||
const key = dayKey(row.timestamp);
|
||||
if (!buckets[key]) buckets[key] = { ts: row.timestamp, discharge: [], level: [] };
|
||||
const b = buckets[key];
|
||||
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 forecastRows = fr.ok ? await fr.json() : [];
|
||||
if (forecastRows.length) {
|
||||
const keyOf = (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 keyOf = (value) => rows.length > 2000 ? dayKey(value) : hourKey(value);
|
||||
const byKey = new Map();
|
||||
forecastRows.forEach((r) => {
|
||||
if (r.predicted_max_level == null) return;
|
||||
@@ -1177,7 +1285,7 @@
|
||||
state.historyChart = new Chart($('history-chart'), {
|
||||
type: 'line',
|
||||
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: [
|
||||
{ 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 },
|
||||
@@ -1187,13 +1295,14 @@
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, animation: { duration: 0 },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
color: cssVar('--ink'),
|
||||
scales: {
|
||||
flow: { type: 'linear', position: 'left' },
|
||||
level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false } },
|
||||
x: { ticks: { maxTicksLimit: 12 } }
|
||||
flow: { type: 'linear', position: 'left', grid: { color: cssVar('--chart-grid') }, ticks: { color: cssVar('--muted') } },
|
||||
level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false }, ticks: { color: cssVar('--muted') } },
|
||||
x: { ticks: { maxTicksLimit: 12, color: cssVar('--muted') }, grid: { color: cssVar('--chart-grid') } }
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
legend: { display: true, labels: { color: cssVar('--ink') } },
|
||||
floodBands: { enabled: true }
|
||||
}
|
||||
},
|
||||
@@ -1343,7 +1452,7 @@
|
||||
function setP1Level(value, at, opts) {
|
||||
const options = opts || {};
|
||||
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;
|
||||
if (!options.force && known != null && state.p1NowAt != null && known < state.p1NowAt) {
|
||||
return; // an older snapshot must not overwrite a newer one
|
||||
@@ -1383,7 +1492,7 @@
|
||||
|
||||
function renderSummary(stations, readings) {
|
||||
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;
|
||||
$('station-count').textContent = `${current.length} / ${stations.length}`;
|
||||
if (state.hiiReportingCount != null) {
|
||||
@@ -1403,12 +1512,24 @@
|
||||
const worst = stressed.length ? stressed.reduce((max, item) => item.percent > max.percent ? item : max) : null;
|
||||
$('peak-flow').textContent = worst ? `${worst.percent.toFixed(0)}%` : '—';
|
||||
$('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) {
|
||||
const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000));
|
||||
$('data-age').textContent = t('stat.updated.ago',
|
||||
latest.toLocaleDateString(loc(), { day: 'numeric', month: 'short' }), minutes);
|
||||
} else $('data-age').textContent = t('stat.updated.none');
|
||||
const day = latest.toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short' });
|
||||
// RID publishes hourly and the scrape runs hourly, so anything past
|
||||
// ~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() {
|
||||
@@ -1655,13 +1776,13 @@
|
||||
// force: replay frames are 2024 timestamps, older than anything live
|
||||
if (p1Level != null) setP1Level(p1Level, null, { force: true });
|
||||
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
|
||||
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 basinFlow = Math.round(totalFlow).toLocaleString(loc());
|
||||
$('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);
|
||||
// model track: what the forecast system (trained pre-flood) said at this moment
|
||||
const model = data.model || {};
|
||||
@@ -1850,9 +1971,9 @@
|
||||
`<div class="risk-chips">${chips}</div>`;
|
||||
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 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
|
||||
? t('forecast.status.model', modelRow.model_version, trainedAt ? t('forecast.status.trained', trainedAt) : '')
|
||||
: '';
|
||||
@@ -1888,7 +2009,7 @@
|
||||
const strip = $('db-stats');
|
||||
try {
|
||||
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
|
||||
$('db-total').textContent = Number(stats.total_measurements).toLocaleString(loc());
|
||||
if (stats.rid_measurements != null) {
|
||||
@@ -1924,6 +2045,7 @@
|
||||
: t('forecast.expand', count);
|
||||
});
|
||||
$('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);
|
||||
$('sensors-head').addEventListener('click', () => setSensorsOpen(!state.sensorsOpen));
|
||||
$('sensors-head').addEventListener('keydown', (event) => {
|
||||
|
||||
@@ -632,9 +632,7 @@ class EnhancedWaterMonitorScraper:
|
||||
if data:
|
||||
if self.save_to_database(data):
|
||||
filled_count += len(data)
|
||||
logger.info(
|
||||
f"Filled {len(data)} measurements for {fetch_date}"
|
||||
)
|
||||
logger.info(f"Filled {len(data)} measurements for {fetch_date}")
|
||||
else:
|
||||
logger.warning(f"Failed to save data for {fetch_date}")
|
||||
else:
|
||||
|
||||
+17
-16
@@ -374,9 +374,7 @@ async def background_scraping_task():
|
||||
hii_counts = await asyncio.get_event_loop().run_in_executor(
|
||||
None, hii_collector.run_cycle
|
||||
)
|
||||
set_gauge(
|
||||
"hii_rainfall_rows_saved", hii_counts["rainfall"]
|
||||
)
|
||||
set_gauge("hii_rainfall_rows_saved", hii_counts["rainfall"])
|
||||
set_gauge(
|
||||
"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)
|
||||
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)
|
||||
@@ -1022,8 +1022,12 @@ async def get_hii_rainfall_catchment(
|
||||
|
||||
engine = _hii_engine()
|
||||
if engine is None:
|
||||
return {"box": hii_rain.CATCHMENT_BOX, "gauge": [], "openmeteo": [],
|
||||
"comparison_24h_sums": {"overlap_hours": 0}}
|
||||
return {
|
||||
"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)
|
||||
openmeteo = None
|
||||
try:
|
||||
@@ -1050,7 +1054,10 @@ async def get_hii_rainfall_catchment(
|
||||
if s is None:
|
||||
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()
|
||||
]
|
||||
|
||||
@@ -1121,9 +1128,7 @@ async def get_postgres_history(
|
||||
return cached[1]
|
||||
try:
|
||||
db_config = Config.get_database_config()
|
||||
end_time = (
|
||||
datetime.combine(end, datetime.max.time()) if end else datetime.now()
|
||||
)
|
||||
end_time = datetime.combine(end, datetime.max.time()) if end else datetime.now()
|
||||
start_time = (
|
||||
datetime.combine(start, datetime.min.time())
|
||||
if start
|
||||
@@ -1216,17 +1221,13 @@ async def get_forecast_history(
|
||||
store = app_state.get("forecast_store")
|
||||
if not store:
|
||||
return []
|
||||
end_dt = (
|
||||
datetime.combine(end, datetime.max.time()) if end else datetime.now()
|
||||
)
|
||||
end_dt = datetime.combine(end, datetime.max.time()) if end else datetime.now()
|
||||
start_dt = (
|
||||
datetime.combine(start, datetime.min.time())
|
||||
if start
|
||||
else end_dt - timedelta(hours=hours)
|
||||
)
|
||||
return await asyncio.to_thread(
|
||||
store.fetch, station_code, start_dt, end_dt, horizon
|
||||
)
|
||||
return await asyncio.to_thread(store.fetch, station_code, start_dt, end_dt, horizon)
|
||||
|
||||
|
||||
@app.get("/measurements/latest", response_model=List[MeasurementResponse])
|
||||
|
||||
Reference in New Issue
Block a user