Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,82 @@

All notable changes to TsunamiSimulator. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · [SemVer](https://semver.org/spec/v2.0.0.html).

## [Unreleased] — Deep correctness, reliability & UX hardening

### Fixed — physics correctness
- **Synolakis 1987 coastal run-up corrected.** `synolakis_runup_m` multiplied
by the offshore *amplitude* instead of the offshore *depth*, so every run-up
/ inundation figure (overlay bars, Inspect readout) under-predicted by a
factor of `d/H`. The implementation now matches the documented
`R = 2.831·√(cot β)·H^(5/4)/d^(1/4)` Carrier-Greenspan form, and the
feature-gated `synolakis_matches_carrier_greenspan_envelope` validation —
previously failing by up to 100 % — now passes. Added a non-gated
closed-form regression test so the default suite guards it.
- **Non-finite seismic magnitude eliminated.** A custom landslide with
`drop_height_m = 0` (admitted by the IPC validator) produced zero kinetic
energy and a `-inf` `seismic_mw_equivalent` that crossed the IPC boundary
into the UI. A shared, floored `mw_from_radiated_j` helper now backs the
asteroid, nuclear, and landslide magnitude paths.
- **Latent NaN sources guarded** in the asteroid cavity-scaling (zero diameter
/ out-of-range angle), nuclear cavity radius (negative burst depth →
cube-root of a negative number), and Lamb-wave envelope (zero source radius
→ `cos(NaN)` at arrival).

### Fixed — reliability & safety
- **`simulate_grid` compute budget.** The cell count and step count were each
capped but their *product* was not, so a single request could wedge the
blocking worker for minutes. A combined cell-steps budget now rejects
pathological requests up front.
- **`simulate_grid` polar/longitude robustness.** A source beyond ±80°
latitude no longer builds an inverted (degenerate, silently blank) grid, and
the source longitude is normalised so the returned bbox stays inside the
frame Cesium expects. Longitude validation is now a single canonical ±180°
contract across every command.
- **GPU SWE path.** Fixed a missing `bytemuck` dependency that meant the `gpu`
feature never compiled. The GPU readback no longer advances simulated time
over a failed/garbage field — it returns a clean failure so the dispatcher
falls back to the CPU — and the dispatcher only routes all-wet grids to the
(linear, land-mask-free) kernel so it can't re-flood continents.
- **SWE sponge boundary** is now applied (thinner) on small grids instead of
silently reverting to reflective edges.
- **Preset registry** now has a test asserting every shipped preset satisfies
the same input bounds the live `*_initial_conditions` commands enforce, and
`find_preset` no longer rebuilds the whole registry on each lookup.

### Fixed — UX & accessibility
- **Custom number fields are editable again.** Scenario-builder inputs held a
draft string and clamp on blur, so clearing a field to retype no longer
snaps it to its minimum on every keystroke.
- **Failures are no longer silent.** Preset/scenario IPC errors, preset-list
load failures (with retry), and PNG/share/video export failures now surface
a visible toast / inline status instead of only a console log.
- **Modal focus management.** Settings, Citations, first-run, and tour dialogs
now move focus inside on open, trap Tab/Shift-Tab, and restore focus on close
(WCAG 2.4.3 / 2.1.2).
- **Globe NaN-proofing.** The Inspect readout and run-up labels coalesce
non-finite physics to an em dash, the SWE imagery layer rejects non-finite
bounding boxes before handing them to Cesium, stale wavefront rings are torn
down when the source clears, and the Inspect readout now uses the source’s
real water depth instead of a hardcoded 4000 m.
- Inspect uses the live viewer instance (not a stale closure); the dev-mode
StrictMode remount no longer leaves a blank globe; SWE Play restarts from the
top at the final frame and stays usable after Cancel; the Settings Save
button shows a saving state; the timeline tolerates a non-finite time.

### Fixed — security & DX
- CSP/permissions: tightened the `shell:allow-open` allow-list (scoped Forbes
to the cited author path, added the explicit repo URL with a trailing-slash
glob).
- Production builds now **fail loudly if a personal `VITE_CESIUM_TOKEN` would
be inlined** into the distributable bundle (override with
`ALLOW_TOKEN_IN_BUNDLE=1`).
- Settings store uses a read-only init probe so it no longer writes an
`__init_probe` key into the user’s `settings.json`; the video exporter has a
watchdog so a missing `MediaRecorder` stop event can’t hang forever; export
filenames neutralise Windows reserved device names and clamp length.
- App version string corrected to `v0.4.0` (was a stale `v0.2.1`); refreshed
stale "planned / scaffold" docs for the now-shipped Okada and GPU kernel code.

## [0.4.0] - 2026-05-25 — Premium polish + GPU SWE + Lamb-wave coupling

### Premium polish pass
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ validation = []
# integrated GPU adapter on the host. Falls back to CPU on adapter
# failure. Off by default — Linux CI runners may not have a usable
# Vulkan/Metal device.
gpu = ["dep:wgpu", "dep:pollster"]
gpu = ["dep:wgpu", "dep:pollster", "dep:bytemuck"]

[dependencies.wgpu]
version = "26.0"
Expand All @@ -53,6 +53,13 @@ features = ["wgsl"]
version = "0.4"
optional = true

# Zero-copy casting of the f32 grid buffers + the `GpuParams` uniform struct.
# Only pulled in with the `gpu` feature (gpu.rs is the sole user).
[dependencies.bytemuck]
version = "1"
optional = true
features = ["derive"]

[profile.release]
opt-level = 3
lto = true
Expand Down
5 changes: 3 additions & 2 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
{ "url": "https://www.nature.com/*" },
{ "url": "https://www.researchgate.net/*" },
{ "url": "https://www.sciencedirect.com/*" },
{ "url": "https://www.forbes.com/*" },
{ "url": "https://www.forbes.com/sites/davidhambling/*" },
{ "url": "http://www.tsunamisociety.org/*" },
{ "url": "http://library.lanl.gov/*" },
{ "url": "https://nuclearsecrecy.com/*" },
Expand All @@ -38,7 +38,8 @@
{ "url": "https://portal.opentopography.org/*" },
{ "url": "https://www.naturalearthdata.com/*" },
{ "url": "https://www.clawpack.org/*" },
{ "url": "https://github.com/SysAdminDoc/TsunamiSimulator*" }
{ "url": "https://github.com/SysAdminDoc/TsunamiSimulator" },
{ "url": "https://github.com/SysAdminDoc/TsunamiSimulator/*" }
]
}
]
Expand Down
114 changes: 78 additions & 36 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,22 @@ fn check_finite_nonnegative(name: &str, value: f64) -> Result<(), String> {
Ok(())
}

