mountain-waves / validate.py
snesbitt's picture
Mountain Waves β€” deploy to Hugging Face Space
7c3bfa9
Raw
History Blame Contribute Delete
7.2 kB
#!/usr/bin/env python3
"""Validate Rust vs. pure-Python solver, and sanity-check physics.
Run with: python validate.py
If the Rust extension hasn't been built, only the pure-Python code is
exercised β€” useful for catching mistakes in the port of tlwplot.m.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "python"))
from mountain_waves import compute_two_layer, compute_from_profile, streamlines, backend_name
from mountain_waves import reference as ref
from mountain_waves.profile import (
default_profile_heights,
default_theta_profile,
default_u_profile,
)
def describe(name, w):
print(f" {name:<28} min={w.min():+.4f} max={w.max():+.4f} rms={np.sqrt((w**2).mean()):.4f}")
def case_uniform():
# Example 1: uniform atmosphere. No wave trapping.
return dict(l_upper=4e-4, l_lower=4e-4, u=20.0, h=3500.0, a=2500.0, ho=500.0,
xdom=40000.0, zdom=10000.0, mink=0.0, maxk=30.0 / 2500.0, npts=100)
def case_trapped():
# Example 2: trapped lee waves.
return dict(l_upper=4e-4, l_lower=10e-4, u=20.0, h=3500.0, a=2500.0, ho=500.0,
xdom=40000.0, zdom=10000.0, mink=0.0, maxk=30.0 / 2500.0, npts=100)
def main() -> int:
print(f"Active backend: {backend_name()}")
# ---- Python reference sanity: uniform atmosphere, w β‰ˆ odd in x near crest
print("\n[1] Two-layer, uniform atmosphere (pure-Python reference):")
p = case_uniform()
x, z, w, up = ref.compute_two_layer(**p)
assert w.shape == (101, 101)
assert up.shape == w.shape
describe("w (reference)", w)
describe("u' (reference)", up)
print("\n[2] Two-layer, trapped-wave case:")
pt = case_trapped()
x, z, w_ref, up_ref = ref.compute_two_layer(**pt)
describe("w (reference)", w_ref)
describe("u' (reference)", up_ref)
# Expect substantial wave amplitude in the lee (x > 0) for the trapped case.
# Find amplitude in z ~ 1-2 km and x ~ 5-20 km.
xi = (x >= 5000) & (x <= 20000)
zi = (z >= 500) & (z <= 2000)
lee_rms = np.sqrt((w_ref[np.ix_(zi, xi)] ** 2).mean())
print(f" lee-wave rms(1-2 km, 5-20 km) = {lee_rms:.3f} m/s")
assert lee_rms > 0.05, "Trapped-wave case showed suspiciously weak lee waves."
# ---- Rust vs Python, if Rust is available
try:
from mountain_waves import _core # type: ignore
has_rust = True
except ImportError:
has_rust = False
if has_rust:
print("\n[3] Rust vs. Python two-layer (trapped case):")
_, _, w_rust, up_rust = _core.compute_two_layer(**pt)
err_w = np.max(np.abs(w_rust - w_ref))
err_u = np.max(np.abs(up_rust - up_ref))
print(f" max|w_rust - w_python| = {err_w:.2e}")
print(f" max|u'_rust - u'_python| = {err_u:.2e}")
assert err_w < 1e-6, f"Rust/Python w disagreement too large: {err_w}"
assert err_u < 1e-6, f"Rust/Python u' disagreement too large: {err_u}"
else:
print("\n[3] (skipped β€” Rust extension not built)")
# ---- Profile solver: should approximate two-layer as profile resolution β†’ ∞
print("\n[4] Profile solver β€” trapped-wave profile:")
zs = default_profile_heights(10.0, 17)
us = np.full_like(zs, 20.0) # constant wind
# Construct theta so that N^2/u^2 approximates L_lower/L_upper below/above 3.5 km.
# L_lower^2 = N^2/u^2 - (1/u) d2u/dz2, constant u β‡’ second term 0.
# Want L^2_lower = 10e-4^2 -> N^2 = L^2 * u^2.
N2_lower = (10e-4) ** 2 * 20.0 ** 2
N2_upper = (4e-4) ** 2 * 20.0 ** 2
thetas = np.empty_like(zs)
thetas[0] = 290.0
for i in range(1, zs.size):
dz = zs[i] - zs[i - 1]
n2 = N2_lower if zs[i] <= 3500.0 else N2_upper
thetas[i] = thetas[i - 1] + thetas[i - 1] * n2 / 9.80665 * dz
prof_out = ref.compute_from_profile(
zs, us, thetas,
a=2500.0, ho=500.0, xdom=40000.0, zdom=10000.0,
mink=0.0, maxk=30.0 / 2500.0, npts=100,
)
# compute_from_profile returns (x, z, w) or (x, z, w, u_prime) depending
# on the port-era. Accept either to keep this script robust.
x, z, w_prof = prof_out[0], prof_out[1], prof_out[2]
describe("w (profile solver)", w_prof)
# Compare RMS to two-layer case β€” should be broadly similar.
rms_prof = np.sqrt(np.mean(w_prof ** 2))
rms_ref = np.sqrt(np.mean(w_ref ** 2))
ratio = rms_prof / rms_ref
print(f" rms(profile) / rms(2-layer) = {ratio:.3f}")
assert 0.3 < ratio < 3.0, "Profile solver RMS differs unreasonably from two-layer reference."
# ---- Critical-level handling: U crosses zero mid-column.
#
# Linear Scorer/Taylor-Goldstein is singular at U = 0, so we clamp |U|
# at U_FLOOR_SCORER (~0.5 m/s) in both Rust and Python scorer helpers
# and surface the detected zero-crossings separately. This test makes
# sure (a) the clamp keeps l^2 finite, (b) Rust and Python agree on
# the clamped profile, and (c) the `critical_levels` helper correctly
# locates the zero crossing.
print("\n[5] Critical-level handling (wind reversal at zβ‰ˆ5 km):")
zs_c = np.linspace(0.0, 10000.0, 41)
us_c = np.linspace(10.0, -10.0, 41) # zero at index 20, z = 5000 m
thetas_c = 290.0 + 0.004 * zs_c # mildly stable
l2_py = ref.scorer_from_profile(zs_c, us_c, thetas_c)
assert np.all(np.isfinite(l2_py)), "Python Scorer went non-finite across U=0"
crits = ref.critical_levels(zs_c, us_c)
print(f" detected critical levels: {[round(h, 1) for h in crits]} m")
assert len(crits) == 1 and abs(crits[0] - 5000.0) < 1e-6, crits
# Also make sure the full solver survives a wind-reversal column. Rust
# and Python both have to clamp |U| internally for scorer_from_profile;
# running compute_from_profile exercises that path end-to-end.
out_py = ref.compute_from_profile(
zs_c, us_c, thetas_c,
a=2500.0, ho=500.0, xdom=40000.0, zdom=10000.0,
mink=0.0, maxk=30.0 / 2500.0, npts=80,
)
w_py = out_py[2]
assert np.all(np.isfinite(w_py)), "Python solver went non-finite across U=0"
if has_rust:
out_rust = _core.compute_from_profile(
zs_c, us_c, thetas_c,
a=2500.0, ho=500.0, xdom=40000.0, zdom=10000.0,
mink=0.0, maxk=30.0 / 2500.0, npts=80,
)
w_rust_c = out_rust[2]
assert np.all(np.isfinite(w_rust_c)), "Rust solver went non-finite across U=0"
err = np.max(np.abs(w_rust_c - w_py))
print(f" wind-reversal max|w_rust - w_python| = {err:.2e}")
assert err < 1e-4, f"Rust/Python wind-reversal disagreement: {err}"
# ---- Streamline tracer: the first line should sweep over the mountain crest.
print("\n[6] Streamline tracer:")
lines = streamlines(x, z, 20.0, w_ref, num=10)
assert len(lines) == 10
xs0, ys0 = lines[0]
assert xs0[0] < 0 < xs0[-1]
print(f" first streamline: x ∈ [{xs0[0]:.0f}, {xs0[-1]:.0f}] m, y ∈ [{ys0.min():.1f}, {ys0.max():.1f}] m")
print("\nAll checks passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())