snesbitt commited on
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 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

  • SHA256: 3093c98604c1ccd7345fcc4e89c54a7d314cca0cc3f410e7567362ff044e4096
  • Pointer size: 131 Bytes
  • Size of remote file: 103 kB
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
+ })