Compare commits

..
Author SHA1 Message Date
Christian SvenssonandClaude Opus 5.5 0ec1a5c507 docs/remote: design proposal for FieldCraft Remote
A distributed, asynchronous simulation backend: submit a solve from the
desktop app, the browser, the CLI or an MCP agent, disconnect, and claim
the results later from any device. The hub is an in-memory switchboard
(hub.solidcraft.io or self-hosted) that stores nothing; one worker
coordinates and keeps the job log, results stay on the workers until a
client claims them, and a cluster is a 12-word passphrase from which all
keys derive, so the hub never sees content. Covers the protocol, hub
restarts, studies and caching, Kubernetes pools, the engine changes for
M1 and a five-milestone rollout with the public hub last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:57:50 +02:00
22 changed files with 422 additions and 647 deletions

No files matched your search

-1
View File
@@ -30,7 +30,6 @@ is committed and listed here with its exports.
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 | | crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/heated-flow.png | FieldCraft contributors | Original: the heated-flow sample's temperature slice rendered directly at 320 × 213 by `fieldcraft-cli snapshot` with the CPU renderer; geometry is the original upright-bracket recipe | MIT OR Apache-2.0 |
| docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 | | crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 | | crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 |
Generated
+22 -22
View File
@@ -1264,7 +1264,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]] [[package]]
name = "fieldcraft" name = "fieldcraft"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-cli" name = "fieldcraft-cli"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-engine", "fieldcraft-engine",
"fieldcraft-gpu", "fieldcraft-gpu",
@@ -1301,7 +1301,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-decks" name = "fieldcraft-decks"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-model", "fieldcraft-model",
"serde_json", "serde_json",
@@ -1310,7 +1310,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-engine" name = "fieldcraft-engine"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-decks", "fieldcraft-decks",
"fieldcraft-explicit", "fieldcraft-explicit",
@@ -1334,7 +1334,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-explicit" name = "fieldcraft-explicit"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-fem", "fieldcraft-fem",
"fieldcraft-model", "fieldcraft-model",
@@ -1345,7 +1345,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-fem" name = "fieldcraft-fem"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-model", "fieldcraft-model",
"thiserror 2.0.21", "thiserror 2.0.21",
@@ -1353,7 +1353,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-geometry" name = "fieldcraft-geometry"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-model", "fieldcraft-model",
"quick-xml 0.42.0", "quick-xml 0.42.0",
@@ -1370,7 +1370,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-gpu" name = "fieldcraft-gpu"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-render", "fieldcraft-render",
"log", "log",
@@ -1382,7 +1382,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-io" name = "fieldcraft-io"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-model", "fieldcraft-model",
"log", "log",
@@ -1395,7 +1395,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-lbm" name = "fieldcraft-lbm"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-mesh", "fieldcraft-mesh",
"fieldcraft-model", "fieldcraft-model",
@@ -1405,7 +1405,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-lbm-gpu" name = "fieldcraft-lbm-gpu"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-gpu", "fieldcraft-gpu",
"fieldcraft-lbm", "fieldcraft-lbm",
@@ -1418,7 +1418,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-mcp" name = "fieldcraft-mcp"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-engine", "fieldcraft-engine",
"fieldcraft-gpu", "fieldcraft-gpu",
@@ -1430,7 +1430,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-mesh" name = "fieldcraft-mesh"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-model", "fieldcraft-model",
"fieldcraft-parallel", "fieldcraft-parallel",
@@ -1444,7 +1444,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-model" name = "fieldcraft-model"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1453,14 +1453,14 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-parallel" name = "fieldcraft-parallel"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"rayon", "rayon",
] ]
[[package]] [[package]]
name = "fieldcraft-render" name = "fieldcraft-render"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-model", "fieldcraft-model",
"png", "png",
@@ -1470,7 +1470,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-solver" name = "fieldcraft-solver"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-fem", "fieldcraft-fem",
"fieldcraft-mesh", "fieldcraft-mesh",
@@ -1482,7 +1482,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-testkit" name = "fieldcraft-testkit"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-engine", "fieldcraft-engine",
"fieldcraft-geometry", "fieldcraft-geometry",
@@ -1494,7 +1494,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-ui-egui" name = "fieldcraft-ui-egui"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"craft-ui", "craft-ui",
"egui", "egui",
@@ -1516,7 +1516,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-update" name = "fieldcraft-update"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"flate2", "flate2",
"log", "log",
@@ -1533,7 +1533,7 @@ dependencies = [
[[package]] [[package]]
name = "fieldcraft-web" name = "fieldcraft-web"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"base64", "base64",
"eframe", "eframe",
@@ -5426,7 +5426,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.7.0" version = "0.6.0"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"] members = ["crates/*", "apps/*", "xtask"]
[workspace.package] [workspace.package]
version = "0.7.0" version = "0.6.0"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.95" rust-version = "1.95"
-7
View File
@@ -123,10 +123,3 @@ What exists and what is missing, by area; each gap is in a milestone above.
| Fluid dynamics | LBM D3Q19 TRT, walls, inlets/outlets, obstacle forces | Turbulence with wall models, thermal and conjugate heat transfer, non-Newtonian, porous media, multiphase, GPU (M12, M13) | | Fluid dynamics | LBM D3Q19 TRT, walls, inlets/outlets, obstacle forces | Turbulence with wall models, thermal and conjugate heat transfer, non-Newtonian, porous media, multiphase, GPU (M12, M13) |
| Wave propagation | One-dimensional elastic waves and impact in the explicit solver (bar cases in the oracle) | A 2D/3D finite-difference ultrasound solver (velocity–stress FDTD, EFIT, k-space pseudospectral) on voxelised geometry, time–frequency diagrams (spectrogram, wavelet, Wigner–Ville) with dispersion overlays, spectral elements, absorbing boundaries, tone-burst and transducer sources, attenuation, Lamb/Rayleigh/Love guided waves and SAFE dispersion curves, ultrasound in fluids and tissue, nonlinear acoustics, A/B-scans and frequency–wavenumber analysis (M23) | | Wave propagation | One-dimensional elastic waves and impact in the explicit solver (bar cases in the oracle) | A 2D/3D finite-difference ultrasound solver (velocity–stress FDTD, EFIT, k-space pseudospectral) on voxelised geometry, time–frequency diagrams (spectrogram, wavelet, Wigner–Ville) with dispersion overlays, spectral elements, absorbing boundaries, tone-burst and transducer sources, attenuation, Lamb/Rayleigh/Love guided waves and SAFE dispersion curves, ultrasound in fluids and tissue, nonlinear acoustics, A/B-scans and frequency–wavenumber analysis (M23) |
| Design | — | Parametric studies on SolidCraft parameters, optimisation, submodelling (M22) | | Design | — | Parametric studies on SolidCraft parameters, optimisation, submodelling (M22) |
### Heated flow results (#157)
Temperature is available on fluid slices, obstacle surfaces and streamline colours when the
saved frames carry it. Signed face-average wall heat follows recorded histories and the
model's power/area units. Commands, the Results panel and headless pictures share the view;
initial frames without recorded wall heat remain uncoloured.
+1 -1
View File
@@ -696,7 +696,7 @@ pub static CATALOGUE: &[Capability] = &[
"Streamlines", "Streamlines",
Results, Results,
"Plots", "Plots",
"A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area", "A fluid step's streamlines coloured by speed, its velocity or pressure on a slice, and the obstacles coloured by the flow",
&["results.slice", "results.surface"], &["results.slice", "results.surface"],
), ),
ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"), ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"),
+2 -19
View File
@@ -1971,7 +1971,6 @@ pub const SAMPLE: &[Field] = &[choice(
("drop-test", "Drop test"), ("drop-test", "Drop test"),
("rubber-seal", "Rubber seal"), ("rubber-seal", "Rubber seal"),
("flow-past", "Flow past a bracket"), ("flow-past", "Flow past a bracket"),
("heated-flow", "Heated flow past a bracket"),
("heated-bracket", "Heated bracket"), ("heated-bracket", "Heated bracket"),
("bracket-hex", "Hex-meshed bracket"), ("bracket-hex", "Hex-meshed bracket"),
("crank-arm", "Bicycle crank arm"), ("crank-arm", "Bicycle crank arm"),
@@ -2082,8 +2081,6 @@ pub const CONTOUR: &[Field] = &[
("displacement_z", "Displacement Z"), ("displacement_z", "Displacement Z"),
("velocity", "Velocity magnitude"), ("velocity", "Velocity magnitude"),
("pressure", "Pressure (fluid)"), ("pressure", "Pressure (fluid)"),
("temperature", "Temperature"),
("wall_heat_flux", "Wall heat flux (into wall)"),
("max_principal", "Max principal"), ("max_principal", "Max principal"),
("mid_principal", "Mid principal"), ("mid_principal", "Mid principal"),
("min_principal", "Min principal"), ("min_principal", "Min principal"),
@@ -2125,7 +2122,7 @@ pub const FRAME: &[Field] = &[
const FLUID_FRAME: Field = int("frame", "Frame", 0, 1_000_000_000).help("The frame shown, from 0.").hint("the frame shown"); const FLUID_FRAME: Field = int("frame", "Frame", 0, 1_000_000_000).help("The frame shown, from 0.").hint("the frame shown");
pub const SLICE: &[Field] = &[ pub const SLICE: &[Field] = &[
SOLVED, SOLVED,
choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure"), ("temperature", "Temperature (heated flow)")]) choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure")])
.help("The field coloured on the slice and the obstacles.") .help("The field coloured on the slice and the obstacles.")
.hint("the field shown"), .hint("the field shown"),
choice("axis", "Normal", &[("x", "X"), ("y", "Y"), ("z", "Z"), ("none", "None (hide)")]) choice("axis", "Normal", &[("x", "X"), ("y", "Y"), ("z", "Z"), ("none", "None (hide)")])
@@ -2136,23 +2133,9 @@ pub const SLICE: &[Field] = &[
.hint("the position shown"), .hint("the position shown"),
FLUID_FRAME, FLUID_FRAME,
]; ];
pub const FLOW_SURFACE: &[Field] = &[ pub const FLOW_SURFACE: &[Field] = &[SOLVED, flag("show", "Show").help("Colour the obstacles by the field in the fluid beside them.").def("true")];
SOLVED,
flag("show", "Show").help("Colour the obstacles by the selected field.").def("true"),
choice(
"field",
"Field",
&[("velocity", "Velocity"), ("pressure", "Pressure"), ("temperature", "Temperature"), ("wall_heat_flux", "Wall heat flux (into wall)")],
)
.help("Colour the obstacle faces by the adjacent fluid field, or signed wall heat received per area.")
.hint("the field shown"),
FLUID_FRAME,
];
pub const STREAMLINES: &[Field] = &[ pub const STREAMLINES: &[Field] = &[
SOLVED, SOLVED,
choice("field", "Field", &[("velocity", "Velocity"), ("pressure", "Pressure (plain lines)"), ("temperature", "Temperature")])
.help("Colour streamlines by speed or temperature; pressure leaves the lines in one colour.")
.hint("the field shown"),
flag("show", "Show").help("Draw the streamlines.").def("true"), flag("show", "Show").help("Draw the streamlines.").def("true"),
int("count", "Count", 1, 2000).help("Seeds on the inlet face, or points along the seed line.").def("64"), int("count", "Count", 1, 2000).help("Seeds on the inlet face, or points along the seed line.").def("64"),
int("max_steps", "Max steps", 1, 100_000).help("Most steps of half a cell each streamline takes.").def("4000"), int("max_steps", "Max steps", 1, 100_000).help("Most steps of half a cell each streamline takes.").def("4000"),
+10 -105
View File
@@ -24,7 +24,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
pub const CONTOUR: CommandSpec = CommandSpec { pub const CONTOUR: CommandSpec = CommandSpec {
id: "results.contour", id: "results.contour",
label: "Contours", label: "Contours",
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), temperature (thermal frames), wall_heat_flux (signed face average on thermal fluid obstacles), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#, params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
form: Some(super::forms::CONTOUR), form: Some(super::forms::CONTOUR),
kind: CommandKind::Session, kind: CommandKind::Session,
requires: &[Requirement::Result], requires: &[Requirement::Result],
@@ -94,7 +94,7 @@ pub const EXPORT_CSV: CommandSpec = CommandSpec {
pub const SLICE: CommandSpec = CommandSpec { pub const SLICE: CommandSpec = CommandSpec {
id: "results.slice", id: "results.slice",
label: "Flow Slice", label: "Flow Slice",
params: r#"{"step": "Flow", "field": "velocity" | "pressure" | "temperature", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude, gauge pressure or temperature when its frames carry heat, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#, params: r#"{"step": "Flow", "field": "velocity" | "pressure", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude or gauge pressure, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#,
form: Some(super::forms::SLICE), form: Some(super::forms::SLICE),
kind: CommandKind::Session, kind: CommandKind::Session,
requires: &[Requirement::Result], requires: &[Requirement::Result],
@@ -104,7 +104,7 @@ pub const SLICE: CommandSpec = CommandSpec {
pub const SURFACE: CommandSpec = CommandSpec { pub const SURFACE: CommandSpec = CommandSpec {
id: "results.surface", id: "results.surface",
label: "Flow on Surfaces", label: "Flow on Surfaces",
params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature" | "wall_heat_flux", "frame"?: 10} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `wall_heat_flux` is signed power received by each obstacle face divided by its complete tessellated area, in model power/area units (positive into the wall); histories interpolate at the shown frame without extrapolation; recorded adiabatic faces are zero, missing histories remain uncoloured; no slice is drawn for this surface-only field. `show: false` draws the part in its own colours"#, params: r#"{"step": "Flow", "show": true} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `show: false` draws the part in its own colours"#,
form: Some(super::forms::FLOW_SURFACE), form: Some(super::forms::FLOW_SURFACE),
kind: CommandKind::Session, kind: CommandKind::Session,
requires: &[Requirement::Result], requires: &[Requirement::Result],
@@ -114,7 +114,7 @@ pub const SURFACE: CommandSpec = CommandSpec {
pub const STREAMLINES: CommandSpec = CommandSpec { pub const STREAMLINES: CommandSpec = CommandSpec {
id: "results.streamlines", id: "results.streamlines",
label: "Streamlines", label: "Streamlines",
params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature", "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed or temperature when that field is selected (plain for pressure): from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#, params: r#"{"step": "Flow", "show": true, "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed when the legend shows velocity: from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#,
form: Some(super::forms::STREAMLINES), form: Some(super::forms::STREAMLINES),
kind: CommandKind::Session, kind: CommandKind::Session,
requires: &[Requirement::Result], requires: &[Requirement::Result],
@@ -249,27 +249,12 @@ fn contour(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(f), Some((r, _))) = (field, s.latest_result(&step)) if let (Some(f), Some((r, _))) = (field, s.latest_result(&step))
&& !Field::available(r).any(|a| a == f) && !Field::available(r).any(|a| a == f)
{ {
if f == Field::Temperature && matches!(r.kind(), Kind::Fluid(_)) {
return Err(EngineError::Params(format!(
"temperature is not available for fluid step `{step}`: its frames have no complete temperature field (isothermal flow)"
)));
}
if f == Field::WallHeatFlux {
return Err(EngineError::Params(format!("wall heat flux is not available for step `{step}`: it requires thermal obstacle histories")));
}
return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label()))); return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label())));
} }
let range = match p.get("range") { let range = match p.get("range") {
None => None, None => None,
Some(r) => Some(legend_range(r)?), Some(r) => Some(legend_range(r)?),
}; };
if field == Some(Field::WallHeatFlux)
&& let Some((r, _)) = s.latest_result(&step)
&& let Kind::Fluid(f) = r.kind()
{
let pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default();
crate::flow::wall::faces(s, f, pick)?;
}
let v = view_of(s, &step)?; let v = view_of(s, &step)?;
if v.contour != field { if v.contour != field {
(v.range_min, v.range_max) = (None, None); (v.range_min, v.range_max) = (None, None);
@@ -418,7 +403,12 @@ fn seed_point(v: &Value) -> Option<[f64; 3]> {
fn slice(s: &mut Session, p: &Value) -> Result<Value> { fn slice(s: &mut Session, p: &Value) -> Result<Value> {
let (step, r) = fluid_step(s, p)?; let (step, r) = fluid_step(s, p)?;
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
let field = fluid_field(p, &r)?; let field = match opt::<String>(p, "field")?.as_deref() {
None => None,
Some("velocity") => Some(Field::Velocity),
Some("pressure") => Some(Field::Pressure),
Some(_) => return Err(EngineError::invalid("field", "must be \"velocity\" or \"pressure\"")),
};
// Some(None): hide the slice. // Some(None): hide the slice.
let axis = match opt::<String>(p, "axis")?.as_deref() { let axis = match opt::<String>(p, "axis")?.as_deref() {
None => None, None => None,
@@ -473,27 +463,7 @@ fn surface(s: &mut Session, p: &Value) -> Result<Value> {
let (step, r) = fluid_step(s, p)?; let (step, r) = fluid_step(s, p)?;
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
let show = opt::<bool>(p, "show")?.unwrap_or(true); let show = opt::<bool>(p, "show")?.unwrap_or(true);
let field = if opt::<String>(p, "field")?.as_deref() == Some("wall_heat_flux") {
if !Field::available(&r).any(|f| f == Field::WallHeatFlux) {
return Err(EngineError::invalid("field", "wall heat flux is unavailable: this step has no thermal obstacle histories"));
}
Some(Field::WallHeatFlux)
} else {
fluid_field(p, &r)?
};
let frame = fluid_frame(f, p)?;
if field == Some(Field::WallHeatFlux) {
let mut pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default();
if frame.is_some() {
pick.frame = frame;
}
crate::flow::wall::faces(s, f, pick)?;
}
let v = view_of(s, &step)?; let v = view_of(s, &step)?;
set_fluid_field(v, field);
if frame.is_some() {
v.frame = frame;
}
let mut flow = FlowView::of(v, f).into_owned(); let mut flow = FlowView::of(v, f).into_owned();
flow.surface = show; flow.surface = show;
v.flow = Some(flow); v.flow = Some(flow);
@@ -504,7 +474,6 @@ fn streamlines(s: &mut Session, p: &Value) -> Result<Value> {
let (step, r) = fluid_step(s, p)?; let (step, r) = fluid_step(s, p)?;
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
let show = opt::<bool>(p, "show")?.unwrap_or(true); let show = opt::<bool>(p, "show")?.unwrap_or(true);
let field = fluid_field(p, &r)?;
let count = count_param(p, "count", MAX_STREAMLINES)?; let count = count_param(p, "count", MAX_STREAMLINES)?;
let max_steps = count_param(p, "max_steps", MAX_STREAMLINE_STEPS)?; let max_steps = count_param(p, "max_steps", MAX_STREAMLINE_STEPS)?;
let max = MAX_STREAMLINES as usize; let max = MAX_STREAMLINES as usize;
@@ -533,7 +502,6 @@ fn streamlines(s: &mut Session, p: &Value) -> Result<Value> {
} }
let frame = fluid_frame(f, p)?; let frame = fluid_frame(f, p)?;
let v = view_of(s, &step)?; let v = view_of(s, &step)?;
set_fluid_field(v, field);
let mut flow = FlowView::of(v, f).into_owned(); let mut flow = FlowView::of(v, f).into_owned();
flow.streamlines = if show { flow.streamlines = if show {
let mut sl = flow.streamlines.take().unwrap_or_default(); let mut sl = flow.streamlines.take().unwrap_or_default();
@@ -569,32 +537,6 @@ fn streamlines(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out) Ok(out)
} }
fn fluid_field(p: &Value, r: &crate::StepResult) -> Result<Option<Field>> {
let field = match opt::<String>(p, "field")?.as_deref() {
None => return Ok(None),
Some("velocity") => Field::Velocity,
Some("pressure") => Field::Pressure,
Some("temperature") => Field::Temperature,
Some(_) => return Err(EngineError::invalid("field", "must be velocity, pressure or temperature")),
};
if !Field::available(r).any(|f| f == field) {
return Err(EngineError::invalid(
"field",
"temperature is unavailable: this isothermal step has no complete temperature field in its frames",
));
}
Ok(Some(field))
}
fn set_fluid_field(view: &mut ResultsView, field: Option<Field>) {
if let Some(field) = field {
if view.contour != Some(field) {
(view.range_min, view.range_max) = (None, None);
}
view.contour = Some(field);
}
}
fn clear(s: &mut Session, _: &Value) -> Result<Value> { fn clear(s: &mut Session, _: &Value) -> Result<Value> {
s.results_view = None; s.results_view = None;
Ok(json!({"view": null})) Ok(json!({"view": null}))
@@ -1494,43 +1436,6 @@ mod tests {
} }
} }
#[test]
fn temperature_views_require_thermal_frames_and_follow_the_selected_frame() {
let mut r = crate::flow::tests::channel();
r.frames[0].temperature = vec![20.0; r.grid.len()];
r.frames[1].temperature = vec![70.0; r.grid.len()];
let mut s = Session::default();
s.store_result(crate::StepResult::Fluid(r));
for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] {
let report = s.execute(command, &json!({"field": "temperature"})).unwrap();
assert_eq!(report["contour"]["field"], "temperature", "{command}: {report}");
assert_eq!(report["contour"]["range"]["max"]["value"], 70.0);
}
let report = s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert_eq!(report["contour"]["range"]["max"]["value"], 20.0);
assert!(Field::available(s.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature));
let before = s.results_view.clone();
for command in ["results.slice", "results.surface", "results.streamlines"] {
for params in [json!({"field": "temperature", "frame": u64::MAX}), json!({"field": ["temperature"]})] {
assert!(s.execute(command, &params).is_err());
assert_eq!(s.results_view, before);
}
}
let mut incomplete = crate::flow::tests::channel();
incomplete.frames[0].temperature = vec![20.0; incomplete.grid.len()];
incomplete.frames[1].temperature = vec![70.0; incomplete.grid.len().saturating_sub(1)];
let incomplete = crate::StepResult::Fluid(incomplete);
assert!(!Field::available(&incomplete).any(|f| f == Field::Temperature));
let mut cold = fluid();
let before = cold.results_view.clone();
for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] {
let error = cold.execute(command, &json!({"field": "temperature"})).unwrap_err().to_string();
assert!(error.contains("isothermal"), "{command}: {error}");
assert_eq!(cold.results_view, before);
}
assert!(!Field::available(cold.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature));
}
#[test] #[test]
fn fluid_display_hostile_params_never_panic() { fn fluid_display_hostile_params_never_panic() {
let s = fluid(); let s = fluid();
+10 -101
View File
@@ -1,5 +1,5 @@
//! The display of a fluid step's result: its fields per cell drawn as a coloured slice through the //! The display of a fluid step's result: its fields per cell drawn as a coloured slice through the
//! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed or temperature //! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed
//! ([`fieldcraft_render::flow`]), as the results view's [`FlowView`] asks. The headless pictures //! ([`fieldcraft_render::flow`]), as the results view's [`FlowView`] asks. The headless pictures
//! ([`crate::view`]) and the desktop viewport draw the same [`FlowPlot`]; the part stays drawn //! ([`crate::view`]) and the desktop viewport draw the same [`FlowPlot`]; the part stays drawn
//! (a fluid step does not deform it). //! (a fluid step does not deform it).
@@ -21,8 +21,6 @@ use serde_json::{Value, json};
use crate::results::{Field, Kind, Pick, ResultsView, magnitude}; use crate::results::{Field, Kind, Pick, ResultsView, magnitude};
use crate::{EngineError, Result, StepResult}; use crate::{EngineError, Result, StepResult};
pub mod wall;
/// Streamlines traced when no count is given, and the steps (half a cell each) each may take. /// Streamlines traced when no count is given, and the steps (half a cell each) each may take.
pub const DEFAULT_STREAMLINES: u32 = 64; pub const DEFAULT_STREAMLINES: u32 = 64;
pub const DEFAULT_STREAMLINE_STEPS: u32 = 4_000; pub const DEFAULT_STREAMLINE_STEPS: u32 = 4_000;
@@ -92,7 +90,7 @@ impl Default for StreamlineView {
} }
/// What a fluid step's display shows besides the field's legend (the view's contour field: /// What a fluid step's display shows besides the field's legend (the view's contour field:
/// velocity, pressure, temperature or wall heat flux; `None` draws no colours). /// velocity or pressure, `None` draws no colours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FlowView { pub struct FlowView {
/// The slice, `None` for none. /// The slice, `None` for none.
@@ -159,29 +157,14 @@ pub fn direction(r: &FluidResult, f: &Frame) -> (Axis, bool) {
(axis, sum[axis.index()] >= 0.0) (axis, sum[axis.index()] >= 0.0)
} }
/// A cell field's value in every cell of a frame: velocity magnitude, pressure or temperature, NaN in the /// A cell field's value in every cell of a frame: velocity magnitude or pressure, NaN in the
/// solid cells. /// solid cells.
pub fn cell_values(r: &FluidResult, field: Field, f: &Frame) -> Result<Vec<f64>> { pub fn cell_values(r: &FluidResult, field: Field, f: &Frame) -> Result<Vec<f64>> {
let solid = |k: usize| r.solid.get(k).copied().unwrap_or(false); let solid = |k: usize| r.solid.get(k).copied().unwrap_or(false);
let values: Vec<f64> = match field { let values: Vec<f64> = match field {
Field::Velocity => f.velocity.iter().map(|v| magnitude(v.map(f64::from))).collect(), Field::Velocity => f.velocity.iter().map(|v| magnitude(v.map(f64::from))).collect(),
Field::Pressure => f.pressure.iter().map(|p| f64::from(*p)).collect(), Field::Pressure => f.pressure.iter().map(|p| f64::from(*p)).collect(),
Field::Temperature if f.temperature.len() == r.grid.len() && !f.temperature.is_empty() => { _ => return Err(EngineError::Params(format!("{} is not available for fluid step `{}` (velocity or pressure)", field.label(), r.step))),
f.temperature.iter().map(|t| f64::from(*t)).collect()
}
Field::Temperature => {
return Err(EngineError::Params(format!(
"temperature is not available for fluid step `{}`: this frame has no complete temperature field (isothermal flow)",
r.step
)));
}
_ => {
return Err(EngineError::Params(format!(
"{} is not available for fluid step `{}` (velocity, pressure or temperature)",
field.label(),
r.step
)));
}
}; };
Ok(values.into_iter().enumerate().map(|(k, v)| if solid(k) || !v.is_finite() { f64::NAN } else { v }).collect()) Ok(values.into_iter().enumerate().map(|(k, v)| if solid(k) || !v.is_finite() { f64::NAN } else { v }).collect())
} }
@@ -303,10 +286,6 @@ pub struct FlowPlot {
/// The bands streamlines are coloured by speed over: the legend's when it shows velocity; /// The bands streamlines are coloured by speed over: the legend's when it shows velocity;
/// `None` draws them in one colour (the legend shows another field). /// `None` draws them in one colour (the legend shows another field).
pub speed: Option<Bands>, pub speed: Option<Bands>,
/// Face-averaged heat values paired with the displayed geometry triangles.
pub wall_values: Vec<([Vec3; 3], Option<f64>)>,
/// A point on each recorded wall face for the legend's extreme markers.
pub wall_positions: Vec<Option<Vec3>>,
} }
impl FlowPlot { impl FlowPlot {
@@ -322,7 +301,7 @@ pub fn plot(view: &ResultsView, result: &Arc<StepResult>, traced: &TraceCache) -
let k = frame_index(r, view.pick())?; let k = frame_index(r, view.pick())?;
let f = frame(r, view.pick())?; let f = frame(r, view.pick())?;
let fv = FlowView::of(view, r).into_owned(); let fv = FlowView::of(view, r).into_owned();
let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure | Field::Temperature)) { let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure)) {
Some(field) => { Some(field) => {
let values = cell_values(r, field, f)?; let values = cell_values(r, field, f)?;
cell_range(&values).map(|(min, max)| CellContour { field, bands: view.bands(min.1, max.1), min, max, values }) cell_range(&values).map(|(min, max)| CellContour { field, bands: view.bands(min.1, max.1), min, max, values })
@@ -342,34 +321,7 @@ pub fn plot(view: &ResultsView, result: &Arc<StepResult>, traced: &TraceCache) -
None => Arc::default(), None => Arc::default(),
}; };
let speed = contour.as_ref().filter(|c| c.field == Field::Velocity).map(|c| c.bands); let speed = contour.as_ref().filter(|c| c.field == Field::Velocity).map(|c| c.bands);
Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv, wall_values: Vec::new(), wall_positions: Vec::new() }) Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv })
}
/// Add the obstacle face mapping shared by the desktop and headless views.
pub fn plot_with_surfaces(
view: &ResultsView,
result: &Arc<StepResult>,
traced: &TraceCache,
session: &crate::Session,
base: &fieldcraft_render::DisplayMesh,
bodies: &[&str],
) -> Result<FlowPlot> {
let mut out = plot(view, result, traced)?;
if view.contour == Some(Field::WallHeatFlux) {
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
let faces = wall::faces(session, r, view.pick())?;
let positions: std::collections::BTreeMap<_, _> = base
.surface_tris
.iter()
.filter_map(|t| bodies.get(t.body as usize).map(|name| ((*name, t.face), (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0))))
.collect();
out.wall_positions = faces.iter().map(|f| positions.get(&(f.body.as_str(), f.index)).copied()).collect();
let values: Vec<f64> = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect();
out.contour =
cell_range(&values).map(|(min, max)| CellContour { field: Field::WallHeatFlux, bands: view.bands(min.1, max.1), min, max, values });
out.wall_values = base.surface_tris.iter().zip(wall::triangle_values(&faces, base, bodies)).map(|(t, v)| (t.p, v)).collect();
}
Ok(out)
} }
/// The colours a flow plot is drawn in. /// The colours a flow plot is drawn in.
@@ -378,7 +330,7 @@ pub struct FlowColors<'a> {
/// The contour scale, low to high, and values above a clamped legend's maximum. /// The contour scale, low to high, and values above a clamped legend's maximum.
pub stops: &'a [Rgb], pub stops: &'a [Rgb],
pub over: Rgb, pub over: Rgb,
/// Streamlines when the legend shows neither velocity nor temperature. /// Streamlines when the legend shows another field than velocity.
pub plain: Rgb, pub plain: Rgb,
} }
@@ -387,19 +339,7 @@ pub struct FlowColors<'a> {
pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) { pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
if let Some(c) = &plot.contour { if let Some(c) = &plot.contour {
let color = |band: Option<u32>| band.map_or(colors.plain, |k| bands_color(colors.stops, colors.over, &c.bands, k)); let color = |band: Option<u32>| band.map_or(colors.plain, |k| bands_color(colors.stops, colors.over, &c.bands, k));
if plot.surface && c.field == Field::WallHeatFlux { if plot.surface {
// Only tagged geometry triangles can receive wall heat. A structural mesh or
// hidden geometry must not accidentally inherit a face's value by index.
if scene.tris.len() == plot.wall_values.len() {
for (tri, (points, value)) in scene.tris.iter_mut().zip(&plot.wall_values) {
if tri.p == *points
&& let Some(v) = value
{
tri.color = color(c.bands.band(*v));
}
}
}
} else if plot.surface {
let model = std::mem::take(&mut scene.tris); let model = std::mem::take(&mut scene.tris);
for t in model { for t in model {
let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p)); let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p));
@@ -422,14 +362,7 @@ pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
for (k, w) in l.points.windows(2).enumerate() { for (k, w) in l.points.windows(2).enumerate() {
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
let speed = 0.5 * (l.speed.get(k).copied().unwrap_or(0.0) + l.speed.get(k + 1).copied().unwrap_or(0.0)); let speed = 0.5 * (l.speed.get(k).copied().unwrap_or(0.0) + l.speed.get(k + 1).copied().unwrap_or(0.0));
let temperature = plot.contour.as_ref().filter(|c| c.field == Field::Temperature); let color = plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k))).unwrap_or(colors.plain);
let color = if let Some(c) = temperature {
let value = flow::sample(&plot.grid, &c.values, (a + b) * 0.5);
c.bands.band(value).map(|k| bands_color(colors.stops, colors.over, &c.bands, k))
} else {
plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k)))
}
.unwrap_or(colors.plain);
scene.lines.push(SceneLine { a, b, color, width: STREAMLINE_WIDTH }); scene.lines.push(SceneLine { a, b, color, width: STREAMLINE_WIDTH });
} }
} }
@@ -453,7 +386,7 @@ pub fn report(view: &ResultsView, r: &FluidResult, units: fieldcraft_model::Unit
} }
} }
let fv = FlowView::of(view, r); let fv = FlowView::of(view, r);
out["slice"] = match fv.slice.as_ref().filter(|_| view.contour != Some(Field::WallHeatFlux)) { out["slice"] = match &fv.slice {
Some(s) => json!({"axis": s.axis, "position": slice_position(r, s)}), Some(s) => json!({"axis": s.axis, "position": slice_position(r, s)}),
None => Value::Null, None => Value::Null,
}; };
@@ -563,30 +496,6 @@ pub(crate) mod tests {
assert!(s.iter().all(|p| p.y == 3.5), "{s:?}"); assert!(s.iter().all(|p| p.y == 3.5), "{s:?}");
} }
#[test]
fn temperature_colours_cells_and_streamlines_and_masks_solids() {
let mut r = channel();
for f in &mut r.frames {
f.temperature = (0..r.grid.len()).map(|k| 20.0 + 2.0 * r.grid.coords(k)[0] as f32).collect();
}
let values = cell_values(&r, Field::Temperature, &r.frames[1]).unwrap();
assert_eq!(values.iter().filter(|v| v.is_nan()).count(), 8);
assert_eq!(cell_range(&values).map(|(a, b)| (a.1, b.1)), Some((20.0, 38.0)));
let result = Arc::new(crate::StepResult::Fluid(r));
let mut view = ResultsView::for_result(&result);
view.contour = Some(Field::Temperature);
let p = plot(&view, &result, &TraceCache::default()).unwrap();
let mut scene = Scene::default();
let colors = FlowColors { stops: &[Rgb(0, 0, 255), Rgb(255, 0, 0)], over: Rgb(255, 255, 255), plain: Rgb(7, 7, 7) };
draw(&p, &mut scene, &colors);
assert!(!scene.tris.is_empty());
assert!(!scene.lines.is_empty());
assert!(scene.lines.iter().all(|l| l.color != colors.plain));
assert!(scene.lines.windows(2).any(|l| l[0].color != l[1].color));
let cold = channel();
assert!(cell_values(&cold, Field::Temperature, &cold.frames[1]).unwrap_err().to_string().contains("isothermal"));
}
#[test] #[test]
fn cell_fields_leave_the_solid_out() { fn cell_fields_leave_the_solid_out() {
let r = channel(); let r = channel();
-156
View File
@@ -1,156 +0,0 @@
//! Face-averaged heat received by obstacle walls, in the model's consistent units.
//! The history is interpolated only between recorded samples: frame zero predates it.
use std::collections::BTreeMap;
use fieldcraft_lbm::FluidResult;
use fieldcraft_render::{DisplayMesh, Vec3};
use serde::Serialize;
use crate::results::Pick;
use crate::{EngineError, Result, Session};
#[derive(Clone, Debug, Serialize)]
pub struct FaceHeat {
pub body: String,
pub face: String,
pub area: f64,
/// Positive into the wall. None before the first recorded history sample.
pub power: Option<f64>,
pub flux: Option<f64>,
#[serde(skip)]
pub index: u32,
}
fn interpolate(times: &[f64], values: &[f64], time: f64) -> Option<f64> {
if !time.is_finite()
|| times.len() != values.len()
|| times.is_empty()
|| times.iter().chain(values).any(|v| !v.is_finite())
|| times.windows(2).any(|p| p[0] >= p[1])
{
return None;
}
let k = times.partition_point(|t| *t < time);
if times.get(k) == Some(&time) {
return values.get(k).copied();
}
let lo = k.checked_sub(1)?;
let (a, b) = (*times.get(lo)?, *times.get(k)?);
let weight = (time - a) / (b - a);
let value = (1.0 - weight) * values.get(lo)? + weight * values.get(k)?;
value.is_finite().then_some(value)
}
/// All recorded obstacle faces, using their complete tessellated geometric area (not the
/// visible triangle area or a lattice-link count). Missing geometry is an error, never zero.
pub fn faces(s: &Session, r: &FluidResult, pick: Pick) -> Result<Vec<FaceHeat>> {
if s.latest_result(&r.step).is_some_and(|(_, current)| !current) {
return Err(EngineError::Params("wall heat requires current geometry: solve the edited model again before dividing by face area".into()));
}
let time = super::frame(r, pick)?.time;
let mut areas = BTreeMap::new();
let mut output = Vec::new();
for h in r.heat.iter().filter(|h| h.body.is_some()) {
let Some(name) = h.body.as_deref() else { continue };
let body = s
.design
.iter()
.flat_map(|d| &d.bodies)
.find(|b| b.name == name)
.ok_or_else(|| EngineError::Params(format!("wall heat: obstacle `{name}` is no longer in the design")))?;
let index = body
.face_index(&h.name)
.ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` is no longer in the design", h.name)))?;
if let std::collections::btree_map::Entry::Vacant(entry) = areas.entry(name.to_owned()) {
let mesh = body.tessellate((body.size()? * 0.001).max(1e-4))?;
let mut by_face = BTreeMap::<u32, f64>::new();
for (tri, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
let point = |k: u32| mesh.positions.get(k as usize).copied().map(Vec3::from);
let (Some(a), Some(b), Some(c)) = (point(tri[0]), point(tri[1]), point(tri[2])) else { continue };
*by_face.entry(*face).or_default() += 0.5 * (b - a).cross(c - a).len();
}
entry.insert(by_face);
}
let area = areas
.get(name)
.and_then(|a| a.get(&index))
.copied()
.filter(|a| a.is_finite() && *a > 0.0)
.ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` has no finite positive area", h.name)))?;
let power = interpolate(&r.history_times, &h.power, time);
let flux = power.map(|q| q / area).filter(|q| q.is_finite());
output.push(FaceHeat { body: name.into(), face: h.name.clone(), index, area, power, flux });
}
Ok(output)
}
/// One optional value per displayed surface triangle. Imported/unrecorded faces remain plain.
pub fn triangle_values(faces: &[FaceHeat], base: &DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
let by_face: BTreeMap<_, _> = faces.iter().map(|f| ((f.body.as_str(), f.index), f.flux)).collect();
base.surface_tris
.iter()
.map(|t| {
let body = bodies.get(t.body as usize)?;
by_face.get(&(*body, t.face)).copied().flatten()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn wall_heat_uses_face_identity_area_sign_and_frame_in_both_views() {
let mut session = crate::flow::tests::plate_in_a_tank();
let crate::StepResult::Fluid(mut r) = session.latest_result("Flow").unwrap().0.as_ref().clone() else { panic!() };
let names: Vec<String> = session.design.as_ref().unwrap().bodies[0].faces().iter().map(|f| f.name.clone()).collect();
let end = r.frames.last().unwrap().time;
r.history_times = vec![end * 0.5, end];
r.heat = names
.iter()
.enumerate()
.map(|(k, name)| fieldcraft_lbm::HeatHistory {
name: name.clone(),
body: Some("Body1".into()),
power: vec![0.0, if k == 0 { -120.0 } else { 0.0 }],
})
.collect();
for f in &mut r.frames {
f.temperature = vec![20.0; r.grid.len()];
}
let face_values = faces(&session, &r, Pick::default()).unwrap();
assert!(face_values.iter().all(|f| f.area > 0.0));
assert!(face_values.iter().any(|f| (f.area - 400.0).abs() < 1e-8), "40 by 10 mm outer face");
assert!(face_values.iter().any(|f| (f.area - 300.0).abs() < 1e-8), "30 by 10 mm outer face");
assert!(face_values[0].flux.unwrap() < 0.0);
assert!((face_values.iter().map(|f| f.flux.unwrap() * f.area).sum::<f64>() + 120.0).abs() < 1e-10);
assert!(face_values[1..].iter().all(|f| f.flux == Some(0.0)), "adiabatic faces are zero");
session.store_result(crate::StepResult::Fluid(r));
let report = session.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
assert_eq!(report["wall_heat"]["positive"], "into wall");
assert_eq!(report["wall_heat"]["faces"][0]["power"], -120.0);
assert!(report["slice"].is_null(), "wall heat is a surface-only field");
session.execute("results.streamlines", &json!({"show": false})).unwrap();
let (scene, _, shown) = crate::view::scene(&session, &crate::view::Palette::LIGHT);
assert!(shown.contour.as_ref().is_some_and(|c| c.0 == "wall_heat_flux"));
assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color));
let report = session.execute("results.frame", &json!({"frame": 0})).unwrap();
assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().all(|f| f["flux"].is_null()));
assert!(session.execute("results.slice", &json!({"field": "wall_heat_flux"})).is_err());
assert!(session.execute("results.streamlines", &json!({"field": "wall_heat_flux"})).is_err());
}
#[test]
fn histories_interpolate_without_fabricating_initial_heat() {
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 2.0), Some(2.0));
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 1.0), Some(-4.0));
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 0.0), None);
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 4.0), None);
assert_eq!(interpolate(&[1.0, 1.0], &[0.0, 1.0], 1.0), None);
assert_eq!(interpolate(&[1.0], &[f64::NAN], 1.0), None);
assert_eq!(interpolate(&[1.0], &[], 1.0), None);
}
}
+5 -33
View File
@@ -91,8 +91,6 @@ pub enum Field {
Temperature, Temperature,
/// Heat flux magnitude (heat transfer steps, at the end of the step). /// Heat flux magnitude (heat transfer steps, at the end of the step).
HeatFlux, HeatFlux,
/// Signed fluid heat received by an obstacle face per geometric area.
WallHeatFlux,
/// Contact pressure (nonlinear static steps with contact: 0 off the contacts' secondary /// Contact pressure (nonlinear static steps with contact: 0 off the contacts' secondary
/// surfaces). /// surfaces).
ContactPressure, ContactPressure,
@@ -108,7 +106,7 @@ pub enum Field {
} }
impl Field { impl Field {
pub const ALL: [Field; 24] = [ pub const ALL: [Field; 23] = [
Field::Displacement, Field::Displacement,
Field::DisplacementX, Field::DisplacementX,
Field::DisplacementY, Field::DisplacementY,
@@ -127,7 +125,6 @@ impl Field {
Field::StressYz, Field::StressYz,
Field::Temperature, Field::Temperature,
Field::HeatFlux, Field::HeatFlux,
Field::WallHeatFlux,
Field::ContactPressure, Field::ContactPressure,
Field::ContactGap, Field::ContactGap,
Field::ContactStatus, Field::ContactStatus,
@@ -156,7 +153,6 @@ impl Field {
Field::StressYz => "stress_yz", Field::StressYz => "stress_yz",
Field::Temperature => "temperature", Field::Temperature => "temperature",
Field::HeatFlux => "heat_flux", Field::HeatFlux => "heat_flux",
Field::WallHeatFlux => "wall_heat_flux",
Field::ContactPressure => "contact_pressure", Field::ContactPressure => "contact_pressure",
Field::ContactGap => "contact_gap", Field::ContactGap => "contact_gap",
Field::ContactStatus => "contact_status", Field::ContactStatus => "contact_status",
@@ -185,7 +181,6 @@ impl Field {
Field::StressYz => "Stress YZ", Field::StressYz => "Stress YZ",
Field::Temperature => "Temperature", Field::Temperature => "Temperature",
Field::HeatFlux => "Heat flux magnitude", Field::HeatFlux => "Heat flux magnitude",
Field::WallHeatFlux => "Wall heat flux (into wall)",
Field::ContactPressure => "Contact pressure", Field::ContactPressure => "Contact pressure",
Field::ContactGap => "Contact gap", Field::ContactGap => "Contact gap",
Field::ContactStatus => "Contact status (0 open, 1 closed or stick, 2 slip)", Field::ContactStatus => "Contact status (0 open, 1 closed or stick, 2 slip)",
@@ -201,7 +196,7 @@ impl Field {
Field::Velocity => Quantity::Velocity, Field::Velocity => Quantity::Velocity,
Field::Pressure => Quantity::Stress, Field::Pressure => Quantity::Stress,
Field::Temperature => Quantity::Temperature, Field::Temperature => Quantity::Temperature,
Field::HeatFlux | Field::WallHeatFlux => Quantity::HeatFlux, Field::HeatFlux => Quantity::HeatFlux,
Field::ContactGap | Field::ContactStatus => Quantity::None, Field::ContactGap | Field::ContactStatus => Quantity::None,
Field::ContactPressure Field::ContactPressure
| Field::ContactShear | Field::ContactShear
@@ -254,7 +249,6 @@ impl Field {
| Field::Pressure | Field::Pressure
| Field::Temperature | Field::Temperature
| Field::HeatFlux | Field::HeatFlux
| Field::WallHeatFlux
) && !self.is_contact() ) && !self.is_contact()
} }
@@ -279,16 +273,7 @@ impl Field {
e.frames.last().map_or((false, false, false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty(), false, false)) e.frames.last().map_or((false, false, false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty(), false, false))
} }
// Cell fields, drawn by `crate::flow`. // Cell fields, drawn by `crate::flow`.
Kind::Fluid(f) => { Kind::Fluid(_) => return vec![Field::Velocity, Field::Pressure].into_iter(),
let mut fields = vec![Field::Velocity, Field::Pressure];
if !f.frames.is_empty() && f.frames.iter().all(|frame| !frame.temperature.is_empty() && frame.temperature.len() == f.grid.len()) {
fields.push(Field::Temperature);
if f.heat.iter().any(|h| h.body.is_some()) {
fields.push(Field::WallHeatFlux);
}
}
return fields.into_iter();
}
Kind::Heat(_) => return Field::ALL.into_iter().filter(|f| f.is_thermal()).collect::<Vec<_>>().into_iter(), Kind::Heat(_) => return Field::ALL.into_iter().filter(|f| f.is_thermal()).collect::<Vec<_>>().into_iter(),
}; };
Field::ALL Field::ALL
@@ -296,7 +281,6 @@ impl Field {
.filter(move |f| match f { .filter(move |f| match f {
Field::Velocity => velocity, Field::Velocity => velocity,
Field::Pressure => false, Field::Pressure => false,
Field::WallHeatFlux => false,
Field::Temperature => temperature, Field::Temperature => temperature,
Field::HeatFlux => flux, Field::HeatFlux => flux,
f if f.is_contact() => contact, f if f.is_contact() => contact,
@@ -552,7 +536,7 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) ->
Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()), Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()),
}, },
// Per cell (crate::flow::cell_values), not per node. // Per cell (crate::flow::cell_values), not per node.
Field::Pressure | Field::WallHeatFlux => Err(unavailable()), Field::Pressure => Err(unavailable()),
Field::Temperature => match r.kind() { Field::Temperature => match r.kind() {
Kind::Heat(h) => Ok(heat_frame(h, pick)?.1.to_vec()), Kind::Heat(h) => Ok(heat_frame(h, pick)?.1.to_vec()),
Kind::Static(StaticResult { temperature: Some(t), .. }) => Ok(t.clone()), Kind::Static(StaticResult { temperature: Some(t), .. }) => Ok(t.clone()),
@@ -722,18 +706,6 @@ impl Session {
out[k.as_str()] = x; out[k.as_str()] = x;
} }
} }
if v.contour == Some(Field::WallHeatFlux)
&& let Ok(faces) = crate::flow::wall::faces(self, f, v.pick())
{
let values: Vec<f64> = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect();
let end = |(k, value): (usize, f64)| json!({"body": faces.get(k).map(|f| &f.body), "face": faces.get(k).map(|f| &f.face), "value": value});
let range = crate::flow::cell_range(&values).map(|(a, b)| json!({"min": end(a), "max": end(b)}));
out["contour"] = json!({"field": Field::WallHeatFlux, "label": Field::WallHeatFlux.label(), "unit": Quantity::HeatFlux.unit(self.model.units()), "range": range});
if v.range_min.is_some() || v.range_max.is_some() {
out["contour"]["legend"] = json!({"min": v.range_min, "max": v.range_max});
}
out["wall_heat"] = json!({"faces": faces, "positive": "into wall", "history": "linear interpolation between recorded samples; no extrapolation", "unrecorded": "uncoloured"});
}
} }
Kind::Heat(h) => { Kind::Heat(h) => {
out["heat_flows"] = heat_flows(h); out["heat_flows"] = heat_flows(h);
@@ -801,7 +773,7 @@ mod tests {
} }
let (modes, _) = s.latest_result("Modes").unwrap(); let (modes, _) = s.latest_result("Modes").unwrap();
assert_eq!(Field::available(modes).count(), 4); assert_eq!(Field::available(modes).count(), 4);
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 10, "all but velocity, pressure, the thermal and the contact fields"); assert_eq!(Field::available(bend).count(), Field::ALL.len() - 9, "all but velocity, pressure, the thermal and the contact fields");
assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err()); assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err());
assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err()); assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err());
assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len()); assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len());
-10
View File
@@ -18,8 +18,6 @@
use fieldcraft_geometry::Shape; use fieldcraft_geometry::Shape;
use serde_json::{Value, json}; use serde_json::{Value, json};
mod heated_flow;
/// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`). /// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`).
pub struct Sample { pub struct Sample {
pub name: &'static str, pub name: &'static str,
@@ -94,14 +92,6 @@ pub const SAMPLES: &[Sample] = &[
prepare: None, prepare: None,
script: |_| flow_past(), script: |_| flow_past(),
}, },
Sample {
name: "heated-flow",
label: "Heated Flow Past a Bracket",
about: "Cool air passing an 80 °C bracket: temperature slices and signed wall heat, a small transient demonstration",
design: Some(UPRIGHT_BRACKET),
prepare: None,
script: heated_flow::script,
},
Sample { Sample {
name: "heated-bracket", name: "heated-bracket",
label: "Heated Bracket", label: "Heated Bracket",
-58
View File
@@ -1,58 +0,0 @@
//! A small transient heated-flow demonstration using the original upright bracket.
//! Air at 20 °C passes an 80 °C bracket; no buoyancy or conjugate solid conduction.
use fieldcraft_geometry::Shape;
use serde_json::{Value, json};
pub(super) fn script(bodies: &[Shape]) -> Value {
let walls: Vec<Value> = bodies
.iter()
.filter(|b| b.name == "Bracket")
.flat_map(|b| {
b.faces().iter().map(move |f| {
json!({
"face": {"body": b.name, "face": f.name},
"condition": {"type": "temperature", "value": 80.0}
})
})
})
.collect();
let [density, viscosity] = super::AIR;
json!([
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
{"command": "model.material.create", "params": {"name": "Air", "density": density,
"fluid": {"type": "newtonian", "viscosity": viscosity},
"thermal": {"conductivity": 0.026, "specific_heat": 1.005e9}}},
{"command": "model.fluid_domain.create", "params": {
"name": "WarmAir", "material": "Air", "min": [-30, -20, 0], "max": [100, 60, 70], "spacing": 5,
"faces": {"x_min": {"type": "velocity", "velocity": [1, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
"y_min": {"type": "slip"}, "y_max": {"type": "slip"}, "z_min": {"type": "wall"}, "z_max": {"type": "slip"}},
"obstacles": ["Bracket"], "initial_velocity": [1, 0, 0],
"thermal": {"initial_temperature": 20, "surfaces": walls,
"faces": {"x_min": {"type": "temperature", "value": 20}}}
}},
{"command": "model.step.create", "params": {"name": "HeatedFlow", "procedure": {
"type": "fluid", "domain": "WarmAir", "steps": 180, "frames": 6
}}}
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heated_flow_sample_solves_and_exposes_temperature_and_wall_heat() {
let mut s = crate::Session::default();
s.execute("file.sample", &json!({"name": "heated-flow"})).unwrap();
s.execute("solve.fluid", &json!({"backend": "cpu"})).unwrap();
let report = s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
let max = report["contour"]["range"]["max"]["value"].as_f64().unwrap();
let min = report["contour"]["range"]["min"]["value"].as_f64().unwrap();
assert!(max > 20.1 && max <= 80.01 && min >= 19.99, "{report}");
let report = s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
assert_eq!(report["contour"]["unit"], "mW/mm²");
assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().any(|f| f["power"].as_f64().unwrap_or(0.0) < 0.0));
assert_eq!(report["frames"].as_array().unwrap().len(), 7);
}
}
+1 -2
View File
@@ -511,8 +511,7 @@ fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) ->
// The part as it is, with the flow drawn through and around it. // The part as it is, with the flow drawn through and around it.
let mut scene = model_scene(&base, p); let mut scene = model_scene(&base, p);
let mut bounds = base.bounds; let mut bounds = base.bounds;
let names: Vec<&str> = bodies.iter().map(String::as_str).collect(); if let Ok(fp) = crate::flow::plot(view, result, &s.traced) {
if let Ok(fp) = crate::flow::plot_with_surfaces(view, result, &s.traced, s, &base, &names) {
if let Some(c) = &fp.contour { if let Some(c) = &fp.contour {
let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v); let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v);
shown.contour = Some((c.field.id().to_string(), cell(c.min), cell(c.max))); shown.contour = Some((c.field.id().to_string(), cell(c.min), cell(c.max)));
+1 -2
View File
@@ -1130,8 +1130,7 @@ impl ModelApp {
let still = key.as_ref().map(|k| PlotKey { frame: None, ..k.clone() }); let still = key.as_ref().map(|k| PlotKey { frame: None, ..k.clone() });
if self.prepared.0 != still { if self.prepared.0 != still {
let pick = key.as_ref().map(|k| k.pick).unwrap_or_default(); let pick = key.as_ref().map(|k| k.pick).unwrap_or_default();
let bodies: Vec<&str> = self.pick.names.iter().map(|n| n.name.as_str()).collect(); self.prepared = (still, results::prepare(&self.session, &self.display.1, pick).unwrap_or_default());
self.prepared = (still, results::prepare(&self.session, &self.display.1, pick, &bodies).unwrap_or_default());
} }
let frame = key.as_ref().and_then(|k| k.frame); let frame = key.as_ref().and_then(|k| k.frame);
let p = key.as_ref().and_then(|_| results::plot(&self.session, &self.display.1, frame, &self.prepared.1)); let p = key.as_ref().and_then(|_| results::plot(&self.session, &self.display.1, frame, &self.prepared.1));
+9 -63
View File
@@ -114,9 +114,6 @@ impl Plot {
/// Where a contour extreme is: its node's position, or a fluid step's cell centre. /// Where a contour extreme is: its node's position, or a fluid step's cell centre.
pub fn extreme_position(&self, label: u32) -> Option<Vec3> { pub fn extreme_position(&self, label: u32) -> Option<Vec3> {
if self.view.contour == Some(Field::WallHeatFlux) {
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
}
match self.result.kind() { match self.result.kind() {
Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))), Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))),
_ => self.node_position(label), _ => self.node_position(label),
@@ -178,12 +175,10 @@ pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &Display
/// The part of the session's results plot that depends on what is drawn (`pick`), not on the /// The part of the session's results plot that depends on what is drawn (`pick`), not on the
/// animation's deformation multiplier: the deformation scale and the contour values. /// animation's deformation multiplier: the deformation scale and the contour values.
pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick, bodies: &[&str]) -> Option<Prepared> { pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option<Prepared> {
let (view, result, _) = shown(session)?; let (view, result, _) = shown(session)?;
if matches!(result.kind(), Kind::Fluid(_)) { if matches!(result.kind(), Kind::Fluid(_)) {
let mut view = view.clone(); let flow = fieldcraft_engine::flow::plot(view, result, &session.traced).ok();
view.frame = pick.frame;
let flow = fieldcraft_engine::flow::plot_with_surfaces(&view, result, &session.traced, session, base, bodies).ok();
let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v); let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v);
let contour = flow let contour = flow
.as_ref() .as_ref()
@@ -341,8 +336,7 @@ pub(crate) mod tests {
fn plot_at(s: &Session, base: &DisplayMesh, frame: Option<u64>) -> Option<Plot> { fn plot_at(s: &Session, base: &DisplayMesh, frame: Option<u64>) -> Option<Plot> {
let (view, result, _) = shown(s)?; let (view, result, _) = shown(s)?;
let bodies: Vec<&str> = s.design.iter().flat_map(|d| &d.bodies).map(|b| b.name.as_str()).collect(); plot(s, base, frame, &prepare(s, base, pick(view, result, frame))?)
plot(s, base, frame, &prepare(s, base, pick(view, result, frame), &bodies)?)
} }
/// The sample cantilever, solved (static step `Load`, frequency step `Modes`). /// The sample cantilever, solved (static step `Load`, frequency step `Modes`).
@@ -418,39 +412,22 @@ pub(crate) mod tests {
} }
/// A 40 × 30 × 10 mm plate in a tank of 5 mm cells, a liquid flowing past it along x, solved. /// A 40 × 30 × 10 mm plate in a tank of 5 mm cells, a liquid flowing past it along x, solved.
fn plate_in_a_tank(thermal: bool) -> Session { fn plate_in_a_tank() -> Session {
use fieldcraft_geometry::sample::{Plate, plate_design}; use fieldcraft_geometry::sample::{Plate, plate_design};
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}-{thermal}", std::process::id())); let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plate.solidcraft"); let path = dir.join("plate.solidcraft");
std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap(); std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap();
let mut s = Session::default(); let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap(); s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
let mut script = json!([ s.run_script(&json!([
{"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}}, {"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}},
{"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"], {"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"],
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}}, "faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}},
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}}, {"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}},
]); ]))
if thermal { .unwrap();
script[0]["params"]["thermal"] = json!({"conductivity": 1.0, "specific_heat": 1e8});
let faces: Vec<_> = s.design.as_ref().unwrap().bodies[0]
.faces()
.iter()
.map(|f| {
json!({
"face": {"body": "Body1", "face": f.name}, "condition": {"type": "temperature", "value": 60.0}
})
})
.collect();
script[1]["params"]["thermal"] =
json!({"initial_temperature": 20.0, "surfaces": faces, "faces": {"x_min": {"type": "temperature", "value": 20.0}}});
}
s.run_script(&script).unwrap();
if thermal {
s.execute("model.units", &json!({"system": "mm-t-s-N-MPa"})).unwrap();
}
s.execute("solve.fluid", &json!({})).unwrap(); s.execute("solve.fluid", &json!({})).unwrap();
s s
} }
@@ -459,7 +436,7 @@ pub(crate) mod tests {
/// slice and on the part, with its legend, and streamlines (#193). /// slice and on the part, with its legend, and streamlines (#193).
#[test] #[test]
fn a_fluid_step_keeps_the_part_and_draws_the_flow() { fn a_fluid_step_keeps_the_part_and_draws_the_flow() {
let mut s = plate_in_a_tank(false); let mut s = plate_in_a_tank();
let (base, _) = fieldcraft_engine::view::display(&s); let (base, _) = fieldcraft_engine::view::display(&s);
assert!(!base.surface_tris.is_empty()); assert!(!base.surface_tris.is_empty());
let p = plot_at(&s, &base, None).unwrap(); let p = plot_at(&s, &base, None).unwrap();
@@ -480,37 +457,6 @@ pub(crate) mod tests {
assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0))); assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0)));
} }
#[test]
fn heated_flow_fields_have_units_colours_and_face_markers() {
let mut s = plate_in_a_tank(true);
let (base, _) = fieldcraft_engine::view::display(&s);
s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
assert_eq!(p.contour.as_ref().unwrap().field, Field::Temperature);
assert!(Field::Temperature.title(&p.result, p.units).contains("°C or K"));
assert!(Field::available(&p.result).any(|f| f == Field::Temperature));
assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
let last = p.frame().unwrap().2 - 1;
s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert_eq!(plot_at(&s, &base, None).unwrap().contour.as_ref().unwrap().max.1, 20.0, "the frame selector changes the temperature shown");
s.execute("results.frame", &json!({"frame": last})).unwrap();
s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
let c = p.contour.as_ref().unwrap();
assert_eq!(c.field, Field::WallHeatFlux);
assert_eq!(c.field.unit(&p.result, p.units).as_deref(), Some("mW/mm²"));
assert!(c.min.1 < 0.0, "hot wall gives heat to fluid");
assert!(p.extreme_position(c.min.0).is_some());
assert!(p.flow.as_ref().unwrap().slice.is_none());
for t in [Tokens::light(), Tokens::dark()] {
let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t);
assert_eq!(scene.tris.len(), base.surface_tris.len());
assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color));
}
s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert!(plot_at(&s, &base, None).unwrap().contour.is_none());
}
#[test] #[test]
fn modes_swing_and_static_steps_grow_when_animated() { fn modes_swing_and_static_steps_grow_when_animated() {
let mut s = solved_cantilever(); let mut s = solved_cantilever();
+3 -19
View File
@@ -41,8 +41,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
probe(app, &plot, &painter, rect, t); probe(app, &plot, &painter, rect, t);
let mut focus = None; let mut focus = None;
for (i, (line, node)) in extremes.into_iter().enumerate() { for (i, (line, node)) in extremes.into_iter().enumerate() {
let tip = if plot.is_fluid() { "Centre this field extreme".into() } else { format!("Show and probe node {node}") }; let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(format!("Show and probe node {node}"));
let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(tip);
if r.hovered() { if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} }
@@ -87,7 +86,6 @@ fn title(plot: &Plot) -> String {
match (&plot.contour, plot.mode()) { match (&plot.contour, plot.mode()) {
(Some(l), _) => l.field.title(&plot.result, plot.units), (Some(l), _) => l.field.title(&plot.result, plot.units),
(None, Some(_)) => "Mode shape (normalised)".into(), (None, Some(_)) => "Mode shape (normalised)".into(),
(None, None) if plot.is_fluid() => plot.view.contour.map_or_else(|| "Flow".into(), |f| f.title(&plot.result, plot.units)),
(None, None) => "Deformed shape".into(), (None, None) => "Deformed shape".into(),
} }
} }
@@ -476,23 +474,9 @@ fn deformation_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str,
fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)>) { fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)>) {
use fieldcraft_engine::flow::{Axis, slice_position}; use fieldcraft_engine::flow::{Axis, slice_position};
let (Kind::Fluid(f), Some(flow)) = (plot.result.kind(), plot.flow.as_ref()) else { return }; let (Kind::Fluid(f), Some(flow)) = (plot.result.kind(), plot.flow.as_ref()) else { return };
if plot.view.contour == Some(Field::WallHeatFlux) {
ui.label("Wall heat");
ui.label("Positive into wall; face average");
ui.end_row();
if plot.contour.is_none() {
ui.label("History");
ui.label("No wall heat recorded at this frame");
ui.end_row();
}
}
let view = &flow.view; let view = &flow.view;
ui.label("Slice"); ui.label("Slice");
ui.horizontal(|ui| { ui.horizontal(|ui| {
if plot.view.contour == Some(Field::WallHeatFlux) {
ui.label("Hidden for wall heat");
return;
}
let shown = view.slice.as_ref().map_or("None", |s| s.axis.id()); let shown = view.slice.as_ref().map_or("None", |s| s.axis.id());
egui::ComboBox::from_id_salt("fc_results_slice").selected_text(format!("Normal {}", shown.to_uppercase())).width(90.0).show_ui(ui, |ui| { egui::ComboBox::from_id_salt("fc_results_slice").selected_text(format!("Normal {}", shown.to_uppercase())).width(90.0).show_ui(ui, |ui| {
if ui.selectable_label(view.slice.is_none(), "None").clicked() { if ui.selectable_label(view.slice.is_none(), "None").clicked() {
@@ -520,14 +504,14 @@ fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)
ui.end_row(); ui.end_row();
ui.label("Obstacles"); ui.label("Obstacles");
let mut surface = view.surface; let mut surface = view.surface;
if ui.checkbox(&mut surface, "Coloured").on_hover_text("Colour the part by the selected fluid field or wall heat").changed() { if ui.checkbox(&mut surface, "Coloured").on_hover_text("Colour the part by the field in the fluid beside it").changed() {
run.push(("results.surface", json!({"show": surface}))); run.push(("results.surface", json!({"show": surface})));
} }
ui.end_row(); ui.end_row();
ui.label("Streamlines"); ui.label("Streamlines");
ui.horizontal(|ui| { ui.horizontal(|ui| {
let mut on = view.streamlines.is_some(); let mut on = view.streamlines.is_some();
if ui.checkbox(&mut on, "").on_hover_text("Streamlines from the inlet, coloured by speed or temperature when selected").changed() { if ui.checkbox(&mut on, "").on_hover_text("Streamlines from the inlet, coloured by speed when the legend shows velocity").changed() {
run.push(("results.streamlines", json!({"show": on}))); run.push(("results.streamlines", json!({"show": on})));
} }
if let Some(sl) = &view.streamlines { if let Some(sl) = &view.streamlines {
-1
View File
@@ -20,7 +20,6 @@ const THUMBNAILS: &[(&str, &[u8])] = &[
("heated-bracket", include_bytes!("../thumbnails/heated-bracket.png")), ("heated-bracket", include_bytes!("../thumbnails/heated-bracket.png")),
("bracket-hex", include_bytes!("../thumbnails/bracket-hex.png")), ("bracket-hex", include_bytes!("../thumbnails/bracket-hex.png")),
("flow-past", include_bytes!("../thumbnails/flow-past.png")), ("flow-past", include_bytes!("../thumbnails/flow-past.png")),
("heated-flow", include_bytes!("../thumbnails/heated-flow.png")),
("cantilever", include_bytes!("../thumbnails/cantilever.png")), ("cantilever", include_bytes!("../thumbnails/cantilever.png")),
("impact", include_bytes!("../thumbnails/impact.png")), ("impact", include_bytes!("../thumbnails/impact.png")),
("parts", include_bytes!("../thumbnails/parts.png")), ("parts", include_bytes!("../thumbnails/parts.png")),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

-25
View File
@@ -139,28 +139,3 @@ factorization's supernodes, the tet mesher's refinement and improvement passes,
attempts) and stops with `cancelled`, leaving the model unchanged. One job runs at a attempts) and stops with `cancelled`, leaving the model unchanged. One job runs at a
time; another long command meanwhile is refused, naming the job. The MCP server's `solve` and time; another long command meanwhile is refused, naming the job. The MCP server's `solve` and
`mesh` tools wait for the reply. `mesh` tools wait for the reply.
## Heated flow results
A solved fluid step with temperature in every saved frame offers `temperature` in the
Results field list and through `results.contour`, `results.slice`, `results.surface` and
`results.streamlines` (`{"field":"temperature"}`). Slices and obstacle surfaces sample
fluid-cell temperature; streamline colours sample temperature along the velocity traces.
The legend and existing `solve.probe` use the model's temperature scale, without conversion.
An isothermal step hides the field and rejects an explicit request with an explanation.
The frame slider and `results.frame` select the field's time, including the initial frame.
`results.surface {"field":"wall_heat_flux","frame":10}` shows the signed heat received
by each obstacle face divided by that face's complete tessellated geometric area. Positive
values heat the wall; a hot wall heating the fluid is negative. This is a face average,
not a resolved local distribution. The legend uses the model's consistent power/area unit:
for mm–tonne–second it is mW/mm² (not W/mm²); other systems follow their own units.
Wall heat uses the recorded face-group histories, linearly interpolated at the shown frame
only between recorded times. The initial frame precedes the history and remains uncoloured;
no heat is invented or extrapolated. Recorded adiabatic faces are zero. Unrecorded/imported
faces remain in their own colour. The command reply includes `wall_heat.faces` with body,
face, area, power and flux (null when unavailable), plus the sign and sampling convention.
This surface-only field suppresses the slice and leaves streamlines plain; selecting a cell
field restores the saved slice settings. `results.contour` also selects the wall field and
controls its bands/range. The desktop and headless picture use the same mapping and palette.
+1 -20
View File
@@ -6,7 +6,7 @@ press **Solve** (`solve.run`). Each opens fully defined — geometry, mesh, mate
conditions, loads and steps — in mm, t, s, N and MPa, and opens and solves within half a minute. The geometry is our conditions, loads and steps — in mm, t, s, N and MPa, and opens and solves within half a minute. The geometry is our
own: SolidCraft scripts run when the sample opens (a recipe from [`tests/corpus`](../tests/corpus) own: SolidCraft scripts run when the sample opens (a recipe from [`tests/corpus`](../tests/corpus)
or one written in `crates/engine/src/sample.rs`). Each sample has a test in or one written in `crates/engine/src/sample.rs`). Each sample has a test in
`crates/engine/src/sample.rs` or its modules that opens it, checks it clean, solves it and holds its result to `crates/engine/src/sample.rs` that opens it, checks it clean, solves it and holds its result to
the check given below. the check given below.
## Drop test (`drop-test`) ## Drop test (`drop-test`)
@@ -200,22 +200,3 @@ SolidCraft geometry with persistent face names, swept all-hex (hex8 of 4 mm, inc
clamped at `F2:start` and loaded with 1000 N down through a kinematic coupling to `F2:top`. clamped at `F2:start` and loaded with 1000 N down through a kinematic coupling to `F2:top`.
**Look at:** the bending stress in the flanges, highest at the clamp; the tip moves 0.18 mm down **Look at:** the bending stress in the flanges, highest at the clamp; the tip moves 0.18 mm down
(beam theory with shear: 0.184 mm). (beam theory with shear: 0.184 mm).
## Heated flow past a bracket (`heated-flow`)
Air at 20 °C enters at 1 mm/s and passes the original upright bracket, whose faces are
held at 80 °C. The 130 × 80 × 70 mm domain has 5 mm cells (5,824 cells), a pressure outlet,
slip sides and top, and a no-slip floor. There are 180 steps and six saved output intervals,
plus the initial frame. Open `file.sample {"name":"heated-flow"}` and solve on CPU or GPU.
The slow prescribed flow keeps this coarse lattice away from its small-relaxation-time limit.
**Look at:** Results → Temperature, or `results.slice {"field":"temperature"}`, for the
warming air. `results.surface {"field":"wall_heat_flux"}` shows heat received by each
bracket face per area: the hot wall heating the air is negative. In this model's mm–tonne–second
units the wall flux is mW/mm². The initial frame has no recorded wall heat and stays uncoloured.
**Check:** the fluid warms above its inlet temperature while staying below the held wall
temperature (with a small numerical tolerance); recorded hot walls give heat to the fluid.
This coarse, short transient is a quick display demonstration, not a converged convection
benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in
`crates/engine/src/sample/heated_flow.rs` and its gallery registration.
+1 -1
View File
@@ -160,7 +160,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Plots | Path Plot — Plot a quantity along a path | coming soon (M9) | | | Plots | Path Plot — Plot a quantity along a path | coming soon (M9) | |
| Plots | Cut — Section cuts through the model | coming soon (M9) | | | Plots | Cut — Section cuts through the model | coming soon (M9) | |
| Plots | Display Group — Show and hide parts of the model | coming soon (M9) | | | Plots | Display Group — Show and hide parts of the model | coming soon (M9) | |
| Plots | Streamlines — A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area | ready | `results.streamlines`, `results.slice`, `results.surface` | | Plots | Streamlines — A fluid step's streamlines coloured by speed, its velocity or pressure on a slice, and the obstacles coloured by the flow | ready | `results.streamlines`, `results.slice`, `results.surface` |
| Files | Export VTU — Write a solved step as VTK unstructured grids for ParaView | ready | `results.export.vtu` | | Files | Export VTU — Write a solved step as VTK unstructured grids for ParaView | ready | `results.export.vtu` |
| Files | Read Results — Read results in public formats | coming soon (M9) | | | Files | Read Results — Read results in public formats | coming soon (M9) | |
| Files | Convergence — Mesh convergence studies | coming soon (M9) | | | Files | Convergence — Mesh convergence studies | coming soon (M9) | |
+355
View File
@@ -0,0 +1,355 @@
# FieldCraft Remote: a distributed, asynchronous simulation backend
**Status:** design proposal, October 2026, open for feedback. Try the flow in the [clickable prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83).
## Summary
FieldCraft Remote lets you submit a solve from the desktop app, the browser, the CLI or an AI agent, disconnect, and collect the results later from any device. A stateless switchboard, the **hub**, connects your clients to your **workers**: the same headless FieldCraft engine the CLI already runs. One worker also acts as the cluster's coordinator and keeps the job log. Results stay on the worker that made them, and the client claims them through the hub whenever it next connects.
The hub is a service we host at `hub.solidcraft.io`, and the same binary is self-hostable. There are no accounts: a **cluster** is a secret seed passphrase. Every machine and client given the passphrase joins the same cluster and sees its jobs, and everything that passes through the hub is encrypted with a key derived from it, so the hosted hub never sees your models or results. The hub stores nothing at all, not even a database: it keeps only live connections in memory, and a restart costs no work. Jobs live on your coordinator, and results stay on your workers until a client claims them.
The compute is always your own. What changes between setups is only how many workers join:
- **One workstation.** Run `fieldcraft worker --join "<passphrase>"` on a machine with a GPU. Submit from the laptop or the browser anywhere; the workstation picks the jobs up.
- **A fleet.** Run the same command on every spare machine. Each advertises its cores, memory and GPUs and takes the jobs that fit.
- **Kubernetes.** A Helm chart runs worker pods per node pool (CPU, GPU, high memory), with the passphrase as a Kubernetes Secret, scaled on the cluster's queue depth.
Self-hosting means running `fieldcraft hub serve` on your own server and pointing clients at it with `--hub <url>`; nothing else changes.
Asynchronous work is the design centre: jobs outlive the client session, every state change is recorded, and clients sync results rather than stream them. You can check in whenever you reconnect, from any device, and it fetches what finished while you were away.
## Why: what the AIO cooler study showed
A realistic study is a few hours of independent solves stuck in a serial queue on one laptop. On 17 October we modelled a 240 mm AIO cooler: a lattice Boltzmann (LBM) sweep of the radiator, then a steady heat solve per cold plate for each fan setup, on a Core Ultra 7 155H with its integrated Arc GPU.
| Stage | Size | Wall time | Where it ran |
| --- | --- | --- | --- |
| Tet mesh, skived cold plate | 796k tets, single-threaded | 214 s | CPU, 1 core |
| Tet mesh, pin-fin cold plate | 579k tets around 261 pins | 15 min | CPU, 1 core |
| Steady heat solve, one step | 184k dofs, 133M factor entries | 60 s | CPU, 16 threads |
| LBM radiator cell, 1.6 m/s | 4.4M cells, 17k steps | 1086 s | Arc iGPU |
| LBM radiator cell, 0.4 m/s | 4.4M cells, 33k steps | 1732 s | Arc iGPU |
| LBM runs lost to instability or a wrong time step | 5 runs | about 1.5 h | Arc iGPU |
What hurt:
- **Independent work ran serially.** The LBM velocities and the 3 cold plates × 3 fan setups depend on nothing but their inputs, yet they ran one after another on one GPU and one CPU.
- **The laptop had to stay up.** Every solve lived inside a local MCP session; closing the lid would have lost hours.
- **Orphans and hand-made caches.** Stopping a run left a headless `fieldcraft-cli` still holding the GPU, unnoticed for 48 minutes. Each script cached its own results in JSON next to the models.
- **Feedback came only at the end.** A bad LBM time step showed up as an energy-balance failure after 33 minutes, not as a trend in the first few.
A cluster node with a discrete GPU runs these LBM cells many times faster than an iGPU. Ten workers would finish the 9-case sweep in about the time of its slowest case.
## Goals and non-goals
**Goals**
1. **Run anywhere, same API.** One hub binary serves a single workstation, a fleet, or a Kubernetes cluster. Clients cannot tell which.
2. **Asynchronous by default.** Submitting returns a job id at once. Jobs survive client disconnects, hub restarts and worker loss. Results wait on the workers until a client claims them.
3. **Every client.** The desktop app, the browser app, `fieldcraft-cli` and MCP agents submit, watch, cancel and fetch through the same calls.
4. **Bit-for-bit results.** A remote solve gives the same numbers as a local one, because the worker is the same engine and the solvers do not depend on thread count.
5. **Studies, not just solves.** Parameter sweeps and chained stages (radiator → coolant temperature → cold plate) are first-class, run in parallel, and reuse finished work by content hash.
6. **Right hardware per stage.** A mesh job goes to a CPU worker, an LBM job to a GPU worker, and a big factorisation to a high-memory node.
7. **Visible progress, early failure.** Live progress, convergence histories and engine warnings stream to the hub. A study can stop a job that is diverging.
**Non-goals**
- Distributing one solve across nodes (MPI, domain decomposition). Each job runs on one machine; the speed-up comes from running many jobs at once.
- A general HPC scheduler. On a cluster the hub hands scheduling to Kubernetes; on a workstation it runs a simple local queue.
- Selling compute. The hosted hub only relays encrypted bytes; solves run, and results stay, on machines the cluster's owner joined.
## What FieldCraft already gives us
Most of the hard parts exist; this proposal adds a transport and a queue, not a new engine.
| Existing piece | Where | What the remote backend uses it for |
| --- | --- | --- |
| Command engine: every capability is a command with a JSON Schema and declared `requires` | `crates/engine`, `fieldcraft-cli commands --json` | The job payload is a list of commands, validated before it is queued |
| `.fieldcraft` package: model, mesh, journal, SHA-256 per part, design referenced by hash | `docs/formats/fieldcraft.md` | The job's input bundle, content-addressed and deduplicated |
| Same bytes for the same model on every platform | `fieldcraft-io` | Content hashes are stable, so the cache key is reliable |
| Results independent of thread count (`fieldcraft-parallel`) | `docs/adr/0001` | A remote solve matches a local one |
| Jobs with progress and cancellation (`job.status`, `job.wait`, `job.cancel`) | `docs/control-protocol.md` | Progress for the hub, cancellation that stops at the next increment |
| Solver caps: `max_dofs`, `max_cells`, `max_cell_updates`, `max_factor_entries`, `threads` | `solve.*` commands | Resource estimates for scheduling, and hard limits per worker |
| GPU LBM backend (`backend: "gpu"`, `FIELDCRAFT_GPU`) | `fieldcraft-lbm-gpu` | GPU jobs land on GPU nodes; the CPU backend is the deterministic fallback |
| VTU/PVD export, CSV probes, heat and force histories | `results.export.*`, `solve.probe` | The result artefacts clients fetch and plot |
| MCP server with path confinement (`--automation-read-root`, `--automation-write-root`) | `fieldcraft-cli mcp` | The worker's sandbox: a job reads only its inputs and writes only its outputs |
| Browser build with the same engine (wasm, WebGPU) | `apps/fieldcraft-web` | A browser client for small solves and for viewing results; large ones go to the hub |
What is missing: a result format the app can open without re-solving, since a saved `.fieldcraft` holds the model but not its results. Today a remote result would have to come back as VTU, or be solved again. The first milestone adds a results part to the package (see Rollout).
## Architecture
There are three parts: clients that submit and claim, a hub that only switches, and your workers, one of which coordinates. The hub never runs a solve, never holds a key and never writes to disk.
```mermaid
flowchart LR
subgraph clients["Your clients"]
D["Desktop app"]
B["Browser app"]
C["fieldcraft CLI"]
M["MCP agents"]
end
subgraph hub["Hub: in memory only (hub.solidcraft.io or self-hosted)"]
S["Switchboard: checks keys, routes by cluster"]
P["Presence: who is connected right now"]
R["Relay: forwards encrypted frames"]
Q["Short buffer: 60 s and 4 MB per cluster"]
end
subgraph workers["Your workers"]
Co["Coordinator (one worker): job log, scheduler, cache index"]
W["Workstation: CPU slots, 1 per GPU"]
F["Fleet machines: fieldcraft worker --join"]
K["Kubernetes pools: cpu, gpu, highmem; holder pool keeps results"]
end
D --> hub
B --> hub
C --> hub
M --> hub
Co --> hub
W --> hub
F --> hub
K --> hub
```
Every arrow is an outbound connection to the hub; the hub writes nothing to disk and forgets all on restart. Jobs, logs and results live on your machines, encrypted with k\_data; the hub only forwards them.
- **Hub, a switchboard.** In memory it holds only who is connected in each cluster, plus a buffer of at most 60 s and 4 MB per cluster for a member that is reconnecting. It checks each connection's signature against the cluster key it presents, forwards encrypted frames between members, and forgets everything when it restarts. Members reconnect within seconds and nothing is lost, because nothing lived there.
- **Coordinator.** A role only a worker can take, never an interactive session (the desktop app, the browser, the CLI or an MCP agent). By default it is the worker joined with `--coordinator`: usually the always-on workstation, or a pod in the Kubernetes cluster. It keeps the job log in SQLite, encrypted at rest. It also schedules leases by needs, resolves study dependencies, tracks which worker holds which result, sends notifications and serves the queue metric for KEDA.
- **Replicas and failover.** Every worker keeps a copy of the job log, streamed from the coordinator. If the coordinator is gone for 60 s, the online worker with the lowest device id takes over from its copy and announces a new term. Log entries carry the term, so a returning old coordinator steps down instead of writing.
- **Worker.** `fieldcraft worker` is the headless engine plus a loop: ask the coordinator for a lease that fits its slots, fetch and decrypt the inputs, run the commands, stream progress, keep the encrypted outputs on its disk, report done. The heartbeat runs on its own thread; a worker silent for 60 s loses its lease and the job is requeued.
- **Results on workers.** Each job's outputs stay on its worker's disk, encrypted and named by their keyed ids. A result is a set of ids, so a client claims only the files it lacks.
**Claiming results.** Result files never rest at the hub; it only relays them.
- **Live claim.** If a cluster client is connected when a job finishes, the worker streams the outputs to it through the hub. The client checks each keyed id and acknowledges.
- **Later claim.** A client that connects later asks the coordinator for the job's results, and the holding worker streams them through the hub.
- **After a claim.** Once a cluster client has acknowledged every output, the worker may drop them. It keeps claimed results in a local cache, oldest out first under a disk quota, for re-delivery and study cache hits. Unclaimed results are kept 7 days.
- **Small records in the job log.** Specs, inputs up to 64 MB, the summary, the log and progress histories live in the coordinator's job log and its replicas. Every device sees a job's outcome even while its worker is offline.
- **Studies.** A job that consumes another job's outputs runs on that job's worker when it fits there; otherwise the outputs go from worker to worker through the hub.
- **A worker goes away.** Results on an offline worker wait until it returns. If a worker's disk is lost, its unclaimed jobs run again, with the same result bit for bit.
**What this costs.** A submit is complete once the coordinator acknowledges it, and from then on the client can disconnect. If no coordinator is online, the client keeps the job and submits it as soon as one appears; it never takes the role itself. A cluster with a workstation that is always on never notices; a cluster of laptops only would. The hub needs no disk and no database, only memory for its connections and bandwidth: every result byte crosses it once per claim.
**Hub restarts.** A restart, a redeploy or a crash of the hub is an ordinary event that costs no work. Every part is built so that "the hub does not know this" means "not yet", never "gone".
- **Workers keep running.** A job in progress carries on with the hub down. Its progress events, heartbeats and finished outputs queue on the worker's disk, numbered, and are replayed in order when it reconnects.
- **Leases freeze, not expire.** The coordinator counts a worker's silence only while the coordinator itself is connected, and gives every worker a 120 s grace period after any reconnect of its own. A hub outage therefore never requeues a running job.
- **The hub says it just restarted.** For its first 120 s the hub answers a request for a cluster it has not seen with `warming` and its uptime, not `unknown`. Clients and workers back off and retry; reconnects are spread with random jitter so a restart does not bring a stampede.
- **Clients own their submissions.** A client generates each job's id itself and keeps a local record of every job it sent: id, spec, cache key, last known state. When the coordinator cannot be reached, the app shows those jobs as "waiting for the cluster" with their last known state, not as failed.
- **Resubmitting is always safe.** Submit is idempotent by job id. If the coordinator, once back, reports a job id as never seen (for example, it was lost with a disk before it replicated), the client resubmits the same id. A worker that already holds a finished result for that id or its cache key answers at once.
- **Nothing waits on the hub's memory.** The 60 s buffer only smooths brief reconnects. Anything that must survive lives on a member and is resent from there.
## Job lifecycle
Submitting returns a job id at once; from then on the coordinator owns the job, and the client only checks in.
```mermaid
stateDiagram-v2
direction LR
[*] --> Submitted: client makes the job id
Submitted --> Queued: coordinator acknowledges
Queued --> Done: cache hit, a worker still holds the result
Queued --> Running: leased to a worker that fits
Running --> Queued: worker lost for 60 s, requeued
Running --> Done: outputs held by its worker
Running --> Failed: error, stop rule or cancel
Done --> Claimed: client claims at its next check-in
```
Your laptop can be offline anywhere between Submitted and Claimed; the job carries on.
1. **Submit.** The client validates the commands against the engine's schemas and `requires`, gives the job its own id, encrypts the spec and sends it with its inputs to the coordinator. The coordinator writes it to the job log and acknowledges; from then on the client can disconnect.
2. **Queue.** The job waits until its inputs exist (a study's earlier jobs), then checks the cache. A cache hit goes straight to Done.
3. **Lease and run.** The coordinator leases the job to a worker whose free slots fit its needs; the worker heartbeats every 10 s. It runs the commands and streams the engine's progress line, warnings and chosen histories (heat, force, residuals) as small encrypted events.
4. **Finish.** Outputs stay on the worker's disk, and stream straight to any connected client, and the job is Done, or Failed with the engine's error, or Cancelled. A lease that misses heartbeats for 60 s, counted only while the coordinator is itself connected, is revoked and the job requeued. Solvers restart from the beginning, since only the transient solvers have frames to resume from.
5. **Fetch.** On its next check-in a client asks the coordinator for everything changed since its last sync cursor and claims the outputs it wants. A worker holds unclaimed results for 7 days, or until you delete them.
## Protocol and engine changes
The hub speaks one protocol: a WebSocket per member at `wss://hub.solidcraft.io/v1/connect`. Everything else is a message between members, inside encrypted frames the hub forwards.
**Connecting.** The hub sends a random challenge. The member answers with the cluster's public key, its device key and a k\_sign signature over the challenge and the device key. The hub then puts the connection in the room for that cluster id. Nothing is stored, and a reconnect repeats the handshake.
| Frame the hub reads | From | What the hub does |
| --- | --- | --- |
| `hello` | Any member | Checks the signature and joins the member to its cluster's room |
| `announce {role, term}` (signed) | Coordinator | Routes `to: coordinator` to the highest term announced since the hub started |
| `send {to, seq, ciphertext}` | Any member | Forwards to a device id, `coordinator` or `clients`; buffers up to 60 s and 4 MB while the target reconnects, else answers `absent` |
| `stream.open`, `stream.chunk`, `stream.close` | Worker or client | Relays a result or input transfer with flow control; a broken stream resumes from its byte offset |
| `presence` | Hub | Tells members who joined or left |
| `warming {uptime}` | Hub | Its answer, for the first 120 s after a start, about any cluster with no members yet |
Inside `send`, encrypted end to end with k\_data, members speak their own protocol. Each message carries its sender's device id and a per-device sequence number, so receivers drop replays and duplicates.
| Message | From → to | Purpose |
| --- | --- | --- |
| `submit {job id, spec, inputs, needs, deps, cache key}` | Client → coordinator | Idempotent by the client's job id; acknowledged once in the log |
| `study {jobs}` | Client → coordinator | A graph of jobs in one message |
| `changes {since}` | Client → coordinator | Every job change since the client's sync cursor |
| `cancel`, `delete {job id}` | Client → coordinator | Stops a job; tells its holder to drop the results |
| `claim {job id, have}` | Client → coordinator | The coordinator asks the holding worker to stream the outputs the client lacks |
| `lease {slots, capabilities}` | Worker → coordinator | Returns a lease that fits, or waits |
| `heartbeat`, `events`, `done` | Worker → coordinator | Numbered and kept on the worker's disk until acknowledged, so a hub restart replays them |
| `log.append {term, entries}` | Coordinator → workers | Replicates the job log |
| `handoff {ids}` | Worker → holder | Moves results off a pod before it scales down |
The cache key is computed by the client, as HMAC(k\_id) over the normalised command list, the input ids and the engine version. The coordinator only compares opaque keys.
**Engine changes (M1).**
- **Results part in `.fieldcraft`.** A fifth package part holds the solved steps' fields, so a fetched result opens without solving again.
- **`fieldcraft-cli worker`.** Runs each job in a fresh in-process engine session, without MCP, and claims one GPU slot per adapter `FIELDCRAFT_GPU` can open.
- **Progress events.** Every solver and mesher reports through one callback: increments, time steps, residuals and the heat and force histories `solve.probe` already exposes. The worker forwards them.
- **`solve.estimate`.** A dry run that reports dofs, factor entries, lattice cells, cell updates and peak memory from the existing caps' arithmetic, so the client can declare needs and the scheduler can pick a pool.
- **Python steps.** A step `{"python": "reduce.py", "inputs": [...]}` runs with the job's inputs in a folder and writes its outputs to another. The worker image ships Python with numpy, and `uv` for anything else a step declares.
## Deployment: the hub and your workers
The hub coordinates and relays; your machines compute and keep the results. Workers and clients only make outbound HTTPS calls to the hub, so a worker behind NAT, a home connection or a cluster with no ingress just works.
| | Hosted hub | Self-hosted hub |
| --- | --- | --- |
| Address | `https://hub.solidcraft.io` (the default once it opens, in M5) | Your own URL; clients take `--hub <url>` or the `FIELDCRAFT_HUB` variable |
| State on the hub | None: live connections in memory only; the job log is on your coordinator | None, the same |
| Result files | None: results stay on your workers | None, the same |
| What the hub can read | Who is connected and the sizes and timing of frames, in memory only; never jobs or content | The same (it is the same software) |
| Limits | Per-cluster quotas, decided when the public hub opens (M5) | Whatever you configure |
| Setup | Nothing | `fieldcraft hub serve`, or the Helm chart's hub |
| Workers | How they join | Notes |
| --- | --- | --- |
| One workstation | `fieldcraft worker --join "<passphrase>"` | One slot per GPU adapter, CPU slots by core count; installs as a system service and keeps results on its own disk |
| A fleet | The same command on each machine | Each worker advertises cores, memory and GPU adapters and leases only jobs that fit |
| Kubernetes | Helm chart: a worker StatefulSet per node pool, each pod with a persistent volume, the passphrase in a Secret | KEDA scales each pool on its queue depth; a pod hands its unclaimed results to the holder pool before it scales down, so the GPU pool still reaches zero |
**Kubernetes.** Workers are StatefulSets, one per node pool, because a worker's disk is where results live. Each pod has a persistent volume, runs one job at a time, and requests resources to fit its pool: `cpu` (many cores, 64–256 GB), `gpu` (one GPU per pod) and `highmem` for large direct factorisations. A small `holder` pool of cheap CPU pods with large volumes runs no solves; it only keeps results. When KEDA scales a GPU or CPU pod down, the pod first relays its unclaimed and cached results to a holder through the hub, then exits. The GPU pool can then still scale to zero without losing anything. The queue depth KEDA reads is a metrics endpoint on the coordinator pod, inside the cluster; it never goes through the hub.
GPU pods need a Vulkan driver inside the image, because wgpu compute runs on Vulkan on Linux. On NVIDIA that means the container toolkit with the `graphics` driver capability, which we need to verify on our nodes. The image also carries Mesa for Intel and AMD nodes.
## Studies, sweeps and caching
A **study** is a small graph of jobs whose inputs can be outputs of earlier jobs. The coordinator runs every job whose inputs are ready, so independent branches run side by side. The AIO study as a graph:
1. `mesh` × 3 (one per cold plate, CPU) and `lbm` × 3 (one per velocity, GPU) start together.
2. A `reduce` step reads the LBM rows (pressure loss, heat per wall) and computes the fan operating points and coolant temperatures. It is a small script job on any CPU worker.
3. `heat` × 9 (3 plates × 3 fan setups) start as each mesh and the reduce step finish.
4. `render` × 9 write the cutaways; a `report` job gathers the table.
With workers to spare, the wall time is the slowest LBM run plus one heat solve. On the laptop it was every run in sequence.
**Study definition.** A study is a JSON file next to the model: named parameters with their values, and jobs with `inputs` (artefact hashes, or `{"from": "<job>", "output": "<name>"}`), the commands to run, and declared needs (`gpu`, cores, memory). A sweep is a parameter with a list of values. `fieldcraft study run aio.study.json` submits it; the app gets a Studies panel that does the same.
**Caching by content hash.** A job's key is the SHA-256 of its command list, its input artefacts' hashes and the engine version. A job whose key matches a result some worker still holds is answered from that worker without running. Rerunning the AIO study after changing only the fan curve would then redo only `reduce`, `heat` and `render`. The LBM and mesh jobs, the 2 hours of compute, come straight back, as long as their workers still hold them.
**Early stopping.** A job can declare a stop rule over a streamed quantity, for example "cancel if the heat history's late-window mean breaks the m·cₚ bound". The worker evaluates it every progress tick and cancels through the engine's `job.cancel`, so a bad time step costs minutes, not half an hour.
## Security: the passphrase is the cluster
There are no accounts or logins. A cluster is created by `fieldcraft cluster new`, which prints a seed passphrase of 12 random words (132 bits). Anyone holding those words is a full member: they can join workers, submit and cancel jobs, read every result and run code on every worker. There is one passphrase per cluster, with no lesser roles. The hub holds none of the keys.
**What the words become.** Every client derives the same keys locally:
```latex
\begin{aligned}
m &= \mathrm{Argon2id}(\text{words},\ \text{salt} = \texttt{fieldcraft-cluster-v1}) \\
(k_{\mathrm{sign}},\ k_{\mathrm{data}},\ k_{\mathrm{id}}) &= \mathrm{HKDF}(m) \\
\text{cluster id} &= \mathrm{SHA256}(\mathrm{pub}(k_{\mathrm{sign}}))
\end{aligned}
```
- **k\_sign** (Ed25519) signs every request to the hub: a timestamp, a nonce and the body hash. The hub stores nothing: each connection presents the public key and proves it holds k\_sign by signing the hub's challenge.
- **k\_data** encrypts every job spec, input bundle, result, log and progress line (XChaCha20-Poly1305) before it leaves the machine. The hub cannot read or alter them undetected.
- **k\_id** keys the artefact ids: an artefact's id is HMAC(k\_id, plaintext). Deduplication and the result cache work inside a cluster, but the hub cannot tell whether two clusters hold the same model.
**What the hub sees.** It sees, in memory and only while they last: the cluster id, which devices are connected, who sends frames to whom, and their sizes and timing. It never sees a job, a need, a state, a file name or a number, because job records and scheduling live on the coordinator, inside encrypted frames. Nothing it sees is written down.
**Words must be random.** A cluster id is public to the hub, so a human-chosen passphrase could be guessed offline. The CLI and app only accept words they generated. The 132 bits plus Argon2id put guessing out of reach.
**Devices and revocation.** Each worker and client also makes its own device key and registers it, signed with k\_sign, so the cluster's device list shows what joined and when. A lost laptop is removed from the list, but its copy of the words still works. A real revocation is a rotation: `fieldcraft cluster rotate` makes new words, re-encrypts what you keep under the new keys, and leaves a signed forwarding record so other devices can follow with the new words.
**Lost words cannot be recovered.** We cannot reset them, and nobody can read the cluster's data without them. Clients keep the derived keys in the OS keychain (in the browser, a non-extractable WebCrypto key in IndexedDB), so the words are typed once per device.
**What a worker will run.** A job is a list of FieldCraft engine commands, plus optional Python steps such as `reduce` and `report`. Joining a cluster means accepting its code: there is no opt-in flag, and anyone with the words can run Python on every worker. So the passphrase is a root credential for those machines. A worker runs as its own unprivileged user (on Kubernetes, a non-root pod with no service-account token), each job in a fresh scratch folder that is wiped afterwards. The engine commands also keep the MCP server's path confinement, which bounds what a mistyped path can touch.
**The hosted hub's own exposure.** There is nothing to register, so anyone with a fresh passphrase has a cluster. `hub.solidcraft.io` limits each cluster's connections, buffer memory and relay bandwidth, and refuses frames over a set size. A compromised hub could drop, delay or reorder frames and watch traffic patterns. It could not read or forge a job or a result, and it could not lose work, since none is kept there.
## Client experience
Every client does the same five things: join, submit, watch, cancel and fetch. The difference is only where you sit.
| Client | Join | Submit and watch | Results |
| --- | --- | --- | --- |
| Desktop app | Settings › Cluster: paste the words once | Solve runs on **This computer** or **Cluster**; a Jobs panel shows each job's progress line, warnings and convergence plot, with Cancel | Synced in the background into a local cache; a finished result opens like a local one |
| Browser, `hub.solidcraft.io/app` | Paste the words; the keys stay in the browser | Small models solve in the page (wasm, one thread); larger ones go to the cluster | Downloaded into the browser's private file storage (OPFS), viewed in the page, saved as a file on request |
| `fieldcraft` CLI | `fieldcraft cluster join` | `fieldcraft submit model.fieldcraft --step heat`, `fieldcraft study run aio.study.json`, `fieldcraft jobs`, `fieldcraft cancel <job>` | `fieldcraft fetch <job> --out results/` |
| MCP agents | The server is started with the cluster joined | Tools `submit_job`, `job_status`, `cancel_job` beside today's `execute` and `batch` | `fetch_results`, then the usual `solve.probe` and `results.*` on the fetched model |
**Checking in later.** You schedule a study and disconnect. Workers keep going; nothing depends on the device you scheduled from. When you next open the app, on that device or another, it asks the coordinator what changed since its last sync. It shows something like "AIO study: 15 of 16 jobs done, 1 failed", and starts fetching in the background. Results under a size limit you set (say 2 GB) download automatically; bigger ones wait until you open them. Everything fetched stays in the local cache for offline viewing and saving.
**What comes back.** Each solved job returns a `.fieldcraft` with a results part, which the app opens without solving again. It also returns the exports the job asked for (VTU or PVD for ParaView, CSV probes), its log and its progress history. These are the same files the AIO study's scripts collected by hand.
**Notifications (optional).** A cluster can name a webhook or an ntfy topic. The coordinator posts only a job id and state there, so the message carries nothing that needs the key; the phone app shows "study finished" and the details open in the client.
## Prototype: schedule a study, check in later
A clickable mockup of the desktop journey is in the [FieldCraft Remote CUJ prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83). It uses the compact Craft shell from SolidCraft's ribbon redesign (tangency-labs/solidcraft#1439) with FieldCraft's own workspaces, commands and icons. Press Play on the artboard; the step bar above the window jumps between steps.
1. **Join cluster.** Analysis › Solve › Remote › Join Cluster opens a dense panel for the 12 words, the hub and the device. The app-bar chip turns from "No cluster" to "harbor-velvet · 3 workers".
2. **Schedule study.** Schedule Study shows aio.study.json with a This computer / Cluster switch, its six stages and the stop rule. Once the coordinator acknowledges, the device can disconnect, and View progress opens the cluster view.
3. **Running.** The cluster view shows slots fill and load change while the scheduling device is offline; a simulated-time control in the prototype advances the clock. A phone notification arrives when the study finishes.
4. **Check in.** The hub-warming banner ("waiting for the cluster, not failed") gives way to the sync. The Browser becomes the job tree, the panel shows the selected job's residuals and warnings, and the failed heat job offers "Rerun with dt × 0.5". Results under 2 GB fetch in the background.
5. **Open result.** The Results workspace opens the fetched .fieldcraft from the local cache with contours, without solving again.
**Joining a cluster.** The Join Cluster panel in Analysis › Solve, words pasted, before Join. The chip in the app bar still reads "No cluster".
*Screenshot in the [prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83): the Join Cluster panel in the Analysis workspace.*
**Jobs in progress.** Three hours after scheduling, with the scheduling device offline. The heat stage holds all 9 CPU slots at 100% while the GPU slots sit free, and every worker keeps its finished results on its own disk.
*Screenshot in the [prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83): the cluster view during the heat stage, with the scheduling device offline.*
**Downloading results.** Checked in later: 24 of 26 jobs done. Results under 2 GB download on their own; the 6.2 GB of LBM fields were fetched by hand. The failed heat job shows why the stop rule cancelled it.
*Screenshot in the [prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83): the Jobs tab after the sync, with the background fetch card.*
| Surface | Where it lives in the app | What it shows |
| --- | --- | --- |
| Cluster chip | App bar, where SolidCraft shows the agent status | Cluster name and workers, running jobs, or hub warming; opens the cluster view |
| Remote section | Analysis › Solve ribbon | Join Cluster, Schedule Study, Jobs |
| Cluster view | Full-page view, like the Start page | Free GPU and CPU slots, memory and held results; client → hub → workers; one card per worker with its slot tiles, CPU, memory and disk; the queue by need |
| Jobs tab | Analysis workspace, in place of Solve | Job tree by stage in the Browser, job detail with convergence in the panel, background fetch |
The hardware and load figures in the prototype are illustrative, and its cluster and job icons are sketches in the icon palette, not final artwork.
## Rollout and decisions
Ship and prove the self-hosted path first, using the AIO study as the acceptance test at every step; the public hub comes last.
```mermaid
flowchart LR
M1["M1 Remote solve<br/>results in .fieldcraft · coordinator, hub serve<br/>cluster words, keys · submit, jobs, claim CLI<br/><b>Gate:</b> AIO study on a self-hosted hub<br/>matches local runs bit for bit"]
M2["M2 Asynchronous app<br/>Jobs panel, cancel · background sync, cache<br/>browser joins by words · claims after reconnect<br/><b>Gate:</b> submit, 4 h offline, fetch on landing"]
M3["M3 Studies<br/>study files and graph · keyed result cache<br/>stop rules on histories · MCP job tools<br/><b>Gate:</b> a fan-curve change reruns<br/>only reduce and heat"]
M4["M4 Kubernetes<br/>Helm chart, KEDA pools · GPU image with Vulkan<br/>holder pool on volumes · coordinator pod<br/><b>Gate:</b> 9-case sweep in about<br/>its slowest case's time"]
M5["M5 Public hub<br/>hub.solidcraft.io opens; its quotas are set then<br/><b>Gate:</b> self-hosted clusters have proven the design"]
M1 --> M2 --> M3 --> M4 --> M5
```
M1 also covers the engine changes the backend needs: a results part in `.fieldcraft`, progress events from every solver, and a `worker` mode in `fieldcraft-cli`. The public hub at hub.solidcraft.io is the last deliverable (M5). It opens only once self-hosted clusters have proven the design, and its per-cluster quotas (connections, buffer memory, relay bandwidth) are decided then.
**Decisions**
| Question | Decision |
| --- | --- |
| How long a worker keeps unclaimed results | Workers hold unclaimed results 7 days; copies a client claimed stay on that client |
| More than one passphrase per cluster | No: one passphrase, full access |
| Relay results straight to a client | Yes, and further: the hub stores no result files at all. Workers hold results until a client claims them, relayed through the hub |
| Lease timeout | 60 s of missed heartbeats; the heartbeat runs on its own worker thread, so a factorisation silent for minutes keeps its lease |
| Script steps | Python, with no opt-in flag: joining a cluster means accepting its code |
| State on the hub | None: no database, no disk. The hub is an in-memory switchboard; the job log lives on a coordinator worker, replicated to the other workers |
| Hub restarts | Cost no work: workers keep running and replay, leases freeze, and clients treat a job the hub doesn't know as "not reconnected yet" |
| Who can coordinate | Only a worker (`fieldcraft worker`), never an interactive session: the desktop app, the browser, the CLI or an MCP agent. With no worker online, clients hold their jobs until one appears |
**Still open**
- [ ] Which NVIDIA driver capabilities do wgpu compute pods need on our nodes, and does the Intel or AMD path need anything beyond Mesa?