Commit ·
a4bd883
0
Parent(s):
Initial myPSD — FastAPI + React port of bokeh-myPSD on rustmatrix
Browse files- backend/app: FastAPI /api/compute + /api/health over rustmatrix, 11 polarimetric metrics + N(D) curve
- backend/tests: 12 pytest cases (smoke + sanity) across S/C/X × rain/hail + canting
- frontend: React 18 + Vite + TypeScript + Plotly (basic bundle), debounced slider inputs
- assets/: Minecraft-block icon with gamma PSD in light blocks + snowflake accent; BioRhyme-bold 'myPSD' wordmark with thick white stroke
- Dockerfile: 3-stage build (node SPA → maturin rustmatrix wheel → python runtime) for HF Spaces
- .github/workflows/hf-sync.yml: mirror main to HF Space on push
- .dockerignore +13 -0
- .gitattributes +1 -0
- .github/workflows/hf-sync.yml +26 -0
- .gitignore +27 -0
- Dockerfile +68 -0
- README.md +70 -0
- assets/icon.svg +288 -0
- assets/logo.svg +298 -0
- backend/app/__init__.py +0 -0
- backend/app/main.py +58 -0
- backend/app/models.py +42 -0
- backend/app/scatter.py +151 -0
- backend/pyproject.toml +31 -0
- backend/tests/__init__.py +0 -0
- backend/tests/test_scatter.py +93 -0
- frontend/index.html +14 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +25 -0
- frontend/public/climas-icon.png +3 -0
- frontend/public/icon.svg +288 -0
- frontend/public/logo.svg +298 -0
- frontend/src/App.tsx +84 -0
- frontend/src/api.ts +15 -0
- frontend/src/components/Controls.tsx +107 -0
- frontend/src/components/MetricsTable.tsx +51 -0
- frontend/src/components/PSDPlot.tsx +51 -0
- frontend/src/main.tsx +10 -0
- frontend/src/styles.css +123 -0
- frontend/src/types.ts +35 -0
- frontend/tsconfig.json +20 -0
- frontend/vite.config.ts +18 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
**/__pycache__
|
| 2 |
+
**/.pytest_cache
|
| 3 |
+
**/.venv
|
| 4 |
+
**/.mypy_cache
|
| 5 |
+
**/.ruff_cache
|
| 6 |
+
**/node_modules
|
| 7 |
+
**/dist
|
| 8 |
+
backend/app/static
|
| 9 |
+
.git
|
| 10 |
+
.github
|
| 11 |
+
.DS_Store
|
| 12 |
+
*.md
|
| 13 |
+
!README.md
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/hf-sync.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
sync:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- uses: actions/checkout@v4
|
| 13 |
+
with:
|
| 14 |
+
fetch-depth: 0
|
| 15 |
+
lfs: true
|
| 16 |
+
|
| 17 |
+
- name: Push to HF Space
|
| 18 |
+
env:
|
| 19 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 20 |
+
HF_USER: snesbitt
|
| 21 |
+
HF_SPACE: myPSD
|
| 22 |
+
run: |
|
| 23 |
+
git config --global user.email "actions@github.com"
|
| 24 |
+
git config --global user.name "GitHub Actions"
|
| 25 |
+
git remote add hf "https://${HF_USER}:${HF_TOKEN}@huggingface.co/spaces/${HF_USER}/${HF_SPACE}"
|
| 26 |
+
git push --force hf main
|
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.egg-info/
|
| 5 |
+
.venv/
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
.mypy_cache/
|
| 8 |
+
.ruff_cache/
|
| 9 |
+
|
| 10 |
+
# Node / Vite
|
| 11 |
+
node_modules/
|
| 12 |
+
dist/
|
| 13 |
+
frontend/dist/
|
| 14 |
+
backend/app/static/
|
| 15 |
+
*.tsbuildinfo
|
| 16 |
+
|
| 17 |
+
# Editors / OS
|
| 18 |
+
.vscode/
|
| 19 |
+
.idea/
|
| 20 |
+
.DS_Store
|
| 21 |
+
|
| 22 |
+
# Local env
|
| 23 |
+
.env
|
| 24 |
+
.env.local
|
| 25 |
+
|
| 26 |
+
# Hugging Face cache
|
| 27 |
+
.cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.6
|
| 2 |
+
#
|
| 3 |
+
# Hugging Face Space Docker build for myPSD.
|
| 4 |
+
#
|
| 5 |
+
# Three stages:
|
| 6 |
+
# 1. node:20-alpine → compile the Vite React SPA
|
| 7 |
+
# 2. rust:1.82-slim → clone rustmatrix at v1.0.1 and build a Python wheel
|
| 8 |
+
# 3. python:3.11-slim → install the wheel + FastAPI, copy the SPA, run uvicorn
|
| 9 |
+
#
|
| 10 |
+
# HF Spaces routes external traffic to whatever port the container listens
|
| 11 |
+
# on via `app_port` in README.md — we use 7860 (the HF default).
|
| 12 |
+
|
| 13 |
+
# ---------- stage 1: build the SPA ----------
|
| 14 |
+
FROM node:20-alpine AS spa
|
| 15 |
+
WORKDIR /spa
|
| 16 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 17 |
+
RUN npm ci --no-audit --no-fund
|
| 18 |
+
COPY frontend/ ./
|
| 19 |
+
# Vite outDir in our config points to ../backend/app/static — we don't want
|
| 20 |
+
# that path layout inside this stage, so override to /spa/dist here.
|
| 21 |
+
RUN npx vite build --outDir dist --emptyOutDir
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------- stage 2: build the rustmatrix wheel ----------
|
| 25 |
+
FROM rust:1.82-slim AS rustbuild
|
| 26 |
+
|
| 27 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 28 |
+
python3 python3-pip python3-venv python3-dev \
|
| 29 |
+
build-essential pkg-config git \
|
| 30 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 31 |
+
|
| 32 |
+
RUN python3 -m pip install --break-system-packages "maturin==1.7.*"
|
| 33 |
+
|
| 34 |
+
WORKDIR /src
|
| 35 |
+
# Pin to the stable release so HF deploys are reproducible.
|
| 36 |
+
ARG RUSTMATRIX_REF=v1.0.1
|
| 37 |
+
RUN git clone --depth 1 --branch ${RUSTMATRIX_REF} \
|
| 38 |
+
https://github.com/swnesbitt/rustmatrix.git .
|
| 39 |
+
RUN maturin build --release --out /wheels -i python3
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------- stage 3: runtime ----------
|
| 43 |
+
FROM python:3.11-slim
|
| 44 |
+
|
| 45 |
+
WORKDIR /app
|
| 46 |
+
|
| 47 |
+
COPY --from=rustbuild /wheels /wheels
|
| 48 |
+
RUN pip install --no-cache-dir /wheels/*.whl \
|
| 49 |
+
&& pip install --no-cache-dir \
|
| 50 |
+
"fastapi>=0.110" \
|
| 51 |
+
"uvicorn[standard]>=0.27" \
|
| 52 |
+
"pydantic>=2.6" \
|
| 53 |
+
"numpy>=1.23" \
|
| 54 |
+
"scipy>=1.10" \
|
| 55 |
+
&& rm -rf /wheels
|
| 56 |
+
|
| 57 |
+
# HF Spaces runs the container as uid 1000 with /tmp as the only writable
|
| 58 |
+
# directory by default.
|
| 59 |
+
ENV HOME=/tmp \
|
| 60 |
+
XDG_CACHE_HOME=/tmp/.cache
|
| 61 |
+
|
| 62 |
+
COPY backend/app /app/app
|
| 63 |
+
COPY --from=spa /spa/dist /app/app/static
|
| 64 |
+
# Also make the repo-level assets discoverable (logo/icon) for non-bundled use.
|
| 65 |
+
COPY assets /app/app/static/assets
|
| 66 |
+
|
| 67 |
+
EXPOSE 7860
|
| 68 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: myPSD
|
| 3 |
+
emoji: 🌧️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
<p align="center">
|
| 12 |
+
<img src="assets/logo.svg" alt="myPSD" width="640" />
|
| 13 |
+
</p>
|
| 14 |
+
|
| 15 |
+
# myPSD
|
| 16 |
+
|
| 17 |
+
Interactive explorer for polarimetric radar observables derived from a
|
| 18 |
+
normalized-gamma particle-size distribution (Testud et al. 2001). Drag the
|
| 19 |
+
sliders to vary `Dm`, `log₁₀Nw`, and `μ`; pick a radar wavelength (S / C / X),
|
| 20 |
+
canting-angle standard deviation, and precipitation type (rain or hail) to see
|
| 21 |
+
the resulting N(D) curve and 11-metric polarimetric table update live.
|
| 22 |
+
|
| 23 |
+
This is a port of [`swnesbitt/bokeh-myPSD`](https://github.com/swnesbitt/bokeh-myPSD)
|
| 24 |
+
rebuilt on:
|
| 25 |
+
|
| 26 |
+
- **[rustmatrix](https://github.com/swnesbitt/rustmatrix)** — Rust-backed
|
| 27 |
+
T-matrix scattering (drop-in `pytmatrix` replacement)
|
| 28 |
+
- **FastAPI** — thin JSON API over the scatterer
|
| 29 |
+
- **React + Vite + Plotly.js** — interactive single-page frontend
|
| 30 |
+
- **Docker on Hugging Face Spaces** — deployment
|
| 31 |
+
|
| 32 |
+
Branded with the [CLIMAS](https://climas.illinois.edu/) group icon.
|
| 33 |
+
|
| 34 |
+
## Local development
|
| 35 |
+
|
| 36 |
+
### Backend
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
cd backend
|
| 40 |
+
uv venv
|
| 41 |
+
uv pip install -e .
|
| 42 |
+
uv run uvicorn app.main:app --reload --port 8000
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### Frontend
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
cd frontend
|
| 49 |
+
npm ci
|
| 50 |
+
npm run dev
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
The Vite dev server proxies `/api` to `localhost:8000`. Production builds
|
| 54 |
+
output to `backend/app/static/`, which FastAPI serves from the same origin.
|
| 55 |
+
|
| 56 |
+
### Docker (simulates HF Spaces)
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
docker build -t mypsd .
|
| 60 |
+
docker run --rm -p 7860:7860 -e HOME=/tmp mypsd
|
| 61 |
+
# open http://localhost:7860
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Attribution
|
| 65 |
+
|
| 66 |
+
- **myPSD** concept & original Bokeh app: Steve Nesbitt, University of
|
| 67 |
+
Illinois Urbana-Champaign.
|
| 68 |
+
- **pytmatrix** (the original T-matrix Python wrapper): Jussi Leinonen,
|
| 69 |
+
MeteoSwiss.
|
| 70 |
+
- **rustmatrix**: Rust port of the pytmatrix numerical core.
|
assets/icon.svg
ADDED
|
|
assets/logo.svg
ADDED
|
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI entry point for myPSD.
|
| 2 |
+
|
| 3 |
+
In production the compiled React SPA is baked into `app/static/` by the
|
| 4 |
+
Dockerfile, and this process serves both `/api/*` and the SPA. In local
|
| 5 |
+
dev, Vite serves the SPA from its own port and proxies `/api/*` here.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
|
| 16 |
+
from . import scatter
|
| 17 |
+
from .models import ComputeRequest, ComputeResponse
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
app = FastAPI(title="myPSD", version="0.1.0")
|
| 21 |
+
|
| 22 |
+
# Dev proxy hits us cross-origin; production is same-origin. Permissive CORS
|
| 23 |
+
# is fine for a stateless teaching tool with no auth.
|
| 24 |
+
app.add_middleware(
|
| 25 |
+
CORSMiddleware,
|
| 26 |
+
allow_origins=["*"],
|
| 27 |
+
allow_methods=["GET", "POST"],
|
| 28 |
+
allow_headers=["*"],
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@app.get("/api/health")
|
| 33 |
+
def health() -> dict[str, str]:
|
| 34 |
+
try:
|
| 35 |
+
import rustmatrix
|
| 36 |
+
|
| 37 |
+
return {"status": "ok", "rustmatrix": rustmatrix.__version__}
|
| 38 |
+
except Exception as exc: # pragma: no cover
|
| 39 |
+
return {"status": "degraded", "error": str(exc)}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.post("/api/compute", response_model=ComputeResponse)
|
| 43 |
+
def compute(req: ComputeRequest) -> ComputeResponse:
|
| 44 |
+
return scatter.compute(
|
| 45 |
+
dm=req.dm,
|
| 46 |
+
log_nw=req.log_nw,
|
| 47 |
+
mu=req.mu,
|
| 48 |
+
band=req.band,
|
| 49 |
+
canting_std_deg=req.canting_std_deg,
|
| 50 |
+
precip=req.precip,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Mount the built SPA if present. Using `html=True` makes unknown paths fall
|
| 55 |
+
# back to index.html so client-side routing works.
|
| 56 |
+
_STATIC_DIR = Path(__file__).parent / "static"
|
| 57 |
+
if _STATIC_DIR.exists():
|
| 58 |
+
app.mount("/", StaticFiles(directory=_STATIC_DIR, html=True), name="static")
|
backend/app/models.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
Band = Literal["S", "C", "X"]
|
| 9 |
+
Precip = Literal["rain", "hail"]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ComputeRequest(BaseModel):
|
| 13 |
+
dm: float = Field(ge=0.1, le=8.0, description="Mass-weighted mean diameter [mm]")
|
| 14 |
+
log_nw: float = Field(ge=0.5, le=6.0, description="log10(Nw) [mm^-1 m^-3]")
|
| 15 |
+
mu: float = Field(ge=-3.0, le=80.0, description="Gamma shape parameter")
|
| 16 |
+
band: Band = "S"
|
| 17 |
+
canting_std_deg: float = Field(default=0.0, ge=0.0, le=40.0)
|
| 18 |
+
precip: Precip = "rain"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Metrics(BaseModel):
|
| 22 |
+
zh_dbz: float
|
| 23 |
+
zv_dbz: float
|
| 24 |
+
zdr_db: float
|
| 25 |
+
ldr_db: float
|
| 26 |
+
rho_hv: float
|
| 27 |
+
delta_deg: float
|
| 28 |
+
kdp_deg_per_km: float
|
| 29 |
+
ah_db_per_km: float
|
| 30 |
+
adr_db_per_km: float
|
| 31 |
+
nt_per_m3: float
|
| 32 |
+
lwc_g_per_m3: float
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class NDCurve(BaseModel):
|
| 36 |
+
d_mm: list[float]
|
| 37 |
+
n_d: list[float]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ComputeResponse(BaseModel):
|
| 41 |
+
metrics: Metrics
|
| 42 |
+
nd: NDCurve
|
backend/app/scatter.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PSD-integrated polarimetric radar metrics via rustmatrix.
|
| 2 |
+
|
| 3 |
+
Mirrors the scatter path from bokeh-myPSD's bokeh-app/main.py, ported to
|
| 4 |
+
rustmatrix. The scatter table is the expensive step; cache it keyed by
|
| 5 |
+
(band, precip, canting_std) so that Dm / log_Nw / mu sweeps are cheap.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from scipy.special import gamma as gamma_fn
|
| 14 |
+
|
| 15 |
+
from rustmatrix import orientation, radar, refractive, tmatrix_aux
|
| 16 |
+
from rustmatrix.psd import GammaPSD, PSDIntegrator
|
| 17 |
+
from rustmatrix.scatter import ldr as scatter_ldr
|
| 18 |
+
from rustmatrix.scatterer import Scatterer
|
| 19 |
+
|
| 20 |
+
from .models import Band, ComputeResponse, Metrics, NDCurve, Precip
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
_BAND_WL = {"S": tmatrix_aux.wl_S, "C": tmatrix_aux.wl_C, "X": tmatrix_aux.wl_X}
|
| 24 |
+
|
| 25 |
+
_HAIL_M = complex(1.78, 7.9e-4)
|
| 26 |
+
|
| 27 |
+
_D_PLOT = np.arange(0.1, 20.0, 0.1) # mm
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _drop_ar(d_eq: float) -> float:
|
| 31 |
+
"""Beard-Chuang-style axis ratio for raindrops (from pytmatrix examples)."""
|
| 32 |
+
if d_eq < 0.7:
|
| 33 |
+
return 1.0
|
| 34 |
+
if d_eq < 1.5:
|
| 35 |
+
return (
|
| 36 |
+
1.173
|
| 37 |
+
- 0.5165 * d_eq
|
| 38 |
+
+ 0.4698 * d_eq**2
|
| 39 |
+
- 0.1317 * d_eq**3
|
| 40 |
+
- 8.5e-3 * d_eq**4
|
| 41 |
+
)
|
| 42 |
+
return (
|
| 43 |
+
1.065
|
| 44 |
+
- 6.25e-2 * d_eq
|
| 45 |
+
- 3.99e-3 * d_eq**2
|
| 46 |
+
+ 7.66e-4 * d_eq**3
|
| 47 |
+
- 4.095e-5 * d_eq**4
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _rain_axis_ratio(d: float) -> float:
|
| 52 |
+
return 1.0 / _drop_ar(d)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _hail_axis_ratio(d: float) -> float:
|
| 56 |
+
return 0.99
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@lru_cache(maxsize=24)
|
| 60 |
+
def _build_scatterer(band: Band, precip: Precip, canting_std_deg: float) -> Scatterer:
|
| 61 |
+
"""Build a scatterer and pre-compute its PSD scatter table.
|
| 62 |
+
|
| 63 |
+
Result is cached across requests because the table build dominates latency.
|
| 64 |
+
A Scatterer holds mutable PSD state, but we rewrite `scatterer.psd` on
|
| 65 |
+
every compute() call, so reuse is safe as long as geometry / canting /
|
| 66 |
+
refractive index don't change — which is exactly what this key covers.
|
| 67 |
+
"""
|
| 68 |
+
wavelength = _BAND_WL[band]
|
| 69 |
+
m = _HAIL_M if precip == "hail" else refractive.m_w_10C[wavelength]
|
| 70 |
+
|
| 71 |
+
scatterer = Scatterer(wavelength=wavelength, m=m)
|
| 72 |
+
scatterer.psd_integrator = PSDIntegrator()
|
| 73 |
+
scatterer.psd_integrator.axis_ratio_func = (
|
| 74 |
+
_rain_axis_ratio if precip == "rain" else _hail_axis_ratio
|
| 75 |
+
)
|
| 76 |
+
scatterer.psd_integrator.D_max = 10.0
|
| 77 |
+
scatterer.psd_integrator.geometries = (
|
| 78 |
+
tmatrix_aux.geom_horiz_back,
|
| 79 |
+
tmatrix_aux.geom_horiz_forw,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if canting_std_deg > 0.0:
|
| 83 |
+
scatterer.or_pdf = orientation.gaussian_pdf(canting_std_deg)
|
| 84 |
+
scatterer.orient = orientation.orient_averaged_fixed
|
| 85 |
+
|
| 86 |
+
scatterer.psd_integrator.init_scatter_table(scatterer)
|
| 87 |
+
return scatterer
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _nd(d_mm: np.ndarray, dm: float, log_nw: float, mu: float) -> np.ndarray:
|
| 91 |
+
"""Normalized gamma PSD, Testud et al. 2001 convention."""
|
| 92 |
+
f_u = (6.0 / 4.0**4) * ((4.0 + mu) ** (mu + 4.0)) / gamma_fn(mu + 4.0)
|
| 93 |
+
return (
|
| 94 |
+
10.0**log_nw
|
| 95 |
+
* f_u
|
| 96 |
+
* (d_mm / dm) ** mu
|
| 97 |
+
* np.exp(-(4.0 + mu) * (d_mm / dm))
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def compute(
|
| 102 |
+
dm: float,
|
| 103 |
+
log_nw: float,
|
| 104 |
+
mu: float,
|
| 105 |
+
band: Band,
|
| 106 |
+
canting_std_deg: float,
|
| 107 |
+
precip: Precip,
|
| 108 |
+
) -> ComputeResponse:
|
| 109 |
+
scatterer = _build_scatterer(band, precip, float(canting_std_deg))
|
| 110 |
+
|
| 111 |
+
d0 = (3.67 + mu) / (4.0 + mu) * dm
|
| 112 |
+
nw = 10.0**log_nw
|
| 113 |
+
scatterer.psd = GammaPSD(D0=d0, Nw=nw, mu=mu)
|
| 114 |
+
|
| 115 |
+
scatterer.set_geometry(tmatrix_aux.geom_horiz_back)
|
| 116 |
+
zh = 10.0 * np.log10(radar.refl(scatterer))
|
| 117 |
+
zv = 10.0 * np.log10(radar.refl(scatterer, False))
|
| 118 |
+
zdr = 10.0 * np.log10(radar.Zdr(scatterer))
|
| 119 |
+
ldr_db = 10.0 * np.log10(scatter_ldr(scatterer))
|
| 120 |
+
rho_hv = float(radar.rho_hv(scatterer))
|
| 121 |
+
delta = float(radar.delta_hv(scatterer))
|
| 122 |
+
|
| 123 |
+
scatterer.set_geometry(tmatrix_aux.geom_horiz_forw)
|
| 124 |
+
kdp = float(radar.Kdp(scatterer))
|
| 125 |
+
ah = float(radar.Ai(scatterer))
|
| 126 |
+
av = float(radar.Ai(scatterer, h_pol=False))
|
| 127 |
+
adr = ah - av
|
| 128 |
+
|
| 129 |
+
f_u = (6.0 / 4.0**4) * ((4.0 + mu) ** (mu + 4.0)) / gamma_fn(mu + 4.0)
|
| 130 |
+
nt = nw * f_u * gamma_fn(mu + 1.0) * dm / ((4.0 + mu) ** (mu + 1.0))
|
| 131 |
+
density = 1000.0 if precip == "rain" else 917.0
|
| 132 |
+
lwc = (np.pi * nw * dm**4) / (4.0**4 * density)
|
| 133 |
+
|
| 134 |
+
metrics = Metrics(
|
| 135 |
+
zh_dbz=float(zh),
|
| 136 |
+
zv_dbz=float(zv),
|
| 137 |
+
zdr_db=float(zdr),
|
| 138 |
+
ldr_db=float(ldr_db),
|
| 139 |
+
rho_hv=rho_hv,
|
| 140 |
+
delta_deg=delta,
|
| 141 |
+
kdp_deg_per_km=kdp,
|
| 142 |
+
ah_db_per_km=ah,
|
| 143 |
+
adr_db_per_km=adr,
|
| 144 |
+
nt_per_m3=float(nt),
|
| 145 |
+
lwc_g_per_m3=float(lwc),
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
n_d = _nd(_D_PLOT, dm, log_nw, mu)
|
| 149 |
+
nd = NDCurve(d_mm=_D_PLOT.tolist(), n_d=n_d.tolist())
|
| 150 |
+
|
| 151 |
+
return ComputeResponse(metrics=metrics, nd=nd)
|
backend/pyproject.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "mypsd-backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "FastAPI backend for myPSD — polarimetric radar PSD explorer"
|
| 5 |
+
requires-python = ">=3.10"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"rustmatrix>=1.0.1",
|
| 8 |
+
"fastapi>=0.110",
|
| 9 |
+
"uvicorn[standard]>=0.27",
|
| 10 |
+
"pydantic>=2.6",
|
| 11 |
+
"numpy>=1.23",
|
| 12 |
+
"scipy>=1.10",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
[tool.uv.sources]
|
| 16 |
+
# rustmatrix is not on PyPI yet — build from the sibling checkout.
|
| 17 |
+
# The Dockerfile builds a wheel directly and installs that instead.
|
| 18 |
+
rustmatrix = { path = "../../Radar/rustmatrix", editable = true }
|
| 19 |
+
|
| 20 |
+
[project.optional-dependencies]
|
| 21 |
+
dev = [
|
| 22 |
+
"pytest>=8.0",
|
| 23 |
+
"httpx>=0.27",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
[build-system]
|
| 27 |
+
requires = ["hatchling"]
|
| 28 |
+
build-backend = "hatchling.build"
|
| 29 |
+
|
| 30 |
+
[tool.hatch.build.targets.wheel]
|
| 31 |
+
packages = ["app"]
|
backend/tests/__init__.py
ADDED
|
File without changes
|
backend/tests/test_scatter.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke + sanity tests for the scatter path.
|
| 2 |
+
|
| 3 |
+
These don't pin values to bokeh-myPSD screenshots (those aren't captured yet),
|
| 4 |
+
but they exercise every branch — rain/hail × S/C/X × canted/uncanted — and
|
| 5 |
+
assert physical sanity: finite, in-range, monotonic where expected.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
from fastapi.testclient import TestClient
|
| 14 |
+
|
| 15 |
+
from app.main import app
|
| 16 |
+
from app.scatter import compute
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture(scope="module")
|
| 20 |
+
def client():
|
| 21 |
+
return TestClient(app)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.mark.parametrize("band", ["S", "C", "X"])
|
| 25 |
+
@pytest.mark.parametrize("precip", ["rain", "hail"])
|
| 26 |
+
def test_compute_smoke(band, precip):
|
| 27 |
+
r = compute(dm=2.0, log_nw=3.0, mu=0.0, band=band, canting_std_deg=0.0, precip=precip)
|
| 28 |
+
m = r.metrics
|
| 29 |
+
|
| 30 |
+
assert all(math.isfinite(v) for v in (
|
| 31 |
+
m.zh_dbz, m.zv_dbz, m.zdr_db, m.ldr_db, m.rho_hv, m.delta_deg,
|
| 32 |
+
m.kdp_deg_per_km, m.ah_db_per_km, m.adr_db_per_km, m.nt_per_m3, m.lwc_g_per_m3,
|
| 33 |
+
))
|
| 34 |
+
# ρ_hv in physical range
|
| 35 |
+
assert 0.0 < m.rho_hv <= 1.0
|
| 36 |
+
# reasonable reflectivity for Dm=2mm, Nw=10^3
|
| 37 |
+
assert 15.0 < m.zh_dbz < 60.0
|
| 38 |
+
# Zh vs Zv: rain is oblate (Zh > Zv); hail at 0.99 is near-spherical so
|
| 39 |
+
# |Zh - Zv| should at least be small.
|
| 40 |
+
if precip == "rain":
|
| 41 |
+
assert m.zh_dbz > m.zv_dbz
|
| 42 |
+
else:
|
| 43 |
+
assert abs(m.zh_dbz - m.zv_dbz) < 0.3
|
| 44 |
+
# PSD curve shape
|
| 45 |
+
assert len(r.nd.d_mm) == len(r.nd.n_d) == 199
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_hail_is_near_isotropic():
|
| 49 |
+
"""Hail at axis_ratio 0.99 should give near-zero Zdr."""
|
| 50 |
+
r = compute(dm=3.0, log_nw=3.0, mu=0.0, band="S", canting_std_deg=0.0, precip="hail")
|
| 51 |
+
assert abs(r.metrics.zdr_db) < 0.2
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_rain_has_positive_zdr():
|
| 55 |
+
"""Rain drops are oblate → Zdr > 0."""
|
| 56 |
+
r = compute(dm=3.0, log_nw=3.0, mu=0.0, band="S", canting_std_deg=0.0, precip="rain")
|
| 57 |
+
assert r.metrics.zdr_db > 0.5
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_api_health(client):
|
| 61 |
+
resp = client.get("/api/health")
|
| 62 |
+
assert resp.status_code == 200
|
| 63 |
+
body = resp.json()
|
| 64 |
+
assert body["status"] == "ok"
|
| 65 |
+
assert "rustmatrix" in body
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_api_compute(client):
|
| 69 |
+
resp = client.post(
|
| 70 |
+
"/api/compute",
|
| 71 |
+
json={"dm": 2.0, "log_nw": 3.0, "mu": 0.0, "band": "S",
|
| 72 |
+
"canting_std_deg": 0.0, "precip": "rain"},
|
| 73 |
+
)
|
| 74 |
+
assert resp.status_code == 200
|
| 75 |
+
body = resp.json()
|
| 76 |
+
assert "metrics" in body and "nd" in body
|
| 77 |
+
assert len(body["nd"]["d_mm"]) == 199
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_api_rejects_out_of_range(client):
|
| 81 |
+
resp = client.post(
|
| 82 |
+
"/api/compute",
|
| 83 |
+
json={"dm": 99.0, "log_nw": 3.0, "mu": 0.0, "band": "S",
|
| 84 |
+
"canting_std_deg": 0.0, "precip": "rain"},
|
| 85 |
+
)
|
| 86 |
+
assert resp.status_code == 422
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_canting_reduces_zdr():
|
| 90 |
+
"""Wider canting PDF should reduce |Zdr| toward zero."""
|
| 91 |
+
r0 = compute(dm=3.0, log_nw=3.0, mu=0.0, band="S", canting_std_deg=0.0, precip="rain")
|
| 92 |
+
r1 = compute(dm=3.0, log_nw=3.0, mu=0.0, band="S", canting_std_deg=20.0, precip="rain")
|
| 93 |
+
assert r1.metrics.zdr_db < r0.metrics.zdr_db
|
frontend/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta name="description" content="Interactive polarimetric radar PSD explorer — rustmatrix + CLIMAS" />
|
| 8 |
+
<title>myPSD — polarimetric radar PSD explorer</title>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div id="root"></div>
|
| 12 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "mypsd-frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.1.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"plotly.js-basic-dist-min": "^2.35.2",
|
| 13 |
+
"react": "^18.3.1",
|
| 14 |
+
"react-dom": "^18.3.1",
|
| 15 |
+
"react-plotly.js": "^2.6.0"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@types/react": "^18.3.12",
|
| 19 |
+
"@types/react-dom": "^18.3.1",
|
| 20 |
+
"@types/react-plotly.js": "^2.6.3",
|
| 21 |
+
"@vitejs/plugin-react": "^4.3.3",
|
| 22 |
+
"typescript": "^5.6.3",
|
| 23 |
+
"vite": "^5.4.10"
|
| 24 |
+
}
|
| 25 |
+
}
|
frontend/public/climas-icon.png
ADDED
|
|
Git LFS Details
|
frontend/public/icon.svg
ADDED
|
|
frontend/public/logo.svg
ADDED
|
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from 'react'
|
| 2 |
+
import { Controls, type ControlsState } from './components/Controls'
|
| 3 |
+
import { PSDPlot } from './components/PSDPlot'
|
| 4 |
+
import { MetricsTable } from './components/MetricsTable'
|
| 5 |
+
import { compute } from './api'
|
| 6 |
+
import type { ComputeResponse } from './types'
|
| 7 |
+
|
| 8 |
+
const INITIAL: ControlsState = {
|
| 9 |
+
dm: 2.0,
|
| 10 |
+
logNw: 3.0,
|
| 11 |
+
mu: 0,
|
| 12 |
+
band: 'S',
|
| 13 |
+
cantingStd: 0,
|
| 14 |
+
precip: 'rain',
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export default function App() {
|
| 18 |
+
const [state, setState] = useState<ControlsState>(INITIAL)
|
| 19 |
+
const [result, setResult] = useState<ComputeResponse | null>(null)
|
| 20 |
+
const [error, setError] = useState<string | null>(null)
|
| 21 |
+
const abortRef = useRef<AbortController | null>(null)
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
// Debounce + abort-in-flight so slider drags don't flood the backend.
|
| 25 |
+
const controller = new AbortController()
|
| 26 |
+
abortRef.current?.abort()
|
| 27 |
+
abortRef.current = controller
|
| 28 |
+
const id = setTimeout(() => {
|
| 29 |
+
compute(
|
| 30 |
+
{
|
| 31 |
+
dm: state.dm,
|
| 32 |
+
log_nw: state.logNw,
|
| 33 |
+
mu: state.mu,
|
| 34 |
+
band: state.band,
|
| 35 |
+
canting_std_deg: state.cantingStd,
|
| 36 |
+
precip: state.precip,
|
| 37 |
+
},
|
| 38 |
+
controller.signal,
|
| 39 |
+
)
|
| 40 |
+
.then((r) => {
|
| 41 |
+
setResult(r)
|
| 42 |
+
setError(null)
|
| 43 |
+
})
|
| 44 |
+
.catch((err: unknown) => {
|
| 45 |
+
if (err instanceof Error && err.name !== 'AbortError') {
|
| 46 |
+
setError(err.message)
|
| 47 |
+
}
|
| 48 |
+
})
|
| 49 |
+
}, 150)
|
| 50 |
+
return () => {
|
| 51 |
+
clearTimeout(id)
|
| 52 |
+
controller.abort()
|
| 53 |
+
}
|
| 54 |
+
}, [state])
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<div className="app">
|
| 58 |
+
<header className="header">
|
| 59 |
+
<img src="/logo.svg" alt="myPSD" className="brand" />
|
| 60 |
+
<div className="subtitle">
|
| 61 |
+
Normalized gamma PSD · T-matrix via rustmatrix
|
| 62 |
+
</div>
|
| 63 |
+
<img src="/climas-icon.png" alt="CLIMAS — University of Illinois" className="climas" />
|
| 64 |
+
</header>
|
| 65 |
+
|
| 66 |
+
<main className="main">
|
| 67 |
+
<Controls value={state} onChange={setState} />
|
| 68 |
+
<div className="right">
|
| 69 |
+
{error && <div className="error">{error}</div>}
|
| 70 |
+
<PSDPlot nd={result?.nd ?? null} />
|
| 71 |
+
<MetricsTable metrics={result?.metrics ?? null} />
|
| 72 |
+
</div>
|
| 73 |
+
</main>
|
| 74 |
+
|
| 75 |
+
<footer className="footer">
|
| 76 |
+
Based on{' '}
|
| 77 |
+
<a href="https://github.com/swnesbitt/bokeh-myPSD">bokeh-myPSD</a> ·
|
| 78 |
+
Scattering by{' '}
|
| 79 |
+
<a href="https://github.com/swnesbitt/rustmatrix">rustmatrix</a> ·
|
| 80 |
+
Source at <a href="https://github.com/swnesbitt/myPSD">swnesbitt/myPSD</a>
|
| 81 |
+
</footer>
|
| 82 |
+
</div>
|
| 83 |
+
)
|
| 84 |
+
}
|
frontend/src/api.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ComputeRequest, ComputeResponse } from './types'
|
| 2 |
+
|
| 3 |
+
export async function compute(req: ComputeRequest, signal?: AbortSignal): Promise<ComputeResponse> {
|
| 4 |
+
const resp = await fetch('/api/compute', {
|
| 5 |
+
method: 'POST',
|
| 6 |
+
headers: { 'Content-Type': 'application/json' },
|
| 7 |
+
body: JSON.stringify(req),
|
| 8 |
+
signal,
|
| 9 |
+
})
|
| 10 |
+
if (!resp.ok) {
|
| 11 |
+
const text = await resp.text()
|
| 12 |
+
throw new Error(`compute failed: ${resp.status} ${text}`)
|
| 13 |
+
}
|
| 14 |
+
return resp.json() as Promise<ComputeResponse>
|
| 15 |
+
}
|
frontend/src/components/Controls.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Band, Precip } from '../types'
|
| 2 |
+
|
| 3 |
+
export interface ControlsState {
|
| 4 |
+
dm: number
|
| 5 |
+
logNw: number
|
| 6 |
+
mu: number
|
| 7 |
+
band: Band
|
| 8 |
+
cantingStd: number
|
| 9 |
+
precip: Precip
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface Props {
|
| 13 |
+
value: ControlsState
|
| 14 |
+
onChange: (next: ControlsState) => void
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function Controls({ value, onChange }: Props) {
|
| 18 |
+
const set = <K extends keyof ControlsState>(k: K, v: ControlsState[K]) =>
|
| 19 |
+
onChange({ ...value, [k]: v })
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className="panel controls">
|
| 23 |
+
<div className="field">
|
| 24 |
+
<label>
|
| 25 |
+
Precip type
|
| 26 |
+
</label>
|
| 27 |
+
<select
|
| 28 |
+
value={value.precip}
|
| 29 |
+
onChange={(e) => set('precip', e.target.value as Precip)}
|
| 30 |
+
>
|
| 31 |
+
<option value="rain">rain</option>
|
| 32 |
+
<option value="hail">hail</option>
|
| 33 |
+
</select>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div className="field">
|
| 37 |
+
<label>
|
| 38 |
+
Wavelength
|
| 39 |
+
</label>
|
| 40 |
+
<select
|
| 41 |
+
value={value.band}
|
| 42 |
+
onChange={(e) => set('band', e.target.value as Band)}
|
| 43 |
+
>
|
| 44 |
+
<option value="S">S band (10 cm)</option>
|
| 45 |
+
<option value="C">C band (5 cm)</option>
|
| 46 |
+
<option value="X">X band (3 cm)</option>
|
| 47 |
+
</select>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div className="field">
|
| 51 |
+
<label>
|
| 52 |
+
Dm (mm) <span className="value">{value.dm.toFixed(1)}</span>
|
| 53 |
+
</label>
|
| 54 |
+
<input
|
| 55 |
+
type="range"
|
| 56 |
+
min={0.5}
|
| 57 |
+
max={8.0}
|
| 58 |
+
step={0.1}
|
| 59 |
+
value={value.dm}
|
| 60 |
+
onChange={(e) => set('dm', parseFloat(e.target.value))}
|
| 61 |
+
/>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div className="field">
|
| 65 |
+
<label>
|
| 66 |
+
log₁₀ Nw (mm⁻¹ m⁻³) <span className="value">{value.logNw.toFixed(2)}</span>
|
| 67 |
+
</label>
|
| 68 |
+
<input
|
| 69 |
+
type="range"
|
| 70 |
+
min={0.5}
|
| 71 |
+
max={6.0}
|
| 72 |
+
step={0.1}
|
| 73 |
+
value={value.logNw}
|
| 74 |
+
onChange={(e) => set('logNw', parseFloat(e.target.value))}
|
| 75 |
+
/>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div className="field">
|
| 79 |
+
<label>
|
| 80 |
+
μ (shape) <span className="value">{value.mu.toFixed(0)}</span>
|
| 81 |
+
</label>
|
| 82 |
+
<input
|
| 83 |
+
type="range"
|
| 84 |
+
min={-3}
|
| 85 |
+
max={80}
|
| 86 |
+
step={1}
|
| 87 |
+
value={value.mu}
|
| 88 |
+
onChange={(e) => set('mu', parseFloat(e.target.value))}
|
| 89 |
+
/>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div className="field">
|
| 93 |
+
<label>
|
| 94 |
+
Canting σ (deg) <span className="value">{value.cantingStd.toFixed(0)}</span>
|
| 95 |
+
</label>
|
| 96 |
+
<input
|
| 97 |
+
type="range"
|
| 98 |
+
min={0}
|
| 99 |
+
max={40}
|
| 100 |
+
step={4}
|
| 101 |
+
value={value.cantingStd}
|
| 102 |
+
onChange={(e) => set('cantingStd', parseFloat(e.target.value))}
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
)
|
| 107 |
+
}
|
frontend/src/components/MetricsTable.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metrics } from '../types'
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
metrics: Metrics | null
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
function fmt(v: number, digits = 3): string {
|
| 8 |
+
if (!Number.isFinite(v)) return '—'
|
| 9 |
+
if (Math.abs(v) !== 0 && (Math.abs(v) < 1e-2 || Math.abs(v) >= 1e4)) {
|
| 10 |
+
return v.toExponential(2)
|
| 11 |
+
}
|
| 12 |
+
return v.toFixed(digits)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function MetricsTable({ metrics }: Props) {
|
| 16 |
+
const rows: Array<[string, string]> = metrics
|
| 17 |
+
? [
|
| 18 |
+
['Zh (dBZ)', fmt(metrics.zh_dbz, 2)],
|
| 19 |
+
['Zv (dBZ)', fmt(metrics.zv_dbz, 2)],
|
| 20 |
+
['Zdr (dB)', fmt(metrics.zdr_db, 3)],
|
| 21 |
+
['LDR (dB)', fmt(metrics.ldr_db, 2)],
|
| 22 |
+
['ρ_hv', fmt(metrics.rho_hv, 5)],
|
| 23 |
+
['δ_hv (deg)', fmt(metrics.delta_deg, 3)],
|
| 24 |
+
['Kdp (° km⁻¹)', fmt(metrics.kdp_deg_per_km, 4)],
|
| 25 |
+
['Ah (dB km⁻¹)', fmt(metrics.ah_db_per_km, 4)],
|
| 26 |
+
['Adr (dB km⁻¹)', fmt(metrics.adr_db_per_km, 5)],
|
| 27 |
+
['NT (m⁻³)', fmt(metrics.nt_per_m3, 2)],
|
| 28 |
+
['LWC (g m⁻³)', fmt(metrics.lwc_g_per_m3, 4)],
|
| 29 |
+
]
|
| 30 |
+
: []
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="panel">
|
| 34 |
+
<h3 style={{ margin: '0 0 10px', fontSize: 16 }}>Polarimetric metrics</h3>
|
| 35 |
+
{metrics ? (
|
| 36 |
+
<table className="table">
|
| 37 |
+
<tbody>
|
| 38 |
+
{rows.map(([label, v]) => (
|
| 39 |
+
<tr key={label}>
|
| 40 |
+
<th>{label}</th>
|
| 41 |
+
<td>{v}</td>
|
| 42 |
+
</tr>
|
| 43 |
+
))}
|
| 44 |
+
</tbody>
|
| 45 |
+
</table>
|
| 46 |
+
) : (
|
| 47 |
+
<div style={{ color: '#5a6578' }}>Computing…</div>
|
| 48 |
+
)}
|
| 49 |
+
</div>
|
| 50 |
+
)
|
| 51 |
+
}
|
frontend/src/components/PSDPlot.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Use the lean 'basic' Plotly bundle (~1 MB instead of ~3 MB) — a line plot
|
| 2 |
+
// doesn't need 3D, maps, or statistical chart types.
|
| 3 |
+
import createPlotlyComponent from 'react-plotly.js/factory'
|
| 4 |
+
// @ts-expect-error plotly.js-basic-dist-min has no types
|
| 5 |
+
import Plotly from 'plotly.js-basic-dist-min'
|
| 6 |
+
import type { NDCurve } from '../types'
|
| 7 |
+
|
| 8 |
+
const Plot = createPlotlyComponent(Plotly)
|
| 9 |
+
|
| 10 |
+
interface Props {
|
| 11 |
+
nd: NDCurve | null
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function PSDPlot({ nd }: Props) {
|
| 15 |
+
const data = nd
|
| 16 |
+
? [{
|
| 17 |
+
x: nd.d_mm,
|
| 18 |
+
y: nd.n_d,
|
| 19 |
+
type: 'scatter' as const,
|
| 20 |
+
mode: 'lines' as const,
|
| 21 |
+
line: { color: '#1f4e79', width: 3 },
|
| 22 |
+
name: 'N(D)',
|
| 23 |
+
hovertemplate: 'D = %{x:.2f} mm<br>N(D) = %{y:.3e} mm⁻¹ m⁻³<extra></extra>',
|
| 24 |
+
}]
|
| 25 |
+
: []
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className="panel">
|
| 29 |
+
<Plot
|
| 30 |
+
data={data}
|
| 31 |
+
layout={{
|
| 32 |
+
title: { text: 'Particle size distribution' },
|
| 33 |
+
xaxis: { title: { text: 'Particle diameter (mm)' }, range: [0, 10] },
|
| 34 |
+
yaxis: {
|
| 35 |
+
title: { text: 'N(D) (mm⁻¹ m⁻³)' },
|
| 36 |
+
type: 'log',
|
| 37 |
+
range: [-1, 6],
|
| 38 |
+
},
|
| 39 |
+
margin: { t: 40, b: 50, l: 70, r: 20 },
|
| 40 |
+
height: 380,
|
| 41 |
+
plot_bgcolor: '#f7f9fc',
|
| 42 |
+
paper_bgcolor: '#ffffff',
|
| 43 |
+
font: { family: 'system-ui, sans-serif' },
|
| 44 |
+
}}
|
| 45 |
+
config={{ displaylogo: false, responsive: true }}
|
| 46 |
+
style={{ width: '100%' }}
|
| 47 |
+
useResizeHandler
|
| 48 |
+
/>
|
| 49 |
+
</div>
|
| 50 |
+
)
|
| 51 |
+
}
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import ReactDOM from 'react-dom/client'
|
| 3 |
+
import App from './App'
|
| 4 |
+
import './styles.css'
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>,
|
| 10 |
+
)
|
frontend/src/styles.css
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--climas-blue: #1f4e79;
|
| 3 |
+
--climas-accent: #4ea8dc;
|
| 4 |
+
--bg: #f7f9fc;
|
| 5 |
+
--panel: #ffffff;
|
| 6 |
+
--border: #d9dfe9;
|
| 7 |
+
--text: #1c2230;
|
| 8 |
+
--muted: #5a6578;
|
| 9 |
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 10 |
+
color: var(--text);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
* { box-sizing: border-box; }
|
| 14 |
+
body { margin: 0; background: var(--bg); }
|
| 15 |
+
|
| 16 |
+
.app {
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
display: flex;
|
| 19 |
+
flex-direction: column;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.header {
|
| 23 |
+
display: flex;
|
| 24 |
+
align-items: center;
|
| 25 |
+
gap: 20px;
|
| 26 |
+
padding: 14px 24px;
|
| 27 |
+
background: #eef2f8;
|
| 28 |
+
color: var(--text);
|
| 29 |
+
border-bottom: 3px solid var(--climas-accent);
|
| 30 |
+
}
|
| 31 |
+
.header .brand { height: 52px; width: auto; }
|
| 32 |
+
.header .subtitle {
|
| 33 |
+
font-size: 13px;
|
| 34 |
+
color: var(--muted);
|
| 35 |
+
flex: 1;
|
| 36 |
+
border-left: 1px solid #cfd6e2;
|
| 37 |
+
padding-left: 16px;
|
| 38 |
+
line-height: 1.4;
|
| 39 |
+
}
|
| 40 |
+
.header .climas { height: 42px; width: auto; }
|
| 41 |
+
|
| 42 |
+
.main {
|
| 43 |
+
display: grid;
|
| 44 |
+
grid-template-columns: 300px 1fr;
|
| 45 |
+
gap: 20px;
|
| 46 |
+
padding: 20px;
|
| 47 |
+
align-items: start;
|
| 48 |
+
}
|
| 49 |
+
@media (max-width: 900px) {
|
| 50 |
+
.main { grid-template-columns: 1fr; }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.panel {
|
| 54 |
+
background: var(--panel);
|
| 55 |
+
border: 1px solid var(--border);
|
| 56 |
+
border-radius: 8px;
|
| 57 |
+
padding: 16px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.controls label {
|
| 61 |
+
display: block;
|
| 62 |
+
font-size: 13px;
|
| 63 |
+
font-weight: 500;
|
| 64 |
+
margin-bottom: 4px;
|
| 65 |
+
color: var(--muted);
|
| 66 |
+
}
|
| 67 |
+
.controls .field { margin-bottom: 14px; }
|
| 68 |
+
.controls input[type="range"] { width: 100%; }
|
| 69 |
+
.controls select {
|
| 70 |
+
width: 100%;
|
| 71 |
+
padding: 6px 8px;
|
| 72 |
+
border: 1px solid var(--border);
|
| 73 |
+
border-radius: 4px;
|
| 74 |
+
background: #fff;
|
| 75 |
+
}
|
| 76 |
+
.controls .value {
|
| 77 |
+
display: inline-block;
|
| 78 |
+
float: right;
|
| 79 |
+
font-variant-numeric: tabular-nums;
|
| 80 |
+
color: var(--text);
|
| 81 |
+
font-weight: 600;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.table {
|
| 85 |
+
width: 100%;
|
| 86 |
+
border-collapse: collapse;
|
| 87 |
+
font-size: 14px;
|
| 88 |
+
}
|
| 89 |
+
.table th, .table td {
|
| 90 |
+
padding: 6px 10px;
|
| 91 |
+
text-align: left;
|
| 92 |
+
border-bottom: 1px solid var(--border);
|
| 93 |
+
font-variant-numeric: tabular-nums;
|
| 94 |
+
}
|
| 95 |
+
.table th {
|
| 96 |
+
background: #eef2f8;
|
| 97 |
+
font-weight: 600;
|
| 98 |
+
width: 55%;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.right {
|
| 102 |
+
display: flex;
|
| 103 |
+
flex-direction: column;
|
| 104 |
+
gap: 20px;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.footer {
|
| 108 |
+
margin-top: auto;
|
| 109 |
+
padding: 10px 24px;
|
| 110 |
+
font-size: 12px;
|
| 111 |
+
color: var(--muted);
|
| 112 |
+
border-top: 1px solid var(--border);
|
| 113 |
+
}
|
| 114 |
+
.footer a { color: var(--climas-blue); }
|
| 115 |
+
|
| 116 |
+
.error {
|
| 117 |
+
color: #b3261e;
|
| 118 |
+
background: #fde7e7;
|
| 119 |
+
border: 1px solid #f5b5b5;
|
| 120 |
+
padding: 8px 12px;
|
| 121 |
+
border-radius: 4px;
|
| 122 |
+
font-size: 13px;
|
| 123 |
+
}
|
frontend/src/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Band = 'S' | 'C' | 'X'
|
| 2 |
+
export type Precip = 'rain' | 'hail'
|
| 3 |
+
|
| 4 |
+
export interface ComputeRequest {
|
| 5 |
+
dm: number
|
| 6 |
+
log_nw: number
|
| 7 |
+
mu: number
|
| 8 |
+
band: Band
|
| 9 |
+
canting_std_deg: number
|
| 10 |
+
precip: Precip
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export interface Metrics {
|
| 14 |
+
zh_dbz: number
|
| 15 |
+
zv_dbz: number
|
| 16 |
+
zdr_db: number
|
| 17 |
+
ldr_db: number
|
| 18 |
+
rho_hv: number
|
| 19 |
+
delta_deg: number
|
| 20 |
+
kdp_deg_per_km: number
|
| 21 |
+
ah_db_per_km: number
|
| 22 |
+
adr_db_per_km: number
|
| 23 |
+
nt_per_m3: number
|
| 24 |
+
lwc_g_per_m3: number
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export interface NDCurve {
|
| 28 |
+
d_mm: number[]
|
| 29 |
+
n_d: number[]
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export interface ComputeResponse {
|
| 33 |
+
metrics: Metrics
|
| 34 |
+
nd: NDCurve
|
| 35 |
+
}
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
"moduleResolution": "bundler",
|
| 9 |
+
"allowImportingTsExtensions": true,
|
| 10 |
+
"resolveJsonModule": true,
|
| 11 |
+
"isolatedModules": true,
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"jsx": "react-jsx",
|
| 14 |
+
"strict": true,
|
| 15 |
+
"noUnusedLocals": true,
|
| 16 |
+
"noUnusedParameters": true,
|
| 17 |
+
"noFallthroughCasesInSwitch": true
|
| 18 |
+
},
|
| 19 |
+
"include": ["src"]
|
| 20 |
+
}
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
// Production build lands directly inside FastAPI's static/ dir so uvicorn
|
| 7 |
+
// serves the SPA at the same origin as /api.
|
| 8 |
+
build: {
|
| 9 |
+
outDir: '../backend/app/static',
|
| 10 |
+
emptyOutDir: true,
|
| 11 |
+
},
|
| 12 |
+
server: {
|
| 13 |
+
port: 5173,
|
| 14 |
+
proxy: {
|
| 15 |
+
'/api': 'http://localhost:8000',
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
})
|