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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 09:14:34 -04:00
1 parent a7bb67c452
commit 0e0d8bd114
12 files changed
+218 -11

No files matched your search

+2
View File
@@ -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() };
+1 -1
View File
@@ -153,7 +153,7 @@ pub fn replacement(s: &Session, command: &str, params: &Value) -> Option<Replace
}
/// `n` with thousands separators: 7274 → "7,274".
fn thousands(n: usize) -> 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() {
+16 -8
View File
@@ -168,6 +168,12 @@ fn extreme(values: impl Iterator<Item = (u32, f64)>, better: fn(f64, f64) -> boo
}
}
/// [`parallel::with_threads`], the caller's progress reporter carried onto the pool's thread.
fn with_threads<R: Send>(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<Value> {
use fieldcraft_model::Procedure;
type Solve = fn(&mut Session, &Value) -> Result<Value>;
@@ -192,7 +198,9 @@ fn run_all(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
let limits = limits(p)?;
let step = opt::<String>(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<Value> {
let limits = limits(p)?;
let step = opt::<String>(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<Value> {
let limits = limits(p)?;
let step = opt::<String>(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<Value> {
let mass = opt::<MassKind>(p, "mass")?.unwrap_or_default();
let shift = opt::<f64>(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<Value> {
let limits = limits(p)?;
let step = opt::<String>(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<Value> {
};
let step = opt::<String>(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<Value> {
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)
+1 -1
View File
@@ -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;
+7
View File
@@ -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}"));
+4
View File
@@ -135,8 +135,12 @@ pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResul
let mut history_times = Vec::new();
let mut forces: Vec<Vec<[f64; 3]>> = 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()) {
+2
View File
@@ -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)?,
+6 -1
View File
@@ -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<Option<AnalyticSurface>>, o: &QualityOptions) -> Result<QualityMesh> {
@@ -486,7 +488,9 @@ pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Opti
report.collapsed = refine::coarsen(&mut cx, &ctx, 0.6, 1.4, 0.05, 6)?;
seed_curves(&mut cx, &ctx, &o.seeds, global)?;
let ropt = RefineOptions { radius_edge: o.radius_edge, ..RefineOptions::default() };
for _ in 0..o.rounds {
progress::stage("Refinement round", Some(o.rounds as f64), "");
for round in 0..o.rounds {
progress::count((round + 1) as f64);
let stats = refine::refine(&mut cx, &ctx, &ropt)?;
let quiet = stats.interior + stats.boundary == 0;
let stopped = stats.stopped.clone();
@@ -510,6 +514,7 @@ pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Opti
}
// Put the boundary vertices on the exact surfaces (the tessellation is within its chordal
// tolerance of them).
progress::stage("Snapping to the geometry", None, "");
report.unsnapped = snap_to_geometry(&mut cx, &exact, true)?;
let c = cx.compact()?;
let boundary: Vec<TaggedFace> = c.boundary.iter().map(|&(tet, k, face)| TaggedFace { tet, opposite: k as u8, face }).collect();
+1
View File
@@ -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;
+174
View File
@@ -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<StepAt>,
/// 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<f64>,
/// 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<f64> {
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<State>,
}
impl Progress {
pub fn new() -> Arc<Progress> {
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<Option<Arc<Progress>>> = 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<R>(progress: Option<Arc<Progress>>, f: impl FnOnce() -> R) -> R {
struct Restore(Option<Arc<Progress>>);
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<Arc<Progress>> {
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<f64>, 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);
}
}
+2
View File
@@ -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;
+2
View File
@@ -1231,7 +1231,9 @@ fn solve_on(model: &Model, step: &str, limits: &Limits, notes: Vec<String>) -> R
// The last converged increment's free motion and the change of the load factor over it.
let mut previous: Option<(Vec<f64>, 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)",