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 <noreply@anthropic.com>
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

+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);
}
}