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)",