forked from 1b-agent/fieldcraft-upstream
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
131 lines
5.0 KiB
Rust
131 lines
5.0 KiB
Rust
//! A cancelled job ([`fieldcraft_model::progress::Progress::cancel`]) stops every solver and
|
|
//! mesher promptly with `cancelled` and leaves the model as it was.
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use fieldcraft_geometry::sample::Plate;
|
|
use fieldcraft_model::progress::{self, Progress};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
use crate::sample::impact;
|
|
|
|
/// How long a cancelled command may keep running: one increment, time step or pass, far
|
|
/// below the minutes the runs below would take uncancelled.
|
|
const PROMPT: Duration = Duration::from_secs(5);
|
|
|
|
/// Run `command` on `s` the way a job does and cancel it once it reports the stage `stage`
|
|
/// (mid-run), or before it starts (`None`): it must fail with `cancelled`, soon after, leaving
|
|
/// the model and the journal as they were.
|
|
fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) {
|
|
let (model, journal) = (s.model.clone(), s.journal.len());
|
|
let p = Progress::new();
|
|
if stage.is_none() {
|
|
p.cancel();
|
|
}
|
|
let (r, waited) = std::thread::scope(|scope| {
|
|
let worker = {
|
|
let p = p.clone();
|
|
let s = &mut *s;
|
|
let params = params.clone();
|
|
scope.spawn(move || progress::scoped(Some(p), || s.execute(command, ¶ms)))
|
|
};
|
|
let mut at = Instant::now();
|
|
if let Some(stage) = stage {
|
|
let deadline = Instant::now() + Duration::from_secs(600);
|
|
while !worker.is_finished() && Instant::now() < deadline {
|
|
let now = p.snapshot();
|
|
if now.stage.starts_with(stage) && now.done > 0.0 {
|
|
at = Instant::now();
|
|
p.cancel();
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
}
|
|
assert!(p.is_cancelled(), "{command}: never reached stage `{stage}` ({:?})", p.snapshot());
|
|
}
|
|
let r = worker.join().unwrap();
|
|
(r, at.elapsed())
|
|
});
|
|
let e = r.expect_err(&format!("{command}: ran to the end although cancelled"));
|
|
assert!(e.is_cancelled(), "{command}: {e}");
|
|
assert_eq!(e.to_string(), "cancelled", "{command}");
|
|
assert!(waited < PROMPT, "{command}: stopped {waited:?} after the cancel");
|
|
assert!(s.model == model, "{command}: the model changed");
|
|
assert_eq!(s.journal.len(), journal, "{command}: journalled");
|
|
}
|
|
|
|
/// A sample's script run on a fresh session.
|
|
fn session(script: &Value) -> Session {
|
|
let mut s = Session::default();
|
|
s.run_script(script).unwrap();
|
|
s
|
|
}
|
|
|
|
fn plate() -> (crate::tests::TempDir, Session) {
|
|
let (dir, path) = crate::tests::sample_design_file(&Plate::default());
|
|
let mut s = Session::default();
|
|
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
|
(dir, s)
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_stops_between_time_steps() {
|
|
// A hundred times as long as the sample: hundreds of thousands of increments.
|
|
let mut script = impact();
|
|
for c in script.as_array_mut().unwrap() {
|
|
if c["command"] == "model.step.create" {
|
|
let p = &mut c["params"]["procedure"];
|
|
(p["end_time"], p["output_interval"], p["history_interval"]) = (json!(4e-3), json!(4e-4), json!(2e-5));
|
|
}
|
|
}
|
|
let mut s = session(&script);
|
|
cancel(&mut s, "solve.explicit", json!({}), Some("Time"));
|
|
}
|
|
|
|
#[test]
|
|
fn fluid_stops_between_lattice_steps() {
|
|
let mut s = Session::default();
|
|
s.execute("file.sample", &json!({"name": "flow-past"})).unwrap();
|
|
cancel(&mut s, "solve.fluid", json!({}), Some("Fluid steps"));
|
|
}
|
|
|
|
#[test]
|
|
fn nonlinear_stops_between_increments() {
|
|
let mut s = Session::default();
|
|
s.execute("file.sample", &json!({"name": "rubber-seal"})).unwrap();
|
|
cancel(&mut s, "solve.nonlinear", json!({}), Some("Load factor"));
|
|
}
|
|
|
|
#[test]
|
|
fn transient_heat_stops_between_increments() {
|
|
let mut s = Session::default();
|
|
s.execute("file.sample", &json!({"name": "heated-bracket"})).unwrap();
|
|
s.execute(
|
|
"model.step.create",
|
|
&json!({"name": "Warm", "procedure": {"type": "heat_transfer", "transient": {"end_time": 1e5, "increment": 1}},
|
|
"boundary_conditions": ["Heater"], "loads": ["Air"]}),
|
|
)
|
|
.unwrap();
|
|
cancel(&mut s, "solve.heat", json!({"step": "Warm"}), Some("Time increment"));
|
|
}
|
|
|
|
#[test]
|
|
fn static_frequency_and_solve_all_stop_at_the_factorization() {
|
|
let mut s = Session::default();
|
|
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
|
|
cancel(&mut s, "solve.static", json!({}), None);
|
|
cancel(&mut s, "solve.frequency", json!({}), None);
|
|
cancel(&mut s, "solve.run", json!({}), None);
|
|
}
|
|
|
|
#[test]
|
|
fn meshers_stop_and_leave_no_mesh() {
|
|
let (_dir, mut s) = plate();
|
|
cancel(&mut s, "mesh.tet.generate", json!({"body": "Body1", "size": 0.4}), Some("Refinement round"));
|
|
cancel(&mut s, "mesh.tet.generate", json!({"body": "Body1", "size": 2.5}), None);
|
|
cancel(&mut s, "mesh.hex.generate", json!({"body": "Body1", "size": 2.5}), None);
|
|
cancel(&mut s, "mesh.hex.octree", json!({"body": "Body1", "size": 5.0, "max_depth": 2}), None);
|
|
assert!(s.model.nodes().is_empty());
|
|
}
|