// Canonical geographic domain enforced uniformly across every command:
// latitude in [-90, 90], longitude in [-180, 180]. The whole frontend (preset
// registry, coastal-point DB, globe picks) already works in this range, so
// accepting the looser ±360 longitude only admitted un-normalised values that
// then produced off-frame Cesium bounding boxes. Keep all callers in one domain.
const LON_ABS_MAX: f64 = 180.0;

fn check_lat_lon(loc: &GeoPoint) -> Result<(), String> {
if !loc.lat_deg.is_finite() || loc.lat_deg.abs() > 90.0 {
return Err(format!("location.lat_deg {} out of range", loc.lat_deg));
}
if !loc.lon_deg.is_finite() || loc.lon_deg.abs() > 360.0 {
return Err(format!("location.lon_deg {} out of range", loc.lon_deg));
}
Ok(())
check_lat_lon_values("location", loc.lat_deg, loc.lon_deg)
}

fn check_lat_lon_values(prefix: &str, lat: f64, lon: f64) -> Result<(), String> {
if !lat.is_finite() || lat.abs() > 90.0 {
return Err(format!("{prefix} latitude {lat} out of range"));
}
if !lon.is_finite() || lon.abs() > 360.0 {
if !lon.is_finite() || lon.abs() > LON_ABS_MAX {
return Err(format!("{prefix} longitude {lon} out of range"));
}
Ok(())
Expand Down Expand Up @@ -522,18 +523,8 @@ pub struct LambWaveSampleResult {
/// volcanic source.
#[tauri::command]
pub fn lamb_wave_sample(req: LambWaveSampleRequest) -> Result<LambWaveSampleResult, String> {
if !req.source.lat_deg.is_finite() || req.source.lat_deg.abs() > 90.0 {
return Err("source latitude out of range".into());
}
if !req.source.lon_deg.is_finite() || req.source.lon_deg.abs() > 360.0 {
return Err("source longitude out of range".into());
}
if !req.lat.is_finite() || req.lat.abs() > 90.0 {
return Err("receiver latitude out of range".into());
}
if !req.lon.is_finite() || req.lon.abs() > 360.0 {
return Err("receiver longitude out of range".into());
}
check_lat_lon_values("source", req.source.lat_deg, req.source.lon_deg)?;
check_lat_lon_values("receiver", req.lat, req.lon)?;
if !req.time_s.is_finite() || req.time_s < 0.0 {
return Err("time_s must be finite and non-negative".into());
}
Expand Down Expand Up @@ -707,6 +698,13 @@ pub struct SimulateGridResponse {

/// Hard cap on the SWE grid size — protects us against runaway requests.
const SWE_MAX_CELLS: usize = 4_000_000;
/// Hard cap on total *work* (grid cells × time steps). The cell cap and the
/// step cap are each individually bounded, but their product is what actually
/// determines wall-clock time; without this a request that passes both (e.g.
/// 4 M cells × ~250 k steps) could wedge the blocking worker for many minutes.
/// 5e10 cell-steps is a few seconds of CPU on this solver and far above any
/// legitimate interactive request (a typical run is well under 1e7).
const SWE_MAX_CELL_STEPS: u64 = 50_000_000_000;
/// Hard cap on number of snapshots per simulation.
const SWE_MAX_SNAPSHOTS: usize = 240;
/// Hard cap on simulated time — runaway scrubs.
Expand All @@ -723,7 +721,7 @@ fn validate_simulate_grid(req: &SimulateGridRequest) -> Result<(), String> {
req.source.lat_deg
));
}
if !req.source.lon_deg.is_finite() || req.source.lon_deg.abs() > 360.0 {
if !req.source.lon_deg.is_finite() || req.source.lon_deg.abs() > LON_ABS_MAX {
return Err(format!(
"source longitude {} out of range",
req.source.lon_deg
Expand Down Expand Up @@ -778,16 +776,20 @@ pub async fn simulate_grid(req: SimulateGridRequest) -> Result<SimulateGridRespo
validate_simulate_grid(&req)?;

tauri::async_runtime::spawn_blocking(move || {
let lat = req.source.lat_deg;
let lon = req.source.lon_deg;
// Keep the simulation-box centre off the poles so the lon/lat box is
// always well-ordered (north > south). A source beyond ±80° would
// otherwise make `(lat - half).max(-80)` exceed `(lat + half).min(80)`,
// producing an inverted/degenerate grid that silently renders nothing.
let lat = req.source.lat_deg.clamp(-80.0, 80.0);
// Normalise longitude into [-180, 180) so the returned snapshot bbox
// stays inside the frame Cesium expects (a source at lon 359 must not
// build a box spanning [357, 361]).
let lon = ((req.source.lon_deg + 180.0).rem_euclid(360.0)) - 180.0;
let half = req.box_half_size_deg;
let cell = 1.0 / req.cells_per_deg;

// Clamp latitudes to avoid polar singularities, keeping the box
// symmetric around the source even if it would otherwise exceed
// [-80, 80].
let south = (lat - half).max(-80.0);
let north = (lat + half).min(80.0);
let south = (lat - half).max(-89.0);
let north = (lat + half).min(89.0);
let west = lon - half;
let east = lon + half;

Expand Down Expand Up @@ -857,6 +859,23 @@ pub async fn simulate_grid(req: SimulateGridRequest) -> Result<SimulateGridRespo
let nx = grid.nx as u32;
let ny = grid.ny as u32;

// Combined work budget (cells × steps). The cell count and the step
// count are each capped, but only their product bounds wall-clock time.
let est_steps = if dt.is_finite() && dt > 0.0 {
(req.t_end_s / dt).clamp(1.0, 1.0e9)
} else {
1.0
};
let work = (grid.nx as u64)
.saturating_mul(grid.ny as u64)
.saturating_mul(est_steps as u64);
if work > SWE_MAX_CELL_STEPS {
return Err(format!(
"simulation too expensive (~{} cell-steps; cap {}). Reduce cells_per_deg, box_half_size_deg, or t_end_s.",
work, SWE_MAX_CELL_STEPS
));
}

// F4-01 — when compiled with `--features gpu`, try the wgpu
// dispatch path. Fall back to CPU cleanly if no adapter is
// available (Linux CI, integrated-only laptops without
Expand Down Expand Up @@ -920,10 +939,29 @@ fn run_simulation_dispatch(
n_snapshots: usize,
) -> (Vec<GridSnapshot>, bool) {
use crate::physics::solver::gpu::GpuTimeStepper;
if let Some(gpu) = GpuTimeStepper::new(grid, dt_s, crate::physics::constants::MANNING_N_COASTAL)
{
let snaps = run_simulation_gpu(grid, &gpu, dt_s, t_end_s, n_snapshots);
return (snaps, true);
use crate::physics::solver::LAND_DEPTH_THRESHOLD_M;

// The WGSL kernel is a LINEAR leapfrog with reflective edges and NO land
// masking, whereas the CPU default is nonlinear + sponge + land-aware. To
// avoid the GPU silently producing materially different physics, only route
// to it when the grid is entirely wet — otherwise the missing land mask
// would re-flood the 1 m land sentinel into a slow-spreading halo across
// continents (exactly the artefact the CPU mask was added to remove).
// (The remaining sponge/advection differences for all-wet grids are tracked
// for a future WGSL kernel-parity pass; see solver/kernels.rs.)
let all_wet = grid.h_m.iter().all(|&h| h > LAND_DEPTH_THRESHOLD_M);
if all_wet {
if let Some(gpu) =
GpuTimeStepper::new(grid, dt_s, crate::physics::constants::MANNING_N_COASTAL)
{
// Snapshot the (already-injected) state so a mid-run GPU failure
// can cleanly retry on the CPU rather than emitting a frozen field.
let pristine = grid.clone();
if let Some(snaps) = run_simulation_gpu(grid, &gpu, dt_s, t_end_s, n_snapshots) {
return (snaps, true);
}
*grid = pristine;
}
}
let stepper = TimeStepper::new(dt_s);
(run_simulation(grid, &stepper, t_end_s, n_snapshots), false)
Expand All @@ -944,20 +982,22 @@ fn run_simulation_dispatch(
/// spaced snapshots as the CPU path. The GPU dispatch loop runs
/// `take` steps between each snapshot then reads back into `grid`
/// so the snapshot encoder can serialise η as PNG.
/// Returns `None` if any GPU step fails (map/poll error or non-finite field),
/// signalling the dispatcher to fall back to the CPU path.
#[cfg(feature = "gpu")]
fn run_simulation_gpu(
grid: &mut SwGrid,
gpu: &crate::physics::solver::gpu::GpuTimeStepper,
dt_s: f64,
t_end_s: f64,
n_snapshots: usize,
) -> Vec<GridSnapshot> {
) -> Option<Vec<GridSnapshot>> {
const MAX_TOTAL_STEPS: usize = 1_000_000;
let n = n_snapshots.max(2);
let mut snaps = Vec::with_capacity(n);
snaps.push(grid.snapshot());
if !t_end_s.is_finite() || t_end_s <= 0.0 {
return snaps;
return Some(snaps);
}
let dt = if dt_s.is_finite() && dt_s > 0.0 {
dt_s
Expand All @@ -974,10 +1014,12 @@ fn run_simulation_gpu(
let target_step = (k * total_steps) / (n - 1);
let current_step = ((k - 1) * total_steps) / (n - 1);
let take = target_step.saturating_sub(current_step).max(1);
gpu.step(grid, take);
if !gpu.step(grid, take) {
return None;
}
snaps.push(grid.snapshot());
}
snaps
Some(snaps)
}

#[tauri::command]
Expand Down
27 changes: 22 additions & 5 deletions src-tauri/src/physics/asteroid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,23 @@ impl AsteroidImpact {
/// ```
/// with `C_T = 1.88`, `β = 0.22` for water.
pub fn transient_cavity_diameter_m(&self) -> f64 {
let theta_rad = self.angle_deg.to_radians();
// Defensive guards (the IPC command already rejects these, but this
// is a `pub` helper that presets and future callers reach directly):
// - a zero/negative diameter makes `pi_term` infinite and then
// `0 · inf = NaN`, poisoning the whole InitialDisplacement;
// - `sin(θ)` raised to a fractional power is NaN for θ outside
// (0°, 180°), so clamp the impact angle into the physical (0, 90].
if !self.diameter_m.is_finite()
|| self.diameter_m <= 0.0
|| !self.velocity_m_s.is_finite()
{
return 0.0;
}
let theta_rad = self.angle_deg.clamp(f64::MIN_POSITIVE, 90.0).to_radians();
let pi_term = self.velocity_m_s.powi(2) / (G_EARTH * self.diameter_m);
SCHMIDT_HOLSAPPLE_CT
* self.diameter_m
* (self.density_kg_m3 / RHO_SEAWATER).powf(1.0 / 3.0)
* (self.density_kg_m3.max(0.0) / RHO_SEAWATER).powf(1.0 / 3.0)
* pi_term.powf(SCHMIDT_HOLSAPPLE_BETA)
* theta_rad.sin().powf(1.0 / 3.0)
}
Expand All @@ -96,6 +108,11 @@ impl AsteroidImpact {
/// amplitude saturates at the local water depth.
pub fn initial_amplitude_m(&self) -> f64 {
let cavity_depth = self.transient_cavity_depth_m();
// Don't let a non-finite cavity depth get laundered into a
// plausible-looking depth-limited amplitude by `min`/`max`.
if !cavity_depth.is_finite() {
return 0.0;
}
let saturated = cavity_depth.min(self.water_depth_m);
0.5 * saturated.max(0.0)
}
Expand All @@ -105,9 +122,9 @@ impl AsteroidImpact {
/// Gault 1975 for hypervelocity oceanic impacts).
pub fn seismic_mw_equivalent(&self) -> f64 {
let radiated_j = 0.01 * self.kinetic_energy_j();
// log10(M0) = log10(E_s / 5e-5) ⇒ Mw = (2/3) (log10 M0 − 9.1)
let m0 = radiated_j / 5.0e-5;
(2.0 / 3.0) * (m0.log10() - 9.1)
// log10(M0) = log10(E_s / 5e-5) ⇒ Mw = (2/3) (log10 M0 − 9.1).
// Shared floored helper guards against zero/negative energy → -inf.
super::mw_from_radiated_j(radiated_j)
}

/// Snapshot of the source ready for the propagation solver.
Expand Down
Loading
Loading