From 0e0d8bd114a9d7758d1436d0fdf95ec35a1a8c64 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:56:02 -0400 Subject: [PATCH] model: progress of long commands, reported by the solvers and the tet mesher fieldcraft_model::progress: a host installs a Progress on the worker thread and reads its snapshot from another; the engine names the step being solved, the nonlinear solver its load factor, the explicit solver its time, the fluid solver its steps, transient heat its increments and the tet mesher its stage and pass. Without one installed every report is a no-op (#322, #357). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/mesh.rs | 2 + crates/engine/src/cmd/replace.rs | 2 +- crates/engine/src/cmd/solve.rs | 24 +++-- crates/engine/src/lib.rs | 2 +- crates/explicit/src/integrate.rs | 7 ++ crates/lbm/src/solve.rs | 4 + crates/mesh/src/tet/improve.rs | 2 + crates/mesh/src/tet/mod.rs | 7 +- crates/model/src/lib.rs | 1 + crates/model/src/progress.rs | 174 +++++++++++++++++++++++++++++++ crates/solver/src/heat.rs | 2 + crates/solver/src/nonlinear.rs | 2 + 12 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 crates/model/src/progress.rs diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 4fd19c7..e5868c7 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -252,7 +252,9 @@ pub(super) fn mesh_body( None => s.design.as_deref().and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(), Some(_) => Vec::new(), }; + fieldcraft_model::progress::stage(&format!("{}: boundary", input.name), None, ""); let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?; + fieldcraft_model::progress::stage(&format!("{}: Delaunay", input.name), None, ""); let tets = tetrahedralize(&surface, cdt)?; let mesh = if quality { let options = QualityOptions { seeds: input.seeds.clone(), ..options.clone() }; diff --git a/crates/engine/src/cmd/replace.rs b/crates/engine/src/cmd/replace.rs index 9bfb224..e41dcaf 100644 --- a/crates/engine/src/cmd/replace.rs +++ b/crates/engine/src/cmd/replace.rs @@ -153,7 +153,7 @@ pub fn replacement(s: &Session, command: &str, params: &Value) -> Option String { +pub fn thousands(n: usize) -> String { let digits = n.to_string(); let mut out = String::with_capacity(digits.len() + digits.len() / 3); for (i, c) in digits.chars().enumerate() { diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index f8b4d55..f871c98 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -168,6 +168,12 @@ fn extreme(values: impl Iterator, better: fn(f64, f64) -> boo } } +/// [`parallel::with_threads`], the caller's progress reporter carried onto the pool's thread. +fn with_threads(threads: usize, f: impl FnOnce() -> R + Send) -> R { + let progress = fieldcraft_model::progress::current(); + parallel::with_threads(threads, move || fieldcraft_model::progress::scoped(progress, f)) +} + fn run_all(s: &mut Session, _: &Value) -> Result { use fieldcraft_model::Procedure; type Solve = fn(&mut Session, &Value) -> Result; @@ -192,7 +198,9 @@ fn run_all(s: &mut Session, _: &Value) -> Result { return Err(EngineError::Params("the model has no steps to solve: add one (model.step.create)".into())); } let mut done = Vec::new(); - for (name, procedure, solve) in steps { + let count = steps.len(); + for (index, (name, procedure, solve)) in steps.into_iter().enumerate() { + fieldcraft_model::progress::step(&name, index + 1, count); let summary = solve(s, &json!({"step": name})).map_err(|e| EngineError::Params(format!("step `{name}`: {e}")))?; done.push(json!({"step": name, "procedure": procedure, "summary": summary})); } @@ -211,7 +219,7 @@ fn run_static(s: &mut Session, p: &Value) -> Result { let limits = limits(p)?; let step = opt::(p, "step")?; let model = &s.model; - let r = parallel::with_threads(threads(s, p)?, || solve_static(model, step.as_deref(), &limits))?; + let r = with_threads(threads(s, p)?, || solve_static(model, step.as_deref(), &limits))?; let summary = json!({ "step": r.step, @@ -239,7 +247,7 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result { let limits = limits(p)?; let step = opt::(p, "step")?; let model = &s.model; - let r = parallel::with_threads(threads(s, p)?, || solve_nonlinear(model, step.as_deref(), &limits))?; + let r = with_threads(threads(s, p)?, || solve_nonlinear(model, step.as_deref(), &limits))?; let history = r.nonlinear.as_ref(); let summary = json!({ "step": r.step, @@ -282,7 +290,7 @@ fn run_heat(s: &mut Session, p: &Value) -> Result { let limits = limits(p)?; let step = opt::(p, "step")?; let model = &s.model; - let r = parallel::with_threads(threads(s, p)?, || solve_heat(model, step.as_deref(), &limits))?; + let r = with_threads(threads(s, p)?, || solve_heat(model, step.as_deref(), &limits))?; let t = r.temperature(); let summary = json!({ "step": r.step, @@ -307,7 +315,7 @@ fn run_frequency(s: &mut Session, p: &Value) -> Result { let mass = opt::(p, "mass")?.unwrap_or_default(); let shift = opt::(p, "shift")?; let model = &s.model; - let r = parallel::with_threads(threads(s, p)?, || solve_frequency(model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits))?; + let r = with_threads(threads(s, p)?, || solve_frequency(model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits))?; let summary = json!({ "step": r.step, "dofs": r.stats.dofs, @@ -330,7 +338,7 @@ fn run_buckle(s: &mut Session, p: &Value) -> Result { let limits = limits(p)?; let step = opt::(p, "step")?; let model = &s.model; - let r = parallel::with_threads(threads(s, p)?, || solve_buckle(model, step.as_deref(), &limits))?; + let r = with_threads(threads(s, p)?, || solve_buckle(model, step.as_deref(), &limits))?; let summary = json!({ "step": r.step, "dofs": r.stats.dofs, @@ -354,7 +362,7 @@ fn run_explicit(s: &mut Session, p: &Value) -> Result { }; let step = opt::(p, "step")?; let model = &s.model; - let r = parallel::with_threads(threads(s, p)?, || solve_explicit(model, step.as_deref(), &limits))?; + let r = with_threads(threads(s, p)?, || solve_explicit(model, step.as_deref(), &limits))?; let summary = explicit_summary(&r); s.store_result(StepResult::Explicit(r)); Ok(summary) @@ -459,7 +467,7 @@ fn run_fluid(s: &mut Session, p: &Value) -> Result { let (_, domain) = fieldcraft_lbm::fluid_step(&s.model, step.as_deref())?; let obstacles = obstacles(s, domain)?; let model = &s.model; - let r = parallel::with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?; + let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?; let summary = fluid_summary(&r); s.store_result(StepResult::Fluid(r)); Ok(summary) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 69a7aa5..b1f31a8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,7 +20,7 @@ pub mod crash; pub mod logfile; pub use cmd::analytic_surfaces; -pub use cmd::replace::{MESHERS, Replacement, replacement}; +pub use cmd::replace::{MESHERS, Replacement, replacement, thousands}; pub use cmd::update::enable_checks as enable_update_checks; pub use fieldcraft_io::{Item as ViewItem, ItemKind}; pub mod history; diff --git a/crates/explicit/src/integrate.rs b/crates/explicit/src/integrate.rs index 27f9239..194aa14 100644 --- a/crates/explicit/src/integrate.rs +++ b/crates/explicit/src/integrate.rs @@ -23,6 +23,9 @@ //! The run starts from u = 0 and the initial velocities with the half increment //! v^{½} = v^0 + ½Δt^{½} a^0. +/// Increments between progress reports (each takes a lock). +const PROGRESS_EVERY: u64 = 64; + use fieldcraft_model::{ExplicitDynamic, Model, OutputVariable, Procedure, Step}; use crate::contact::{Evaluation, Interface}; @@ -356,7 +359,11 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res let mut v_now = v0; let mut completed = true; let mut v_new = vec![0.0; n]; + fieldcraft_model::progress::stage("Time", Some(end), ""); loop { + if increments.is_multiple_of(PROGRESS_EVERY) { + fieldcraft_model::progress::count(t); + } if increments >= max_increments { completed = false; warnings.push(format!("stopped at time {t:.6e} after {increments} increments (the cap); the step ends at {end:.6e}")); diff --git a/crates/lbm/src/solve.rs b/crates/lbm/src/solve.rs index 5d7052b..fa508ad 100644 --- a/crates/lbm/src/solve.rs +++ b/crates/lbm/src/solve.rs @@ -135,8 +135,12 @@ pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result> = vec![Vec::new(); flow.groups().len()]; let mut next_frame = 1u64; + fieldcraft_model::progress::stage("Fluid steps", Some(total as f64), ""); for k in 1..=total { flow.step()?; + if k % 64 == 0 { + fieldcraft_model::progress::count(k as f64); + } if k % every == 0 || k == total { history_times.push(k as f64 * setup.time_step); for (h, f) in forces.iter_mut().zip(flow.forces()) { diff --git a/crates/mesh/src/tet/improve.rs b/crates/mesh/src/tet/improve.rs index 4194959..487e631 100644 --- a/crates/mesh/src/tet/improve.rs +++ b/crates/mesh/src/tet/improve.rs @@ -458,6 +458,8 @@ pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result break; } stats.passes += 1; + fieldcraft_model::progress::stage("Improvement pass", Some(o.max_passes as f64), ""); + fieldcraft_model::progress::count(stats.passes as f64); let before = match carried.take() { Some(b) => b, None => indicators(cx)?, diff --git a/crates/mesh/src/tet/mod.rs b/crates/mesh/src/tet/mod.rs index b96b6ed..4210b36 100644 --- a/crates/mesh/src/tet/mod.rs +++ b/crates/mesh/src/tet/mod.rs @@ -445,6 +445,8 @@ fn boundary_volume(points: &[[f64; 3]], tets: &[[u32; 4]], boundary: &[TaggedFac enclosed_volume(points, &triangles) } +use fieldcraft_model::progress; + /// Run the quality pipeline on a constrained Delaunay tetrahedralization of `surface` (see the /// module docs). `analytic[face]` is the exact surface of a B-rep face when known. pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec>, o: &QualityOptions) -> Result { @@ -486,7 +488,9 @@ pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec = c.boundary.iter().map(|&(tet, k, face)| TaggedFace { tet, opposite: k as u8, face }).collect(); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 7174338..9e2ea88 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -43,6 +43,7 @@ pub mod loads; pub mod materials; mod mesh; pub mod nodal; +pub mod progress; pub mod sections; pub mod seeds; pub mod sets; diff --git a/crates/model/src/progress.rs b/crates/model/src/progress.rs new file mode 100644 index 0000000..d6425f7 --- /dev/null +++ b/crates/model/src/progress.rs @@ -0,0 +1,174 @@ +//! Progress of a long-running command (a solve, a mesher), reported from deep inside it. +//! +//! A host that runs a command in the background (the desktop's jobs, the control channel) +//! makes a [`Progress`], installs it on the worker thread with [`scoped`] and reads +//! [`Progress::snapshot`] from another thread while the command runs. The command's layers +//! report where they are without taking a parameter for it: the engine names the step +//! ([`step`]), solvers and meshers their stage and counts ([`stage`], [`count`]). With no +//! [`Progress`] installed (scripts, tests, the CLI) every report is a no-op. +//! +//! The reporter is per thread: work handed to a thread pool installs the caller's +//! ([`current`]) inside it if it reports from there. Reporting never changes a result. + +use std::cell::RefCell; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; + +/// Where a command is. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct State { + /// The step being solved: its name, index from 1 and the number of steps. + #[serde(skip_serializing_if = "Option::is_none")] + pub step: Option, + /// What it is doing now ("Delaunay", "Increment", "Improvement pass"). + pub stage: String, + /// How far into the stage (increments, passes, time steps, tetrahedra made). + pub done: f64, + /// Where the stage ends, when that is known (`done / total` is then a fraction). + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + /// What `done` counts ("increments", "tets", "s"). + #[serde(skip_serializing_if = "str::is_empty")] + pub unit: &'static str, +} + +/// A step of a multi-step solve. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct StepAt { + pub name: String, + pub index: usize, + pub count: usize, +} + +impl State { + /// The bounded part done, 0 to 1, when the stage has a known end. + pub fn fraction(&self) -> Option { + let total = self.total.filter(|t| *t > 0.0 && t.is_finite())?; + Some((self.done / total).clamp(0.0, 1.0)) + } + + /// One line: `Step 1/2 Load · Increment 3 of 10`. + pub fn line(&self) -> String { + let mut parts = Vec::new(); + if let Some(s) = &self.step { + parts.push(format!("Step {}/{} {}", s.index, s.count, s.name)); + } + if !self.stage.is_empty() { + let count = match self.total { + Some(t) => format!(" {} of {}", number(self.done), number(t)), + None if self.done > 0.0 => format!(" {}", number(self.done)), + None => String::new(), + }; + let unit = if self.unit.is_empty() || count.is_empty() { String::new() } else { format!(" {}", self.unit) }; + parts.push(format!("{}{count}{unit}", self.stage)); + } + parts.join(" · ") + } +} + +/// A count as text: whole numbers without a fraction, others to 3 significant digits. +fn number(x: f64) -> String { + if x.fract() == 0.0 && x.abs() < 1e15 { format!("{}", x as i64) } else { format!("{x:.3}") } +} + +/// A command's progress, shared between the thread that runs it and those that show it. +#[derive(Debug, Default)] +pub struct Progress { + state: Mutex, +} + +impl Progress { + pub fn new() -> Arc { + Arc::new(Progress::default()) + } + + /// The state now (the default if a reporter panicked while holding it). + pub fn snapshot(&self) -> State { + self.state.lock().map(|s| s.clone()).unwrap_or_default() + } + + fn update(&self, f: impl FnOnce(&mut State)) { + if let Ok(mut s) = self.state.lock() { + f(&mut s); + } + } +} + +thread_local! { + static CURRENT: RefCell>> = const { RefCell::new(None) }; +} + +/// Run `f` with `progress` as this thread's reporter (none: reports are dropped); the previous +/// one comes back after, even if `f` panics. +pub fn scoped(progress: Option>, f: impl FnOnce() -> R) -> R { + struct Restore(Option>); + impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + CURRENT.with(|c| *c.borrow_mut() = previous); + } + } + let previous = CURRENT.with(|c| c.replace(progress)); + let _restore = Restore(previous); + f() +} + +/// This thread's reporter, to install on another thread that reports for it. +pub fn current() -> Option> { + CURRENT.with(|c| c.borrow().clone()) +} + +fn with(f: impl FnOnce(&Progress)) { + if let Some(p) = current() { + f(&p); + } +} + +/// Step `index` (from 1) of `count`, named `name`, starts. +pub fn step(name: &str, index: usize, count: usize) { + with(|p| { + p.update(|s| { + *s = State { step: Some(StepAt { name: name.to_string(), index, count }), ..State::default() }; + }) + }); +} + +/// A stage starts: its count is reset, its end and unit are `total` and `unit`. +pub fn stage(name: &str, total: Option, unit: &'static str) { + with(|p| { + p.update(|s| { + s.stage.clear(); + s.stage.push_str(name); + (s.done, s.total, s.unit) = (0.0, total, unit); + }) + }); +} + +/// The current stage has `done` of its work done. +pub fn count(done: f64) { + with(|p| p.update(|s| s.done = done)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_reach_the_installed_progress_only() { + stage("ignored", None, ""); + let p = Progress::new(); + scoped(Some(p.clone()), || { + step("Load", 1, 2); + stage("Increment", Some(10.0), "increments"); + count(3.0); + }); + let s = p.snapshot(); + assert_eq!(s.line(), "Step 1/2 Load · Increment 3 of 10 increments"); + assert_eq!(s.fraction(), Some(0.3)); + assert!(current().is_none()); + // Nothing installed: nothing reported, nothing panics. + count(5.0); + assert_eq!(p.snapshot().done, 3.0); + } +} diff --git a/crates/solver/src/heat.rs b/crates/solver/src/heat.rs index 61b83ca..5dbdbc1 100644 --- a/crates/solver/src/heat.rs +++ b/crates/solver/src/heat.rs @@ -381,7 +381,9 @@ fn run(model: &Model, resolved: &Resolved, step: &Step, limits: &Limits) -> Resu let mut previous = t.clone(); let (mut frames, mut times) = (vec![t.clone()], vec![0.0]); let mut rhs = vec![0.0; n]; + fieldcraft_model::progress::stage("Time increment", Some(steps as f64), ""); for i in 1..=steps { + fieldcraft_model::progress::count((i - 1) as f64); b.mul(&t, &mut rhs); for (r, fj) in rhs.iter_mut().zip(&f) { *r += dt * fj; diff --git a/crates/solver/src/nonlinear.rs b/crates/solver/src/nonlinear.rs index 91978bd..40d37ad 100644 --- a/crates/solver/src/nonlinear.rs +++ b/crates/solver/src/nonlinear.rs @@ -1231,7 +1231,9 @@ fn solve_on(model: &Model, step: &str, limits: &Limits, notes: Vec) -> R // The last converged increment's free motion and the change of the load factor over it. let mut previous: Option<(Vec, f64)> = None; let max_increments = usize::try_from(settings.max_increments).unwrap_or(usize::MAX); + fieldcraft_model::progress::stage("Load factor", Some(1.0), ""); while t < 1.0 { + fieldcraft_model::progress::count(lambda); if history.increments.len() >= max_increments { return Err(Error::Limit(format!( "step `{}`: {} increments reached the step's cap at load factor {lambda:.6} (raise max_increments)",