forked from 1b-agent/fieldcraft-upstream
Compare commits
28
Commits
No files matched your search
Generated
+12
@@ -1272,6 +1272,7 @@ dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-lbm-gpu",
|
||||
"fieldcraft-mesh-gpu",
|
||||
"fieldcraft-ui-egui",
|
||||
"fieldcraft-update",
|
||||
"getrandom 0.4.3",
|
||||
@@ -1293,6 +1294,7 @@ dependencies = [
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-lbm-gpu",
|
||||
"fieldcraft-mcp",
|
||||
"fieldcraft-mesh-gpu",
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-update",
|
||||
"png",
|
||||
@@ -1442,6 +1444,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh-gpu"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-gpu",
|
||||
"fieldcraft-mesh",
|
||||
"pollster 1.0.1",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.7.0"
|
||||
|
||||
@@ -30,6 +30,7 @@ fieldcraft-mcp = { path = "crates/mcp" }
|
||||
fieldcraft-update = { path = "crates/update" }
|
||||
fieldcraft-gpu = { path = "crates/gpu" }
|
||||
fieldcraft-lbm-gpu = { path = "crates/lbm-gpu" }
|
||||
fieldcraft-mesh-gpu = { path = "crates/mesh-gpu" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
||||
+3
-3
@@ -38,7 +38,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
|
||||
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials (neo-Hookean, Mooney–Rivlin, Yeoh) on the shared material point with average nodal pressure tets, and opt-in mass scaling with the added mass reported: done; runs on hex and tet meshes | started |
|
||||
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark; results in the viewport and headless pictures: velocity or pressure on a slice, the obstacles coloured by the flow beside them and streamlines seeded upstream, `results.slice`, `results.surface`, `results.streamlines`: done) | started |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark; results in the viewport and headless pictures: velocity or pressure on a slice, the obstacles coloured by the flow beside them and streamlines seeded upstream, `results.slice`, `results.surface`, `results.streamlines`; runs to steady state with a stop rule on the velocity and temperature residuals and monitored mass flow, wall force, wall heat and mean temperature, reporting why and when it stopped, at the same steps on CPU and GPU: done) | started |
|
||||
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
|
||||
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
|
||||
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. a supernodal symmetric indefinite LDLᵀ factorization (bounded Bunch–Kaufman within supernodes, inertia) on which Newton continues through indefinite tangents, so nearly incompressible hybrid elements (ν = 0.4999) converge under prescribed displacement; linear buckling steps (linear preload, initial-stress stiffness of solids and 2D elements, block Lanczos in the K inner product, the shifted buckling transformation on LDLᵀ with an inertia count, `solve.buckle`, Abaqus `*BUCKLE`), verified by Euler columns and Bryan's plate; imperfections from buckling modes; arc-length (Riks–Crisfield, cylindrical or spherical) with limit and bifurcation points from the tangent's inertia, verified by the snap-through of a shallow arch. Next: Lee's frame and the hinged cylindrical shell (with shells), the follower pressure's full tangent (LU), incompatible modes (EAS), kinematic hardening at finite strain, shells and beams) | started |
|
||||
@@ -50,8 +50,8 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M21 Biomechanics and biomaterials | Soft tissue: Holzapfel–Gasser–Ogden fibre-reinforced hyperelasticity with dispersion, Fung, transversely isotropic models; biphasic and poro-hyperelastic cartilage (M19); active muscle (Hill-type); hydrogel swelling. Bone: density-dependent moduli from CT data, strain-energy-density remodelling (Huiskes, Beaupré). Growth and remodelling by kinematic growth (F = Fe·Fg). Implants and biomaterials (titanium, PEEK, UHMWPE) from the M17 library. Verified against published experiments with their tolerances | planned |
|
||||
| M22 Design studies | Parameters of the SolidCraft design driven from FieldCraft: parametric sweeps, design of experiments, response surfaces and sensitivities; topology optimisation (SIMP with filters, manufacturing constraints); size and shape optimisation; submodelling (driven boundaries from a global model); uncertainty quantification | planned |
|
||||
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
|
||||
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 15 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE2, LE3, LE5, LE7, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started |
|
||||
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
|
||||
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 16 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE2, LE3, LE5, LE6, LE7, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started |
|
||||
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); projection blocking's certified quality, scans and coloured smoothing on threads, its coloured sweeps on the GPU (`fieldcraft-mesh-gpu`, `mesh.hex.project` with `backend: "gpu"`, #409: started); parallel forward/back substitution and explicit tet4 assembly; more GPU paths later, within a stated tolerance of the CPU | started |
|
||||
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done; in-app updates — a daily check of GitHub Releases, Help › Check for Updates and Reinstall, downloads verified against the release key's signature of SHA256SUMS.txt and swapped in after closing, `app.update.check`, `fieldcraft-cli update`, Reload to update in the browser, docs/updates.md: done), command schema as the public API | started |
|
||||
|
||||
## 0.6.0: the UI/UX release
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-lbm-gpu = { workspace = true }
|
||||
fieldcraft-mesh-gpu = { workspace = true }
|
||||
fieldcraft-gpu = { workspace = true }
|
||||
fieldcraft-render = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
|
||||
@@ -65,6 +65,7 @@ const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024;
|
||||
fn main() -> ExitCode {
|
||||
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"` (scripts and MCP).
|
||||
fieldcraft_lbm_gpu::register();
|
||||
fieldcraft_mesh_gpu::register();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("commands") if args.get(1).map(String::as_str) == Some("--json") => {
|
||||
|
||||
@@ -22,6 +22,7 @@ neg_cmp_op_on_partial_ord = "allow"
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-lbm-gpu = { workspace = true }
|
||||
fieldcraft-mesh-gpu = { workspace = true }
|
||||
fieldcraft-ui-egui = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
fieldcraft-update = { workspace = true }
|
||||
|
||||
@@ -226,6 +226,7 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
|
||||
fn main() -> eframe::Result {
|
||||
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"`.
|
||||
fieldcraft_lbm_gpu::register();
|
||||
fieldcraft_mesh_gpu::register();
|
||||
let args = match parse_args(std::env::args().skip(1), std::env::var("FIELDCRAFT_CONTROL_PORT").ok()) {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => return Ok(()),
|
||||
|
||||
@@ -288,7 +288,7 @@ definition_commands!(
|
||||
FluidDomain,
|
||||
"fluid_domain",
|
||||
"Fluid Domain",
|
||||
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing), "thermal": {"initial_temperature": 20, "gravity": [0, -9810, 0], "expansion": 2.1e-4 (β per temperature; buoyancy needs both, Boussinesq), "faces": {"y_min": {"type": "temperature", "value": 80} | {"type": "flux", "value": 1000 (heat per area into the fluid)} | {"type": "adiabatic"}, …} (default adiabatic; a velocity inlet carries its held temperature in, a pressure outlet lets heat leave), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "temperature", "value": 80}}] (obstacle faces are adiabatic unless listed)} (heat in the fluid, carried by the flow and diffused with the material's conductivity and specific heat; left out, the flow is isothermal)}"#,
|
||||
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing), "thermal": {"initial_temperature": 20, "gravity": [0, -9810, 0], "expansion": 2.1e-4 (β per temperature; buoyancy needs both, Boussinesq), "faces": {"y_min": {"type": "temperature", "value": 80} | {"type": "flux", "value": 1000 (heat per area into the fluid)} | {"type": "adiabatic"}, …} (default adiabatic; a velocity inlet carries its held temperature in, a pressure outlet lets heat leave), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "temperature", "value": 80}}] (obstacle faces are adiabatic unless listed)} (heat in the fluid, carried by the flow and diffused with the material's conductivity and specific heat; left out, the flow is isothermal), "porous": [{"name": "Vent", "min": [x, y, z], "max": [x, y, z] (the cells whose centres it contains), "axis": "x|y|z" (the flow's direction through it), "resistance": {"type": "darcy_forchheimer", "permeability": K (length²), "inertial": C₂ (per length; default 0)} | {"type": "loss_coefficient", "coefficient": k (Δp = k ρ u²/2 across the zone)}}] (vents, grilles, filters, perforated plates: a force per volume −(μ/K) u − (ρ C₂/2)|u| u), "fans": [{"name": "Fan", "min": [x, y, z], "max": [x, y, z], "direction": "+x|-x|+y|-y|+z|-z", "curve": [[Q, Δp], …] (volume flow increasing from 0, pressure rise not increasing; linear between points; beyond the last point the fan holds its end value, so it never brakes the flow past free delivery), "relaxation": 0.01 (how far the rise moves toward the curve per time step)}] (the pressure rise pushes the fan's cells; the solve reports each fan's operating point and each vent's flow); fans and zones stay off the inlet and outlet layers; in the model's units, never converted}"#,
|
||||
super::forms::FLUID_DOMAIN,
|
||||
super::forms::FLUID_DOMAIN_DELETE,
|
||||
add_fluid_domain,
|
||||
@@ -340,7 +340,7 @@ fn fluid_thermal_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.create",
|
||||
label: "New Step",
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "buckle", "modes": 5, "shift"?: 0} | {"type": "heat_transfer", "transient"?: {"end_time": 10, "increment": 0.5, "theta": 1, "initial_temperature": 20}} (steady without `transient`) | {"type": "coupled_temperature_displacement"} (steady heat and the static equilibrium of its thermal strains) | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "buckle", "modes": 5, "shift"?: 0} | {"type": "heat_transfer", "transient"?: {"end_time": 10, "increment": 0.5, "theta": 1, "initial_temperature": 20}} (steady without `transient`) | {"type": "coupled_temperature_displacement"} (steady heat and the static equilibrium of its thermal strains) | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ), "steady": {"tolerance": 1e-3, "window": (flow time the change is measured over, sampled 8 times; default a twentieth of the step), "min_time": 0, "divergence": (optional relative change that stops the run as diverged), "monitors": [{"type": "mass_flow", "face": "x_max"} | {"type": "force", "faces": ["x_min", {"body": "Body1", "face": "F3"}]} | {"type": "heat", "faces": […]} | {"type": "mean_temperature"}]} (optional: stop once the velocity residual, the temperature residual with heat, and every monitor vary by less than the tolerance (relative change per window) for two windows in a row; end_time / steps is then the most it runs)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
form: Some(super::forms::STEP_CREATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
|
||||
@@ -661,6 +661,60 @@ const FLUID_THERMAL: Field = group(
|
||||
.help("Heat carried by the flow: needs the material's conductivity and specific heat. Off for an isothermal flow.")
|
||||
.hint("isothermal when off");
|
||||
|
||||
const AXIS: &[(&str, &str)] = &[("\"x\"", "X"), ("\"y\"", "Y"), ("\"z\"", "Z")];
|
||||
|
||||
const RESISTANCE: &[Variant] = &[
|
||||
variant(
|
||||
"darcy_forchheimer",
|
||||
"Darcy–Forchheimer",
|
||||
&[
|
||||
positive("permeability", "Permeability K", D::Area).help("The viscous resistance: a force per volume of μ/K times the velocity.").req(),
|
||||
num("inertial", "Inertial C₂", D::None)
|
||||
.help(
|
||||
"The inertial resistance per length: a force per volume of ρ C₂/2 times the velocity squared. Zero for a purely viscous medium.",
|
||||
)
|
||||
.def("0"),
|
||||
],
|
||||
),
|
||||
variant(
|
||||
"loss_coefficient",
|
||||
"Loss coefficient",
|
||||
&[positive("coefficient", "Loss coefficient k", D::None)
|
||||
.help("The pressure drop across the zone is k ρ u²/2 at the velocity u through it (a grille's or filter's data sheet value).")
|
||||
.req()],
|
||||
),
|
||||
];
|
||||
|
||||
const POROUS_ZONE: &[Field] = &[
|
||||
text("name", "Name").help("The vent's name, in the solve report.").req(),
|
||||
xyz("min", "Box min", D::Length).help("The zone's corner with the smallest coordinates.").req(),
|
||||
xyz("max", "Box max", D::Length).help("The zone's corner with the largest coordinates; it holds the cells whose centres it contains.").req(),
|
||||
choice("axis", "Flow axis", AXIS)
|
||||
.help("The axis the flow goes through it along: the thickness of a loss coefficient and the direction of its reported flow.")
|
||||
.req(),
|
||||
variants("resistance", "Resistance", RESISTANCE)
|
||||
.help("How the zone resists the flow (in every direction).")
|
||||
.req()
|
||||
.def(r#"{"type": "loss_coefficient"}"#),
|
||||
];
|
||||
|
||||
const FAN: &[Field] = &[
|
||||
text("name", "Name").help("The fan's name, in the solve report.").req(),
|
||||
xyz("min", "Box min", D::Length).help("The fan's corner with the smallest coordinates.").req(),
|
||||
xyz("max", "Box max", D::Length)
|
||||
.help("The fan's corner with the largest coordinates: usually one or a few cells thick along its direction.")
|
||||
.req(),
|
||||
choice("direction", "Direction", &[("\"+x\"", "+X"), ("\"-x\"", "−X"), ("\"+y\"", "+Y"), ("\"-y\"", "−Y"), ("\"+z\"", "+Z"), ("\"-z\"", "−Z")])
|
||||
.help("The direction the fan blows.")
|
||||
.req(),
|
||||
table("curve", "Fan curve", D::None, &["flow", "pressure rise"], fieldcraft_model::fluid_zones::MAX_CURVE_POINTS)
|
||||
.help("The pressure–flow curve: volume flow (volume per time) increasing from 0, pressure rise not increasing, in the model's units.")
|
||||
.req(),
|
||||
range("relaxation", "Relaxation", D::None, 1e-6, 1.0)
|
||||
.help("How far the pressure rise moves toward the curve each time step; smaller is steadier.")
|
||||
.hint("0.01"),
|
||||
];
|
||||
|
||||
pub const FLUID_DOMAIN: &[Field] = &[
|
||||
NAME,
|
||||
reference("material", "Fluid", C::Material).help("The fluid material: needs a density and a viscosity.").req(),
|
||||
@@ -699,6 +753,9 @@ pub const FLUID_DOMAIN: &[Field] = &[
|
||||
.help("How close a cell must be to an obstacle surface to count as on it.")
|
||||
.hint("a tenth of the cell size"),
|
||||
FLUID_THERMAL,
|
||||
rows("porous", "Vents and filters", POROUS_ZONE)
|
||||
.help("Porous resistance zones: vents, grilles, filters and perforated plates without resolving every hole."),
|
||||
rows("fans", "Fans", FAN).help("Fans: zones that raise the pressure by their pressure–flow curve; the solve finds the operating point."),
|
||||
];
|
||||
|
||||
pub const FLUID_BOUNDARY: &[Field] = &[
|
||||
@@ -906,6 +963,27 @@ const PROCEDURE: Field = variants(
|
||||
.hint("chosen for Mach 0.087"),
|
||||
int("frames", "Frames", 1, 100_000).help("How many result frames to keep.").def("10"),
|
||||
positive("magic", "TRT Λ", D::None).help("The two-relaxation-time magic parameter; 0.1875 is usual.").hint("0.1875"),
|
||||
group(
|
||||
"steady",
|
||||
"Stop at steady state",
|
||||
&[
|
||||
range("tolerance", "Tolerance", D::None, 0.0, 1.0)
|
||||
.help("The relative change per window: steady when, two windows in a row, every monitored quantity varies by less than this fraction of its largest value.")
|
||||
.def("0.001"),
|
||||
positive("window", "Window", D::Time)
|
||||
.help("Flow time over which the change is measured; each window is sampled 8 times.")
|
||||
.hint("a twentieth of the step"),
|
||||
positive("min_time", "Minimum time", D::Time).help("No decision before this flow time.").hint("0"),
|
||||
positive("divergence", "Divergence limit", D::None)
|
||||
.help("Stop as diverged when a monitored quantity changes by more than this fraction over a window (after the minimum time).")
|
||||
.hint("off"),
|
||||
json("monitors", "Also monitor")
|
||||
.help("Quantities watched besides the velocity (and temperature) residual, as JSON: mass_flow through a box face, force or heat on walls, mean_temperature.")
|
||||
.hint("[{\"type\": \"force\", \"faces\": [\"x_min\"]}]"),
|
||||
],
|
||||
)
|
||||
.help("Stop on its own once the flow stops changing; End time or Steps is then the longest it runs.")
|
||||
.hint("runs its full length when off"),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1262,6 +1340,9 @@ pub const HEX_PROJECT: &[Field] = &[
|
||||
.help("How interior nodes are smoothed after projection, with the boundary held on the geometry.")
|
||||
.def("\"elliptic\""),
|
||||
int("iterations", "Iterations", 1, 100_000).help("Smoothing iterations.").def("100"),
|
||||
choice("backend", "Backend", &[("cpu", "CPU"), ("coloured", "Coloured (threads)"), ("gpu", "GPU")])
|
||||
.help("Where the smoothing sweeps run: the CPU node by node (the same mesh on every machine), colour by colour on every core, or the GPU FIELDCRAFT_GPU picks (single precision, the same verdicts as coloured, smallest scaled Jacobian within 0.02 of it).")
|
||||
.def("\"cpu\""),
|
||||
range("min_quality", "Minimum quality", D::None, 0.0, 1.0).help("Smallest scaled Jacobian accepted.").def("0.2"),
|
||||
range("max_arc_angle", "Max arc angle", D::Degree, 0.0, 90.0)
|
||||
.help("The most a curved face may turn across one element; the divisions grow to keep it. 0 for no limit.")
|
||||
@@ -2037,7 +2118,13 @@ pub const SOLVE_EXPLICIT: &[Field] = &[
|
||||
int("max_contact_faces", "Max contact faces", 1, 10_000_000).help("Cap on the faces in contact search.").hint("10000000"),
|
||||
THREADS,
|
||||
];
|
||||
pub const SOLVE_HISTORY: &[Field] = &[SOLVED.req(), int("every", "Keep one in", 1, 1_000_000_000).help("Keep one history sample in this many.")];
|
||||
pub const SOLVE_HISTORY: &[Field] = &[
|
||||
SOLVED.req(),
|
||||
int("every", "Keep one in", 1, 1_000_000_000).help("Keep one history sample in this many."),
|
||||
references("sets", "Sets", C::Set)
|
||||
.help("Nonlinear static steps: the sets whose mean displacement and reaction sum to give per frame; the boundary-condition sets when none."),
|
||||
int("node", "Node", 1, 4_294_967_295).help("Nonlinear static steps: a node whose displacement to give per frame."),
|
||||
];
|
||||
pub const SOLVE_FLUID: &[Field] = &[
|
||||
STEP,
|
||||
choice("backend", "Backend", &[("cpu", "CPU"), ("gpu", "GPU")])
|
||||
@@ -2206,6 +2293,8 @@ pub const REACTIONS: &[Field] = &[
|
||||
.help("The sets whose reaction forces to sum; the step's boundary-condition sets when empty.")
|
||||
.hint("the step's BC sets"),
|
||||
xyz("about", "About point", D::Length).help("The point moments are taken about."),
|
||||
int("frame", "Frame", 0, 1_000_000_000)
|
||||
.help("Nonlinear static steps: the frame (converged increment) whose reactions to sum; the final state when empty."),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2325,6 +2414,7 @@ mod tests {
|
||||
"`boundary_conditions` or `loads`",
|
||||
"give one of",
|
||||
"is not an explicit",
|
||||
"is neither an explicit dynamic nor a nonlinear static step",
|
||||
"has no explicit dynamics step",
|
||||
"has a result",
|
||||
"no crash report",
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::hex::BoundaryFace;
|
||||
use fieldcraft_mesh::join::{check_conforming, weld};
|
||||
use fieldcraft_mesh::project::{FixedPatch, ProjectOptions, Smoothing, Topology, project_mesh};
|
||||
use fieldcraft_mesh::project::{Backend, FixedPatch, ProjectOptions, Smoothing, Topology, project_mesh};
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_mesh::tet::geometry::Tessellation;
|
||||
use fieldcraft_model::{ElementKind, GeometryRef};
|
||||
@@ -19,7 +19,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
pub const HEX_PROJECT: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.project",
|
||||
label: "Projection Blocking",
|
||||
params: r#"{"body": "Body1", "size": 2.0 (element size, mm; default 1/25 of the body's diagonal), "template": "grid" (default) | "sphere" | "butterfly" | "blocks", "center": [x, y, z], "radius": r, "axis": [0, 0, 1], "levels": [z0, z1, …] (sphere and butterfly: default from the body's bounding box; butterfly levels are distances along the axis from the centre), "inset": 0.55 (inner square or cube corner at this fraction of the radius), "grid": {"planes": {"x": [...], "y": [...], "z": [...]} or "cells": [ni, nj, nk] (even planes over the bounding box, default [1, 1, 1]), "carve": [{"i": [a, b], "j": [c, d], "k": [e, f]}] (block index ranges, inclusive, removed)}, "blocks": {"vertices": [[x, y, z], ...], "blocks": [[v1..v8], ...] (0-based vertices, hex8 order)}, "operations": [{"op": "o_grid", "block": b, "open": [faces], "inset": 0.5 or [i1, i2, i3], "extrude": {"offset": [dx, dy, dz], "layers": 1, "faces": "all" (the open face's butterfly: a round branch) | "inner" (its inner face: a square stub)}} | {"op": "extrude", "faces": [[block, face], ...], "offset": [dx, dy, dz], "layers": 1} | {"op": "connect", "from": [block, face], "to": [block, face], "layers": 1} (blocks bridging two faces: closes a handle) | {"op": "remove", "blocks": [...]} (carve; later blocks renumber) | {"op": "merge", "distance": d} | {"op": "split", "block": b, "direction": 1|2|3 or "x"|"y"|"z", "at": [fractions]} (cuts the block and the whole sheet of blocks the cut runs through, so the topology stays conforming; fractions from the block's side-0 face, or from the low end along an axis; the block keeps its first slab, the others are appended)] (applied in order; blocks are 0-based, new blocks appended, the O-grid's inner block keeping the old index; a face is its hex8 number 1-6 or the direction it faces, "+x" … "-z"), "preview": true (return the topology without meshing), "faces": [{"block": b, "face": 1-6, "to": ["face names"]}] (targets set by hand; other outer block faces go to the smooth group of B-rep faces nearest them), "divisions": [{"block": b, "direction": 1|2|3, "count": n}], "smoothing": "elliptic" (default) | "elliptic-controlled" (Thomas–Middlecoff) | "laplace" | "none", "iterations": 100, "min_quality": 0.2 (smallest scaled Jacobian accepted), "max_arc_angle": 30 (degrees: the most a curved face may turn across one element; the divisions grow to keep it, 0 for no limit, at most 90), "fixed": ["face names"] (faces of the body another body's mesh already covers: its element faces there are taken as given, their nodes held and the block divisions there taken from them, so the two meshes meet node to node), "merge": distance (join nodes of other bodies' meshes within it; default 1e-6 of the diagonal, 0 never), "tolerance": chordal tolerance of the tessellation} — replaces the body's mesh with an all-hex mesh of the block topology: outer block faces projected onto their B-rep faces, edges between faces with different targets onto the curve where those meet (tangent-plane iteration), vertices on three onto their point, nodes placed by transfinite interpolation and smoothed with the boundary held on the geometry. Elements are tagged with the body and boundary faces with their B-rep faces. Reports blocks, divisions, targets and quality before and after smoothing; a corner that flattens on a curved face is an error that says to use an O-grid or template there"#,
|
||||
params: r#"{"body": "Body1", "size": 2.0 (element size, mm; default 1/25 of the body's diagonal), "template": "grid" (default) | "sphere" | "butterfly" | "blocks", "center": [x, y, z], "radius": r, "axis": [0, 0, 1], "levels": [z0, z1, …] (sphere and butterfly: default from the body's bounding box; butterfly levels are distances along the axis from the centre), "inset": 0.55 (inner square or cube corner at this fraction of the radius), "grid": {"planes": {"x": [...], "y": [...], "z": [...]} or "cells": [ni, nj, nk] (even planes over the bounding box, default [1, 1, 1]), "carve": [{"i": [a, b], "j": [c, d], "k": [e, f]}] (block index ranges, inclusive, removed)}, "blocks": {"vertices": [[x, y, z], ...], "blocks": [[v1..v8], ...] (0-based vertices, hex8 order)}, "operations": [{"op": "o_grid", "block": b, "open": [faces], "inset": 0.5 or [i1, i2, i3], "extrude": {"offset": [dx, dy, dz], "layers": 1, "faces": "all" (the open face's butterfly: a round branch) | "inner" (its inner face: a square stub)}} | {"op": "extrude", "faces": [[block, face], ...], "offset": [dx, dy, dz], "layers": 1} | {"op": "connect", "from": [block, face], "to": [block, face], "layers": 1} (blocks bridging two faces: closes a handle) | {"op": "remove", "blocks": [...]} (carve; later blocks renumber) | {"op": "merge", "distance": d} | {"op": "split", "block": b, "direction": 1|2|3 or "x"|"y"|"z", "at": [fractions]} (cuts the block and the whole sheet of blocks the cut runs through, so the topology stays conforming; fractions from the block's side-0 face, or from the low end along an axis; the block keeps its first slab, the others are appended)] (applied in order; blocks are 0-based, new blocks appended, the O-grid's inner block keeping the old index; a face is its hex8 number 1-6 or the direction it faces, "+x" … "-z"), "preview": true (return the topology without meshing), "faces": [{"block": b, "face": 1-6, "to": ["face names"]}] (targets set by hand; other outer block faces go to the smooth group of B-rep faces nearest them), "divisions": [{"block": b, "direction": 1|2|3, "count": n}], "smoothing": "elliptic" (default) | "elliptic-controlled" (Thomas–Middlecoff) | "laplace" | "none", "iterations": 100, "backend": "cpu" (default: the sweeps node by node, the same mesh on every machine) | "coloured" (colour by colour on every core: also the same mesh everywhere, a slightly different one) | "gpu" (the coloured sweeps' interior nodes on the GPU `FIELDCRAFT_GPU` picks, in single precision: deterministic per device, the same verdicts as "coloured" and a smallest scaled Jacobian within 0.02 of it, the mesh still certified in double precision; an error when the build has no GPU), "min_quality": 0.2 (smallest scaled Jacobian accepted), "max_arc_angle": 30 (degrees: the most a curved face may turn across one element; the divisions grow to keep it, 0 for no limit, at most 90), "fixed": ["face names"] (faces of the body another body's mesh already covers: its element faces there are taken as given, their nodes held and the block divisions there taken from them, so the two meshes meet node to node), "merge": distance (join nodes of other bodies' meshes within it; default 1e-6 of the diagonal, 0 never), "tolerance": chordal tolerance of the tessellation} — replaces the body's mesh with an all-hex mesh of the block topology: outer block faces projected onto their B-rep faces, edges between faces with different targets onto the curve where those meet (tangent-plane iteration), vertices on three onto their point, nodes placed by transfinite interpolation and smoothed with the boundary held on the geometry. Elements are tagged with the body and boundary faces with their B-rep faces. Reports blocks, divisions, targets and quality before and after smoothing; a corner that flattens on a curved face is an error that says to use an O-grid or template there"#,
|
||||
form: Some(super::forms::HEX_PROJECT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
@@ -239,6 +239,7 @@ struct ProjectParams {
|
||||
divisions: Vec<Division>,
|
||||
smoothing: Option<String>,
|
||||
iterations: Option<usize>,
|
||||
backend: Option<String>,
|
||||
min_quality: Option<f64>,
|
||||
max_arc_angle: Option<f64>,
|
||||
merge: Option<f64>,
|
||||
@@ -516,6 +517,14 @@ fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
|
||||
if iterations > 10_000 {
|
||||
return Err(EngineError::invalid("iterations", "at most 10000"));
|
||||
}
|
||||
let backend = match p.backend.as_deref().unwrap_or("cpu") {
|
||||
"cpu" => Backend::Cpu,
|
||||
"coloured" => Backend::Coloured,
|
||||
"gpu" => Backend::Device,
|
||||
other => {
|
||||
return Err(EngineError::invalid("backend", format!("`{}` is not cpu, coloured or gpu", other.chars().take(40).collect::<String>())));
|
||||
}
|
||||
};
|
||||
let min_quality = p.min_quality.unwrap_or(fieldcraft_mesh::project::DEFAULT_MIN_QUALITY);
|
||||
if !(-1.0..=1.0).contains(&min_quality) {
|
||||
return Err(EngineError::invalid("min_quality", "from -1 to 1"));
|
||||
@@ -548,6 +557,7 @@ fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
|
||||
fixed,
|
||||
planes: Vec::new(),
|
||||
field,
|
||||
backend,
|
||||
};
|
||||
let merge = merge_distance(p.merge, diag)?;
|
||||
project_body(s, &p.body, b, &t, &opts, merge)
|
||||
|
||||
@@ -92,6 +92,10 @@ fn hostile_parameters_change_nothing() {
|
||||
json!({"body": "Ball", "template": "sphere", "inset": 2}),
|
||||
json!({"body": "Ball", "template": "sphere", "smoothing": "magic"}),
|
||||
json!({"body": "Ball", "template": "sphere", "iterations": 1_000_000}),
|
||||
json!({"body": "Ball", "template": "sphere", "backend": "tpu"}),
|
||||
json!({"body": "Ball", "template": "sphere", "backend": 7}),
|
||||
// No sweep device is registered in this test binary: an error, not a panic.
|
||||
json!({"body": "Ball", "template": "sphere", "backend": "gpu"}),
|
||||
json!({"body": "Ball", "template": "sphere", "faces": [{"block": 0, "face": 1, "to": ["nope"]}]}),
|
||||
json!({"body": "Ball", "template": "sphere", "divisions": [{"block": 99, "direction": 1, "count": 2}]}),
|
||||
json!({"body": "Ball", "template": "sphere", "divisions": [{"block": 0, "direction": 4, "count": 2}]}),
|
||||
|
||||
@@ -64,7 +64,7 @@ pub const FRAME: CommandSpec = CommandSpec {
|
||||
pub const REACTIONS: CommandSpec = CommandSpec {
|
||||
id: "results.reactions",
|
||||
label: "Reaction Sums",
|
||||
params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` (none or empty) defaults to the sets the step's boundary conditions hold"#,
|
||||
params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0], "frame": 3} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` (none or empty) defaults to the sets the step's boundary conditions hold; `frame` (nonlinear static steps, from 0 as in results.frame; default the final state) reads the reactions of that converged increment and adds its `load_factor` (solve.history gives every frame's sums at once)"#,
|
||||
form: Some(super::forms::REACTIONS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -606,14 +606,58 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !about.iter().all(|c| c.is_finite()) {
|
||||
return Err(EngineError::param("about", ProblemKind::NotAVector { len: 3 }));
|
||||
}
|
||||
let frame_index: Option<usize> = opt(p, "frame")?;
|
||||
let Some((result, current)) = s.latest_result(&step) else { return Err(EngineError::Params(solved_list(s))) };
|
||||
let Kind::Static(r) = result.kind() else {
|
||||
return Err(EngineError::Params(format!(
|
||||
"step `{step}` is not a static step: reactions are kept for static steps (explicit steps keep the constraint force history, solve.history)"
|
||||
)));
|
||||
};
|
||||
// No sets (or an empty list, a form with none ticked): the step's boundary-condition sets.
|
||||
let sets: Vec<String> = match p.get("sets").filter(|v| v.as_array().is_none_or(|a| !a.is_empty())) {
|
||||
let frame = match frame_index {
|
||||
None => None,
|
||||
Some(k) => Some(nonlinear_frame(&step, r, k)?),
|
||||
};
|
||||
let sets = reaction_sets(s, &step, p)?;
|
||||
let reaction = |i: usize| -> Option<[f64; 3]> {
|
||||
match frame {
|
||||
Some(f) => Some(f.reaction_at(i)),
|
||||
None => r.reaction.get(i).copied(),
|
||||
}
|
||||
};
|
||||
let mut per_set = Vec::with_capacity(sets.len());
|
||||
for name in &sets {
|
||||
let resolved = s.model.resolve_set(name)?;
|
||||
let mut row = reaction_sum(s, r, &reaction, &mut resolved.nodes.iter().copied(), about);
|
||||
row["set"] = json!(name);
|
||||
per_set.push(row);
|
||||
}
|
||||
let total = reaction_sum(s, r, &reaction, &mut r.nodes.iter().copied(), about);
|
||||
let mut out = json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total});
|
||||
match (frame_index, frame) {
|
||||
(Some(k), Some(f)) => {
|
||||
out["frame"] = json!(k);
|
||||
out["load_factor"] = json!(f.load_factor);
|
||||
}
|
||||
_ => out["applied"] = json!(r.applied_total),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Frame `k` of a nonlinear static step.
|
||||
pub(crate) fn nonlinear_frame<'a>(step: &str, r: &'a fieldcraft_solver::StaticResult, k: usize) -> Result<&'a fieldcraft_solver::nonlinear::Frame> {
|
||||
let h = r
|
||||
.nonlinear
|
||||
.as_ref()
|
||||
.ok_or_else(|| EngineError::invalid("frame", format!("step `{step}` is a linear static step: it has one state and no frames")))?;
|
||||
h.frames.get(k).ok_or_else(|| {
|
||||
EngineError::invalid("frame", format!("step `{step}` has frames 0 to {}; there is no frame {k}", h.frames.len().saturating_sub(1)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The sets `p.sets` names (at most [`MAX_SETS`]); none or an empty list (a form with none
|
||||
/// ticked): the sets the step's boundary conditions hold.
|
||||
pub(crate) fn reaction_sets(s: &Session, step: &str, p: &Value) -> Result<Vec<String>> {
|
||||
Ok(match p.get("sets").filter(|v| v.as_array().is_none_or(|a| !a.is_empty())) {
|
||||
Some(v) => {
|
||||
let list = v
|
||||
.as_array()
|
||||
@@ -626,40 +670,39 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => {
|
||||
let mut names: Vec<String> = s
|
||||
.model
|
||||
.step(&step)
|
||||
.step(step)
|
||||
.map(|st| st.boundary_conditions.iter().filter_map(|bc| s.model.boundary_condition(bc)?.target().set().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
};
|
||||
// Σ R and Σ (x − about) × R over nodes (undeformed positions).
|
||||
let sum = |nodes: &mut dyn Iterator<Item = u32>| {
|
||||
let (mut force, mut moment, mut count) = ([0.0; 3], [0.0; 3], 0usize);
|
||||
for label in nodes {
|
||||
let (Ok(i), Some(node)) = (r.nodes.binary_search(&label), s.model.node(label)) else { continue };
|
||||
let Some(f) = r.reaction.get(i) else { continue };
|
||||
let d = [node.xyz[0] - about[0], node.xyz[1] - about[1], node.xyz[2] - about[2]];
|
||||
for k in 0..3 {
|
||||
force[k] += f[k];
|
||||
}
|
||||
moment[0] += d[1] * f[2] - d[2] * f[1];
|
||||
moment[1] += d[2] * f[0] - d[0] * f[2];
|
||||
moment[2] += d[0] * f[1] - d[1] * f[0];
|
||||
count += 1;
|
||||
})
|
||||
}
|
||||
|
||||
/// Σ R and Σ (x − about) × R over `nodes` (undeformed positions), the reaction of the node of
|
||||
/// index `i` in the result's order being `reaction(i)`: {nodes, force, moment}.
|
||||
pub(crate) fn reaction_sum(
|
||||
s: &Session,
|
||||
r: &fieldcraft_solver::StaticResult,
|
||||
reaction: &dyn Fn(usize) -> Option<[f64; 3]>,
|
||||
nodes: &mut dyn Iterator<Item = u32>,
|
||||
about: [f64; 3],
|
||||
) -> Value {
|
||||
let (mut force, mut moment, mut count) = ([0.0; 3], [0.0; 3], 0usize);
|
||||
for label in nodes {
|
||||
let (Ok(i), Some(node)) = (r.nodes.binary_search(&label), s.model.node(label)) else { continue };
|
||||
let Some(f) = reaction(i) else { continue };
|
||||
let d = [node.xyz[0] - about[0], node.xyz[1] - about[1], node.xyz[2] - about[2]];
|
||||
for k in 0..3 {
|
||||
force[k] += f[k];
|
||||
}
|
||||
json!({"nodes": count, "force": force, "moment": moment})
|
||||
};
|
||||
let mut per_set = Vec::with_capacity(sets.len());
|
||||
for name in &sets {
|
||||
let resolved = s.model.resolve_set(name)?;
|
||||
let mut row = sum(&mut resolved.nodes.iter().copied());
|
||||
row["set"] = json!(name);
|
||||
per_set.push(row);
|
||||
moment[0] += d[1] * f[2] - d[2] * f[1];
|
||||
moment[1] += d[2] * f[0] - d[0] * f[2];
|
||||
moment[2] += d[0] * f[1] - d[1] * f[0];
|
||||
count += 1;
|
||||
}
|
||||
let total = sum(&mut r.nodes.iter().copied());
|
||||
Ok(json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total, "applied": r.applied_total}))
|
||||
json!({"nodes": count, "force": force, "moment": moment})
|
||||
}
|
||||
|
||||
/// Most files one `results.export.vtu` writes.
|
||||
|
||||
@@ -90,7 +90,7 @@ pub const EXPLICIT: CommandSpec = CommandSpec {
|
||||
pub const HISTORY: CommandSpec = CommandSpec {
|
||||
id: "solve.history",
|
||||
label: "Result Histories",
|
||||
params: r#"{"step": "Impact", "every": 1} — the time histories of a solved explicit step: time, increment, energies, momentum, constraint force, the normal force across each contact (in `contacts` order) and the history nodes' displacement and velocity; `every` keeps one sample in so many (by default enough to return at most 10000)"#,
|
||||
params: r#"{"step": "Impact", "every": 1, "sets": ["top"], "node": 21} — the time histories of a solved explicit step: time, increment, energies, momentum, constraint force, the normal force across each contact (in `contacts` order) and the history nodes' displacement and velocity; or the frame histories of a nonlinear static step (one frame per converged increment, from load factor 0): `frames` (indices for results.frame), `load_factor` and, per set of `sets` (default the sets of the step's boundary conditions), the mean displacement of its nodes and the sum of their reactions per frame (a force–travel curve), with `node` that node's displacement too; `every` keeps one sample in so many (by default enough to return at most 10000)"#,
|
||||
form: Some(super::forms::SOLVE_HISTORY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -100,7 +100,7 @@ pub const HISTORY: CommandSpec = CommandSpec {
|
||||
pub const FLUID: CommandSpec = CommandSpec {
|
||||
id: "solve.fluid",
|
||||
label: "Solve Fluid Step",
|
||||
params: r#"{"step": "Flow", "backend": "cpu", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass, the final force on every wall and, with heat, the thermal relaxation time, the temperature range and the heat into every wall; `backend` is `cpu` (the default: double precision, the same result on every machine) or `gpu` (the step on the GPU `FIELDCRAFT_GPU` picks, single precision, within the backend's stated tolerance of the CPU; an error when the build has none); the caps may only be lowered; `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Flow", "backend": "cpu", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass, the final force on every wall and, with heat, the thermal relaxation time, the temperature range and the heat into every wall; with fans and porous zones, each one's volume flow and its pressure rise (a fan's operating point: `pressure_rise` from its curve, and `measured_pressure_rise`) or `pressure_drop`; the measured values are the static-pressure difference between the fluid cell layers just outside the zone, so they include the wall friction over its thickness plus one cell (final and mean over the last quarter of the run); `backend` is `cpu` (the default: double precision, the same result on every machine) or `gpu` (the step on the GPU `FIELDCRAFT_GPU` picks, single precision, within the backend's stated tolerance of the CPU; an error when the build has none); the caps may only be lowered; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_FLUID),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::FluidDomain, Requirement::Step],
|
||||
@@ -416,8 +416,13 @@ fn explicit_summary(r: &ExplicitResult) -> Value {
|
||||
fn history(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step: String = req(p, "step")?;
|
||||
let result = s.result(&step).ok_or_else(|| EngineError::Params(format!("step `{step}` has no result; solve it first")))?;
|
||||
if let crate::results::Kind::Static(r) = result.kind()
|
||||
&& r.nonlinear.is_some()
|
||||
{
|
||||
return nonlinear_history(s, &step, r, p);
|
||||
}
|
||||
let StepResult::Explicit(r) = result.as_ref() else {
|
||||
return Err(EngineError::Params(format!("step `{step}` is not an explicit dynamic step; it has no histories")));
|
||||
return Err(EngineError::Params(format!("step `{step}` is neither an explicit dynamic nor a nonlinear static step; it has no histories")));
|
||||
};
|
||||
let h = &r.history;
|
||||
let samples = h.time.len();
|
||||
@@ -456,6 +461,62 @@ fn history(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// solve.history of a nonlinear static step: per frame the load factor and, per set, the mean
|
||||
/// displacement of its nodes and their reaction sum.
|
||||
fn nonlinear_history(s: &Session, step: &str, r: &fieldcraft_solver::StaticResult, p: &Value) -> Result<Value> {
|
||||
let frames = r.nonlinear.as_ref().map(|h| h.frames.as_slice()).unwrap_or_default();
|
||||
let every = match opt::<u64>(p, "every")? {
|
||||
Some(0) => return Err(EngineError::param("every", ProblemKind::Below(1.0))),
|
||||
Some(k) => usize::try_from(k).unwrap_or(usize::MAX),
|
||||
None => frames.len().div_ceil(MAX_HISTORY_SAMPLES).max(1),
|
||||
};
|
||||
let node = match p.get("node") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let label = v.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| EngineError::invalid("node", "must be a node label"))?;
|
||||
let i =
|
||||
r.nodes.binary_search(&label).map_err(|_| EngineError::invalid("node", format!("node {label} has no result in step `{step}`")))?;
|
||||
Some((label, i))
|
||||
}
|
||||
};
|
||||
let picked: Vec<usize> = (0..frames.len()).step_by(every).collect();
|
||||
let mut sets = Vec::new();
|
||||
for name in super::results::reaction_sets(s, step, p)? {
|
||||
let resolved = s.model.resolve_set(&name)?;
|
||||
let at: Vec<usize> = resolved.nodes.iter().filter_map(|l| r.nodes.binary_search(l).ok()).collect();
|
||||
if at.is_empty() {
|
||||
return Err(EngineError::Params(format!("set `{name}` has no nodes with results in step `{step}`")));
|
||||
}
|
||||
let (mut displacement, mut reaction) = (Vec::with_capacity(picked.len()), Vec::with_capacity(picked.len()));
|
||||
for f in picked.iter().filter_map(|k| frames.get(*k)) {
|
||||
let (mut u, mut force) = ([0.0; 3], [0.0; 3]);
|
||||
for &i in &at {
|
||||
let (d, q) = (f.displacement.get(i).copied().unwrap_or_default(), f.reaction_at(i));
|
||||
for k in 0..3 {
|
||||
u[k] += d[k];
|
||||
force[k] += q[k];
|
||||
}
|
||||
}
|
||||
let n = at.len().max(1) as f64;
|
||||
displacement.push(u.map(|c| c / n));
|
||||
reaction.push(force);
|
||||
}
|
||||
sets.push(json!({"set": name, "nodes": at.len(), "displacement": displacement, "reaction": reaction}));
|
||||
}
|
||||
let mut out = json!({
|
||||
"step": step,
|
||||
"every": every,
|
||||
"frames": picked,
|
||||
"load_factor": picked.iter().map(|k| frames.get(*k).map(|f| f.load_factor)).collect::<Vec<_>>(),
|
||||
"sets": sets,
|
||||
});
|
||||
if let Some((label, i)) = node {
|
||||
out["node"] =
|
||||
json!({"node": label, "displacement": picked.iter().map(|k| frames.get(*k).and_then(|f| f.displacement.get(i))).collect::<Vec<_>>()});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The surfaces of a fluid domain's obstacles: bodies of the open design (triangulated at the
|
||||
/// domain's tolerance) or imported surfaces, by name.
|
||||
fn obstacles(s: &Session, domain: &FluidDomain) -> Result<Vec<Obstacle>> {
|
||||
@@ -530,6 +591,28 @@ fn fluid_summary(r: &FluidResult) -> Value {
|
||||
json!({"name": h.name, "body": h.body, "final": h.power.last(), "mean": mean})
|
||||
})
|
||||
.collect();
|
||||
let tail_mean = |v: &[f64]| {
|
||||
let recent = v.get(v.len().saturating_sub(tail)..).unwrap_or_default();
|
||||
recent.iter().sum::<f64>() / recent.len().max(1) as f64
|
||||
};
|
||||
let zones: Vec<Value> = r
|
||||
.zones
|
||||
.iter()
|
||||
.map(|z| {
|
||||
let mut v = json!({
|
||||
"name": z.name,
|
||||
"kind": if z.fan { "fan" } else { "porous" },
|
||||
"flow": {"final": z.flow.last(), "mean": tail_mean(&z.flow)},
|
||||
});
|
||||
if z.fan {
|
||||
v["pressure_rise"] = json!({"final": z.fan_rise.last(), "mean": tail_mean(&z.fan_rise)});
|
||||
v["measured_pressure_rise"] = json!({"final": z.pressure.last(), "mean": tail_mean(&z.pressure)});
|
||||
} else {
|
||||
v["pressure_drop"] = json!({"final": z.pressure.last(), "mean": tail_mean(&z.pressure)});
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let temperature = last.filter(|f| !f.temperature.is_empty()).map(|f| {
|
||||
let fluid = f.temperature.iter().zip(&r.solid).filter(|(_, solid)| !**solid).map(|(t, _)| f64::from(*t));
|
||||
let (lo, hi) = fluid.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), t| (lo.min(t), hi.max(t)));
|
||||
@@ -556,10 +639,35 @@ fn fluid_summary(r: &FluidResult) -> Value {
|
||||
"tau_thermal": r.stats.tau_thermal,
|
||||
"temperature": temperature,
|
||||
"heat": heat,
|
||||
"stop": r.steady.as_ref().map(|s| steady_summary(s, &r.stats)),
|
||||
"zones": zones,
|
||||
"warnings": r.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a run to steady state stopped, when, and its monitor history.
|
||||
fn steady_summary(s: &fieldcraft_lbm::SteadyReport, stats: &fieldcraft_lbm::FluidStats) -> Value {
|
||||
let value = |v: &Vec<f64>| if let [x] = v.as_slice() { json!(x) } else { json!(v) };
|
||||
let history: Vec<Value> = s
|
||||
.samples
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({"step": m.step, "time": m.time,
|
||||
"values": m.values.iter().map(value).collect::<Vec<_>>(), "changes": m.changes})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"reason": s.reason.name(),
|
||||
"steps": stats.steps,
|
||||
"time": stats.end_time,
|
||||
"tolerance": s.tolerance,
|
||||
"window": s.window,
|
||||
"quantities": s.quantities,
|
||||
"message": s.message,
|
||||
"history": history,
|
||||
})
|
||||
}
|
||||
|
||||
fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
|
||||
if let Some(names) = opt::<Vec<String>>(p, "forces")? {
|
||||
if names.len() > MAX_PROBE_FORCES {
|
||||
@@ -1287,6 +1395,176 @@ pub(crate) mod tests {
|
||||
assert!(s.execute("solve.fluid", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A duct 60 × 4 × 4 with walls around, periodic along x (a closed loop): a fan at x 2..4
|
||||
/// blowing +x and a filter at x 10..12 (Darcy, permeability 0.05), through commands.
|
||||
fn fan_duct(s: &mut Session) {
|
||||
let periodic = json!({"type": "periodic"});
|
||||
let script = json!([
|
||||
{"command": "model.material.create", "params": {"name": "Air", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1}}},
|
||||
{"command": "model.fluid_domain.create", "params": {"name": "Duct", "material": "Air", "min": [0, 0, 0], "max": [60, 4, 4], "spacing": 1,
|
||||
"faces": {"x_min": periodic, "x_max": periodic},
|
||||
"porous": [{"name": "Grille", "min": [10, 0, 0], "max": [12, 4, 4], "axis": "x", "resistance": {"type": "darcy_forchheimer", "permeability": 0.05}}],
|
||||
"fans": [{"name": "Fan", "min": [2, 0, 0], "max": [4, 4, 4], "direction": "+x", "curve": [[0, 0.002], [1.0, 0]], "relaxation": 0.05}]}},
|
||||
{"command": "model.step.create", "params": {"name": "Blow", "procedure": {"type": "fluid", "domain": "Duct", "steps": 3000, "time_step": 1, "frames": 2}}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fan_drives_a_duct_through_a_grille_and_the_reply_reports_the_operating_point() {
|
||||
let mut s = Session::default();
|
||||
fan_duct(&mut s);
|
||||
let r = s.execute("solve.fluid", &json!({})).unwrap();
|
||||
let zone = |name: &str| r["zones"].as_array().unwrap().iter().find(|z| z["name"] == name).cloned().unwrap();
|
||||
let (fan, grille) = (zone("Fan"), zone("Grille"));
|
||||
assert_eq!(fan["kind"], "fan");
|
||||
assert_eq!(grille["kind"], "porous");
|
||||
let q = fan["flow"]["final"].as_f64().unwrap();
|
||||
let rise = fan["pressure_rise"]["final"].as_f64().unwrap();
|
||||
assert!(q > 0.0 && rise > 0.0 && rise < 0.002, "{fan}");
|
||||
// On its curve: Δp = 0.002 (1 − Q).
|
||||
assert!((rise - 0.002 * (1.0 - q)).abs() < 0.01 * rise, "{fan}");
|
||||
// What goes through the fan goes through the filter.
|
||||
assert!((grille["flow"]["final"].as_f64().unwrap() / q - 1.0).abs() < 0.01, "{grille}");
|
||||
// The measured differences are between the fluid layers next to each zone, so they
|
||||
// include the duct's wall shear over t + Δx = 3 (G per length). Around the loop:
|
||||
// the fan loses G·3 between its push and what is measured, the filter adds G·3 to its
|
||||
// own loss, and the duct between them (60 − 2·3 = 54) loses the rest.
|
||||
let drop = grille["pressure_drop"]["final"].as_f64().unwrap();
|
||||
let measured = fan["measured_pressure_rise"]["final"].as_f64().unwrap();
|
||||
let g = (rise - measured) / 3.0;
|
||||
assert!(g > 0.0, "{fan}");
|
||||
assert!(((measured - drop) / 54.0 / g - 1.0).abs() < 0.01, "loop momentum balance: {fan} {grille}");
|
||||
// The filter's own loss is Darcy's (μ/K) u t at the mean velocity Q / 16 (linear, so
|
||||
// the duct's profile does not matter), t = 2, and dominates the filter's drop. What
|
||||
// remains above it (about 6%) is wall shear the duct's G does not cover: the filter
|
||||
// flattens the profile, which shears the walls harder inside it, and the flow redevelops
|
||||
// just after it. This is an integration bound; the precise check of the resistance law
|
||||
// is wall-free: `a_porous_plate_drops_the_pressure_by_the_darcy_forchheimer_law` in
|
||||
// crates/lbm/tests/zones.rs (within 0.5%).
|
||||
let darcy = 0.1 / 0.05 * (q / 16.0) * 2.0;
|
||||
assert!(((drop - 3.0 * g) / darcy - 1.0).abs() < 0.1, "filter loss {} vs Darcy {darcy}", drop - 3.0 * g);
|
||||
assert!(darcy > 0.5 * drop, "the filter, not the walls, dominates its drop: {darcy} of {drop}");
|
||||
assert!(r["mass"]["relative_change"].as_f64().unwrap().abs() < 1e-10, "{r}");
|
||||
|
||||
// Hostile zones are refused and leave the model as it was.
|
||||
let before = s.model.clone();
|
||||
let fan_with = |patch: Value| {
|
||||
let mut f = json!({"name": "Fan", "min": [2, 0, 0], "max": [4, 4, 4], "direction": "+x", "curve": [[0, 0.002], [1.0, 0]]});
|
||||
if let (Some(f), Some(p)) = (f.as_object_mut(), patch.as_object()) {
|
||||
f.extend(p.clone());
|
||||
}
|
||||
json!({"name": "Duct", "material": "Air", "min": [0, 0, 0], "max": [20, 4, 4], "spacing": 1, "fans": [f]})
|
||||
};
|
||||
for patch in [
|
||||
json!({"curve": [[0, 1]]}),
|
||||
json!({"curve": [[0, 1], [0, 0]]}),
|
||||
json!({"curve": [[0, 1], [1, 2]]}),
|
||||
json!({"curve": [[-1, 1], [1, 0]]}),
|
||||
json!({"curve": [[0, -1], [1, -2]]}),
|
||||
json!({"curve": vec![[0.0, 1.0]; 2000]}),
|
||||
json!({"curve": "fast"}),
|
||||
json!({"direction": "up"}),
|
||||
json!({"relaxation": 0}),
|
||||
json!({"relaxation": 5}),
|
||||
json!({"min": [5, 0, 0], "max": [4, 4, 4]}),
|
||||
json!({"name": ""}),
|
||||
] {
|
||||
assert!(s.execute("model.fluid_domain.edit", &fan_with(patch.clone())).is_err(), "{patch}");
|
||||
assert_eq!(s.model, before);
|
||||
}
|
||||
for porous in [
|
||||
json!({"name": "G", "min": [10, 0, 0], "max": [12, 4, 4], "axis": "x", "resistance": {"type": "loss_coefficient", "coefficient": -1}}),
|
||||
json!({"name": "G", "min": [10, 0, 0], "max": [12, 4, 4], "axis": "w", "resistance": {"type": "loss_coefficient", "coefficient": 1}}),
|
||||
json!({"name": "G", "min": [10, 0, 0], "max": [12, 4, 4], "axis": "x", "resistance": {"type": "darcy_forchheimer", "permeability": 0}}),
|
||||
json!({"name": "G", "min": [10, 0, 0], "max": [12, 4, 4], "axis": "x", "resistance": {"type": "darcy_forchheimer", "permeability": 1, "inertial": -3}}),
|
||||
] {
|
||||
let p = json!({"name": "Duct", "material": "Air", "min": [0, 0, 0], "max": [20, 4, 4], "spacing": 1, "porous": [porous]});
|
||||
assert!(s.execute("model.fluid_domain.edit", &p).is_err(), "{p}");
|
||||
assert_eq!(s.model, before);
|
||||
}
|
||||
// A fan outside the box, or one too strong for any time step, is refused when the
|
||||
// step solves.
|
||||
let outside = fan_with(json!({"min": [30, 0, 0], "max": [31, 4, 4]}));
|
||||
s.execute("model.fluid_domain.edit", &outside).unwrap();
|
||||
let e = s.execute("solve.fluid", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("Fan"), "{e}");
|
||||
s.execute("model.fluid_domain.edit", &fan_with(json!({"curve": [[0, f64::MAX], [1, f64::MIN]]}))).unwrap();
|
||||
assert!(s.execute("solve.fluid", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fluid_step_stops_at_steady_state_and_says_why() {
|
||||
let mut s = Session::default();
|
||||
couette(&mut s);
|
||||
let full = s.execute("solve.fluid", &json!({"step": "Shear"})).unwrap();
|
||||
assert!(full["stop"].is_null(), "{full}");
|
||||
let steady =
|
||||
json!({"tolerance": 1e-5, "window": 1, "monitors": [{"type": "force", "faces": ["y_min"]}, {"type": "mass_flow", "face": "x_max"}]});
|
||||
s.execute(
|
||||
"model.step.create",
|
||||
&json!({"name": "Settle", "procedure": {"type": "fluid", "domain": "Gap", "end_time": 50, "frames": 4, "steady": steady}}),
|
||||
)
|
||||
.unwrap();
|
||||
let r = s.execute("solve.fluid", &json!({"step": "Settle"})).unwrap();
|
||||
let stop = &r["stop"];
|
||||
assert_eq!(stop["reason"], "steady", "{stop}");
|
||||
assert_eq!(stop["quantities"], json!(["velocity", "force y_min", "mass_flow x_max"]));
|
||||
assert_eq!(stop["steps"], r["steps"]);
|
||||
assert!(r["steps"].as_u64().unwrap() < full["steps"].as_u64().unwrap() / 2, "{r}");
|
||||
assert_eq!(r["frames"].as_array().unwrap().last(), Some(&r["end_time"]), "a frame at the stop: {r}");
|
||||
let history = stop["history"].as_array().unwrap();
|
||||
assert!(history[0]["changes"][0].is_null() && history.last().unwrap()["changes"][0].as_f64().unwrap() < 1e-5, "{stop}");
|
||||
// The wall shear μ U / H over the wall's area, as the full run gives it.
|
||||
let shear = history.last().unwrap()["values"][1][0].as_f64().unwrap();
|
||||
assert!((shear / (0.1 * 0.1 * 0.125 * 0.125) - 1.0).abs() < 1e-3, "{shear}");
|
||||
|
||||
// Hostile rules are refused and leave the model as it was.
|
||||
let hostile = [
|
||||
json!({"tolerance": -1}),
|
||||
json!({"tolerance": 2}),
|
||||
json!({"tolerance": "tight"}),
|
||||
json!({"window": 0}),
|
||||
json!({"window": -1e308}),
|
||||
json!({"min_time": -1}),
|
||||
json!({"divergence": 0}),
|
||||
json!({"monitors": [{"type": "vorticity"}]}),
|
||||
json!({"monitors": [{"type": "force", "faces": []}]}),
|
||||
json!({"monitors": [{"type": "force", "faces": vec!["x_min"; 5000]}]}),
|
||||
json!({"monitors": [{"type": "mass_flow", "face": "top"}]}),
|
||||
json!({"monitors": vec![json!({"type": "mean_temperature"}); 500]}),
|
||||
json!({"monitors": 3}),
|
||||
json!({"oops": 1}),
|
||||
json!(7),
|
||||
];
|
||||
for steady in &hostile {
|
||||
let before = s.model.clone();
|
||||
let p = json!({"name": "Bad", "procedure": {"type": "fluid", "domain": "Gap", "end_time": 50, "steady": steady}});
|
||||
assert!(s.execute("model.step.create", &p).is_err(), "{steady}");
|
||||
assert_eq!(s.model, before, "{steady}");
|
||||
}
|
||||
// Rules the model accepts but the flow cannot measure fail the solve, not the session.
|
||||
for (name, monitors) in [
|
||||
("Ghost", json!([{"type": "force", "faces": [{"body": "Ghost", "face": "F1"}]}])),
|
||||
("Cold", json!([{"type": "mean_temperature"}])),
|
||||
("Huge", json!([{"type": "heat", "faces": ["y_min"]}])),
|
||||
] {
|
||||
let steady = json!({"window": 1e300, "min_time": 1e300, "monitors": monitors});
|
||||
s.execute("model.step.create", &json!({"name": name, "procedure": {"type": "fluid", "domain": "Gap", "end_time": 50, "steady": steady}}))
|
||||
.unwrap();
|
||||
assert!(s.execute("solve.fluid", &json!({"step": name})).is_err(), "{name}");
|
||||
}
|
||||
// A huge window and minimum time just run the full length.
|
||||
s.execute(
|
||||
"model.step.create",
|
||||
&json!({"name": "Long", "procedure": {"type": "fluid", "domain": "Gap", "end_time": 50, "steady": {"window": 1e300, "min_time": 1e300}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let r = s.execute("solve.fluid", &json!({"step": "Long"})).unwrap();
|
||||
assert_eq!(r["stop"]["reason"], "max_time", "{r}");
|
||||
assert_eq!(r["steps"], full["steps"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_fluid_hostile_params_never_panic() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+125
-14
@@ -16,7 +16,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult};
|
||||
pub const XY: CommandSpec = CommandSpec {
|
||||
id: "results.xy",
|
||||
label: "XY Plot",
|
||||
params: r#"{"step": "Load", "y": {"field": "displacement_z", "node": 21}, "x": "auto", "every": 1, "path": "tip.csv"} — a quantity of a solved step against its natural axis: `x` "load_factor" (nonlinear static), "time" (explicit, transient heat), "mode" (frequency and buckling) or "auto" (the step's own, the default). `y` is {"field": <a contour field id>, "node": <label>} (a nodal field per frame or mode: on nonlinear static steps the displacements, whose frames start at load factor 0; on heat steps the temperature), {"energy": "kinetic|internal|hourglass|viscous|external|constraint|contact|friction|plastic|eroded|added_kinetic|balance"}, {"history": "constraint_force_x|_y|_z|momentum_x|_y|_z|increment"}, {"history_node": 21, "quantity": "displacement|displacement_x|…|velocity_z"} (explicit histories), "frequency" (frequency steps) or "load_factor" (buckling steps). Reactions per increment are not stored by the nonlinear solver, so a reaction cannot be plotted against the load factor; an explicit step's `constraint_force` history is its reaction against time. At most 100000 points: `every` keeps one in so many (by default enough to fit). A stale result is refused. With `path` the series is written as CSV (a header line, then x,y rows). Replies {step, x: {label, values}, y: {label, values}, points, every, frames?} (`frames`: the field frame of each point, for results.frame)"#,
|
||||
params: r#"{"step": "Load", "y": {"field": "displacement_z", "node": 21}, "x": "auto", "every": 1, "path": "tip.csv"} — a quantity of a solved step against its natural axis: `x` "load_factor" (nonlinear static), "time" (explicit, transient heat), "mode" (frequency and buckling) or "auto" (the step's own, the default). `y` is {"field": <a contour field id>, "node": <label>} (a nodal field per frame or mode: on nonlinear static steps the displacements, whose frames start at load factor 0; on heat steps the temperature), {"energy": "kinetic|internal|hourglass|viscous|external|constraint|contact|friction|plastic|eroded|added_kinetic|balance"}, {"history": "constraint_force_x|_y|_z|momentum_x|_y|_z|increment"}, {"history_node": 21, "quantity": "displacement|displacement_x|…|velocity_z"} (explicit histories), {"reaction": "top", "component": "x|y|z|magnitude"} (nonlinear static: the reaction sum on a set per frame), "frequency" (frequency steps) or "load_factor" (buckling steps). On nonlinear static steps `x` may instead be {"displacement": "top", "component": "x|y|z|magnitude"} (the mean displacement of a set's nodes per frame) or {"displacement_node": 21, "component": …}: a reaction against it is a force–travel curve. An explicit step's `constraint_force` history is its reaction against time. At most 100000 points: `every` keeps one in so many (by default enough to fit). A stale result is refused. With `path` the series is written as CSV (a header line, then x,y rows). Replies {step, x: {label, values}, y: {label, values}, points, every, frames?} (`frames`: the field frame of each point, for results.frame)"#,
|
||||
form: Some(super::forms::RESULTS_XY),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -187,10 +187,23 @@ fn series<'a>(s: &'a Session, step: &str, r: &'a StepResult, axis: Axis, y: &Val
|
||||
};
|
||||
return Ok(Series { x_label, y_label, len, point: Box::new(point), frames });
|
||||
}
|
||||
if y.get("reaction").is_some() {
|
||||
return Err(y_error(
|
||||
"reactions are not stored per increment, so they cannot be plotted against the load factor (results.reactions gives the final sums; an explicit step's {\"history\": \"constraint_force_z\"} is its reaction against time)",
|
||||
));
|
||||
if let Some(set) = y.get("reaction") {
|
||||
let frames = nonlinear_frames(step, r).map_err(y_error)?;
|
||||
let set = set.as_str().ok_or_else(|| y_error("`reaction` must be a set name"))?;
|
||||
let (suffix, title) = vector_component(y).map_err(y_error)?;
|
||||
let at = set_indices(s, r, set)?;
|
||||
let point = move |i: usize| -> Result<(f64, f64)> {
|
||||
let Some(f) = frames.get(i) else { return Ok((f64::NAN, f64::NAN)) };
|
||||
let sum = at.iter().map(|k| f.reaction_at(*k)).fold([0.0; 3], |a, v| std::array::from_fn(|c| a[c] + v[c]));
|
||||
Ok((f.load_factor, component(&sum, suffix).unwrap_or(f64::NAN)))
|
||||
};
|
||||
return Ok(Series {
|
||||
x_label,
|
||||
y_label: format!("{} on {set}", with_unit(&format!("Reaction {title}"), Quantity::Force.unit(units).as_deref())),
|
||||
len: frames.len(),
|
||||
point: Box::new(point),
|
||||
frames: true,
|
||||
});
|
||||
}
|
||||
let Kind::Explicit(e) = r.kind() else { return Err(not_explicit()) };
|
||||
let h = &e.history;
|
||||
@@ -271,7 +284,70 @@ fn series<'a>(s: &'a Session, step: &str, r: &'a StepResult, axis: Axis, y: &Val
|
||||
frames: false,
|
||||
});
|
||||
}
|
||||
Err(y_error("must have one of `field` (with `node`), `energy`, `history` or `history_node` (with `quantity`)"))
|
||||
Err(y_error("must have one of `field` (with `node`), `reaction`, `energy`, `history` or `history_node` (with `quantity`)"))
|
||||
}
|
||||
|
||||
/// The frames of a nonlinear static step.
|
||||
fn nonlinear_frames<'a>(step: &str, r: &'a StepResult) -> std::result::Result<&'a [fieldcraft_solver::nonlinear::Frame], String> {
|
||||
match r.kind() {
|
||||
Kind::Static(st) => st.nonlinear.as_ref().map(|h| h.frames.as_slice()).ok_or_else(|| {
|
||||
format!("step `{step}` is a linear static step: one state (a nonlinear static step keeps its reactions and displacements per increment)")
|
||||
}),
|
||||
_ => Err(format!(
|
||||
"step `{step}` is not a nonlinear static step: reactions and set displacements per frame are kept for nonlinear static steps"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A vector's `component` (x, y, z or magnitude, the default): its [`component`] suffix and
|
||||
/// its title.
|
||||
fn vector_component(v: &Value) -> std::result::Result<(&'static str, &'static str), String> {
|
||||
match v.get("component").map(|c| c.as_str()) {
|
||||
None | Some(Some("magnitude")) => Ok(("", "magnitude")),
|
||||
Some(Some("x")) => Ok(("_x", "X")),
|
||||
Some(Some("y")) => Ok(("_y", "Y")),
|
||||
Some(Some("z")) => Ok(("_z", "Z")),
|
||||
_ => Err("`component` must be \"x\", \"y\", \"z\" or \"magnitude\"".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The result indices of a set's nodes (an error for an unknown set or one without results).
|
||||
fn set_indices(s: &Session, r: &StepResult, set: &str) -> Result<Vec<usize>> {
|
||||
let nodes = r.nodes();
|
||||
let at: Vec<usize> = s.model.resolve_set(set)?.nodes.iter().filter_map(|l| nodes.binary_search(l).ok()).collect();
|
||||
if at.is_empty() {
|
||||
return Err(EngineError::Params(format!("set `{set}` has no nodes with results")));
|
||||
}
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
/// `x` as a travel on a nonlinear static step: {"displacement": set} (the mean over its nodes)
|
||||
/// or {"displacement_node": label}, and a component. Its label and value per frame.
|
||||
fn travel<'a>(s: &'a Session, step: &str, r: &'a StepResult, x: &Value) -> Result<(String, Box<dyn Fn(usize) -> f64 + 'a>)> {
|
||||
let x_error = |why: String| EngineError::invalid("x", why);
|
||||
let frames = nonlinear_frames(step, r).map_err(x_error)?;
|
||||
let (suffix, title) = vector_component(x).map_err(x_error)?;
|
||||
let (at, of) = match (x.get("displacement"), x.get("displacement_node")) {
|
||||
(Some(set), None) => {
|
||||
let set = set.as_str().ok_or_else(|| x_error("`displacement` must be a set name".into()))?;
|
||||
(set_indices(s, r, set)?, format!("of {set} (mean)"))
|
||||
}
|
||||
(None, Some(node)) => {
|
||||
let label =
|
||||
node.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| x_error("`displacement_node` must be a node label".into()))?;
|
||||
let i = r.nodes().binary_search(&label).map_err(|_| x_error(format!("node {label} has no result in step `{step}`")))?;
|
||||
(vec![i], format!("at node {label}"))
|
||||
}
|
||||
_ => return Err(x_error("an object must have one of `displacement` (a set) or `displacement_node` (a node label)".into())),
|
||||
};
|
||||
let label = format!("{} {of}", with_unit(&format!("Displacement {title}"), Quantity::Length.unit(s.model.units()).as_deref()));
|
||||
let n = at.len() as f64;
|
||||
let value = move |i: usize| -> f64 {
|
||||
let Some(f) = frames.get(i) else { return f64::NAN };
|
||||
let sum = at.iter().map(|k| f.displacement.get(*k).copied().unwrap_or_default()).fold([0.0; 3], |a, v| std::array::from_fn(|c| a[c] + v[c]));
|
||||
component(&sum.map(|c| c / n), suffix).unwrap_or(f64::NAN)
|
||||
};
|
||||
Ok((label, Box::new(value)))
|
||||
}
|
||||
|
||||
/// The step's own axis.
|
||||
@@ -290,9 +366,18 @@ fn natural(step: &str, r: &StepResult) -> Result<Axis> {
|
||||
fn xy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = step_for(s, p, Want::Any)?;
|
||||
let (r, current) = s.latest_result(&step).ok_or_else(|| EngineError::Params(format!("step `{step}` has no result")))?;
|
||||
let axis = match opt::<String>(p, "x")?.as_deref() {
|
||||
None | Some("auto") => None,
|
||||
Some(id) => Some(Axis::parse(id).ok_or_else(|| EngineError::invalid("x", "must be \"auto\", \"load_factor\", \"time\" or \"mode\""))?),
|
||||
let x_travel = p.get("x").filter(|x| x.is_object());
|
||||
let axis = match x_travel {
|
||||
Some(_) => None,
|
||||
None => match opt::<String>(p, "x")?.as_deref() {
|
||||
None | Some("auto") => None,
|
||||
Some(id) => Some(Axis::parse(id).ok_or_else(|| {
|
||||
EngineError::invalid(
|
||||
"x",
|
||||
"must be \"auto\", \"load_factor\", \"time\", \"mode\" or a travel ({\"displacement\": set} or {\"displacement_node\": label})",
|
||||
)
|
||||
})?),
|
||||
},
|
||||
};
|
||||
let y = p.get("y").filter(|v| !v.is_null()).ok_or_else(|| EngineError::param("y", ProblemKind::Required))?;
|
||||
if !current {
|
||||
@@ -311,7 +396,17 @@ fn xy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(_) => Some(super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?),
|
||||
None => None,
|
||||
};
|
||||
let series = series(s, &step, r, own, y)?;
|
||||
let mut series = series(s, &step, r, own, y)?;
|
||||
let mut x_axis = own.id();
|
||||
if let Some(x) = x_travel {
|
||||
let (label, value) = travel(s, &step, r, x)?;
|
||||
if !series.frames {
|
||||
return Err(EngineError::invalid("x", "a travel needs a series per frame of the nonlinear static step"));
|
||||
}
|
||||
let point = series.point;
|
||||
series = Series { x_label: label, point: Box::new(move |i| Ok((value(i), point(i)?.1))), ..series };
|
||||
x_axis = "displacement";
|
||||
}
|
||||
let every = every.unwrap_or_else(|| series.len.div_ceil(MAX_POINTS).max(1));
|
||||
if series.len.div_ceil(every) > MAX_POINTS {
|
||||
return Err(EngineError::invalid("every", format!("{} points in steps of {every} are more than {MAX_POINTS}; raise `every`", series.len)));
|
||||
@@ -325,7 +420,7 @@ fn xy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut out = json!({
|
||||
"step": step,
|
||||
"x": {"axis": own.id(), "label": series.x_label, "values": xs},
|
||||
"x": {"axis": x_axis, "label": series.x_label, "values": xs},
|
||||
"y": {"label": series.y_label, "values": ys},
|
||||
"points": picked.len(),
|
||||
"every": every,
|
||||
@@ -432,11 +527,27 @@ mod tests {
|
||||
assert_eq!(lines.len(), x.len() + 1);
|
||||
assert_eq!(lines.last().unwrap().split(',').nth(1).unwrap().parse::<f64>().unwrap(), *y.last().unwrap());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
// Stresses are the final state's only; a linear step has one state; reactions are not stored.
|
||||
// Stresses are the final state's only; a linear step has one state.
|
||||
assert!(s.execute("results.xy", &json!({"step": "Large", "y": {"field": "von_mises", "node": 11}})).is_err());
|
||||
assert!(s.execute("results.xy", &json!({"step": "Bend", "y": {"field": "displacement_z", "node": 11}})).is_err());
|
||||
let e = s.execute("results.xy", &json!({"step": "Large", "y": {"reaction": "root"}})).unwrap_err().to_string();
|
||||
assert!(e.contains("not stored per increment"), "{e}");
|
||||
assert!(s.execute("results.xy", &json!({"step": "Bend", "y": {"reaction": "root"}})).is_err());
|
||||
// The root's reaction balances the dead end load at every frame: proportional to λ.
|
||||
let r = s.execute("results.xy", &json!({"step": "Large", "y": {"reaction": "root", "component": "z"}})).unwrap();
|
||||
let (x, y) = (values(&r, "x"), values(&r, "y"));
|
||||
let end = *y.last().unwrap();
|
||||
assert!(end.abs() > 0.0 && r["frames"].as_array().unwrap().len() == x.len(), "{r}");
|
||||
for (l, f) in x.iter().zip(&y) {
|
||||
assert!((f - l * end).abs() < 1e-6 * end.abs(), "λ {l}: {f} against {}", l * end);
|
||||
}
|
||||
// Against the tip's travel: the same reactions.
|
||||
let t = s
|
||||
.execute(
|
||||
"results.xy",
|
||||
&json!({"step": "Large", "y": {"reaction": "root", "component": "z"}, "x": {"displacement_node": 11, "component": "z"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(values(&t, "y"), y);
|
||||
assert_eq!(t["x"]["label"], "Displacement Z at node 11");
|
||||
// A stale result is refused.
|
||||
s.execute("mesh.node.add", &json!({"label": 99_999, "xyz": [0, 0, 0]})).unwrap();
|
||||
let e = s.execute("results.xy", &json!({"step": "Large", "y": {"field": "displacement_z", "node": 11}})).unwrap_err().to_string();
|
||||
|
||||
@@ -597,8 +597,10 @@ pub(crate) mod tests {
|
||||
history_times: Vec::new(),
|
||||
forces: Vec::new(),
|
||||
heat: Vec::new(),
|
||||
zones: Vec::new(),
|
||||
stats,
|
||||
warnings: Vec::new(),
|
||||
steady: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -926,6 +926,92 @@ mod tests {
|
||||
assert!(stiffer > 1.05 && stiffer < 2.5, "bonded {bonded} N, frictionless {free} N");
|
||||
}
|
||||
|
||||
/// The seal's force–travel curve (#204): every frame keeps the reaction on `top`. Squeezed
|
||||
/// by a prescribed travel, the top's mean travel is the load factor times the squeeze and its
|
||||
/// reaction grows with it at every frame (a smooth, stiffening curve), the base reacting
|
||||
/// equal and opposite; results.reactions of a frame and results.xy read the same numbers.
|
||||
/// Pressed by a force instead, the base reacts with the load factor times that force at
|
||||
/// every frame.
|
||||
#[test]
|
||||
fn the_rubber_seal_keeps_a_force_travel_curve() {
|
||||
let mut s = open("rubber-seal");
|
||||
s.execute("mesh.node.add", &json!({"label": 99_999, "xyz": [0, 0, 50]})).unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "loose", "nodes": [99_999]})).unwrap();
|
||||
s.execute("solve.nonlinear", &json!({})).unwrap();
|
||||
let h = s.execute("solve.history", &json!({"step": "Squeeze", "sets": ["top", "bottom"]})).unwrap();
|
||||
let floats = |v: &Value| -> Vec<f64> { v.as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect() };
|
||||
let lambda = floats(&h["load_factor"]);
|
||||
assert!(lambda.len() >= 6 && lambda[0] == 0.0 && lambda.last() == Some(&1.0), "{lambda:?}");
|
||||
let (top, bottom) = (&h["sets"][0], &h["sets"][1]);
|
||||
let force: Vec<f64> = top["reaction"].as_array().unwrap().iter().map(|f| f[2].as_f64().unwrap()).collect();
|
||||
let travel: Vec<f64> = top["displacement"].as_array().unwrap().iter().map(|u| u[2].as_f64().unwrap()).collect();
|
||||
let scale = force.last().unwrap().abs();
|
||||
for (k, l) in lambda.iter().enumerate() {
|
||||
assert!((travel[k] + l * SEAL_SQUEEZE).abs() < 1e-9, "frame {k}: travel {} at λ {l}", travel[k]);
|
||||
let base = bottom["reaction"][k][2].as_f64().unwrap();
|
||||
assert!((force[k] + base).abs() < 1e-6 * scale, "frame {k}: top {} base {base}", force[k]);
|
||||
}
|
||||
// The press pushes the top down (the reaction on it is negative), harder at every frame,
|
||||
// and the rubber stiffens in compression: the secant stiffness never falls.
|
||||
for k in 1..force.len() {
|
||||
assert!(force[k] < force[k - 1], "{force:?}");
|
||||
if k > 1 {
|
||||
let (a, b) = (force[k - 1] / travel[k - 1], force[k] / travel[k]);
|
||||
assert!(b > a * (1.0 - 1e-3), "secant stiffness fell from {a} to {b}: {force:?}");
|
||||
}
|
||||
}
|
||||
let last = s.execute("results.reactions", &json!({"step": "Squeeze", "sets": ["top"]})).unwrap();
|
||||
assert!((last["sets"][0]["force"][2].as_f64().unwrap() + scale).abs() < 1e-9 * scale, "{last}");
|
||||
let k = lambda.len() / 2;
|
||||
let at = s.execute("results.reactions", &json!({"step": "Squeeze", "sets": ["top"], "frame": k})).unwrap();
|
||||
assert_eq!((at["frame"].as_u64(), at["load_factor"].as_f64()), (Some(k as u64), Some(lambda[k])), "{at}");
|
||||
assert!((at["sets"][0]["force"][2].as_f64().unwrap() - force[k]).abs() < 1e-9 * scale, "{at}");
|
||||
let xy = s
|
||||
.execute(
|
||||
"results.xy",
|
||||
&json!({"step": "Squeeze", "y": {"reaction": "top", "component": "z"}, "x": {"displacement": "top", "component": "z"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(xy["x"]["axis"], "displacement");
|
||||
assert_eq!(floats(&xy["x"]["values"]), travel);
|
||||
assert!(floats(&xy["y"]["values"]).iter().zip(&force).all(|(a, b)| (a - b).abs() < 1e-9 * scale), "{xy}");
|
||||
// Hostile parameters are errors; so is a set without result nodes.
|
||||
for (id, p) in [
|
||||
("results.reactions", json!({"step": "Squeeze", "frame": 100000})),
|
||||
("results.reactions", json!({"step": "Squeeze", "frame": -1})),
|
||||
("results.reactions", json!({"step": "Squeeze", "sets": ["nope"], "frame": 1})),
|
||||
("solve.history", json!({"step": "Squeeze", "sets": ["nope"]})),
|
||||
("solve.history", json!({"step": "Squeeze", "node": 0})),
|
||||
("solve.history", json!({"step": "Squeeze", "every": 0})),
|
||||
("results.xy", json!({"step": "Squeeze", "y": {"reaction": "nope"}})),
|
||||
("results.xy", json!({"step": "Squeeze", "y": {"reaction": "top", "component": "w"}})),
|
||||
("results.xy", json!({"step": "Squeeze", "y": {"reaction": "top"}, "x": {"displacement": 3}})),
|
||||
("results.xy", json!({"step": "Squeeze", "y": {"reaction": "top"}, "x": {}})),
|
||||
] {
|
||||
assert!(s.execute(id, &p).is_err(), "{id} {p}");
|
||||
}
|
||||
// A set whose nodes have no results (a node outside the mesh) is an error too.
|
||||
let e = s.execute("solve.history", &json!({"step": "Squeeze", "sets": ["loose"]})).unwrap_err().to_string();
|
||||
assert!(e.contains("set `loose` has no nodes with results"), "{e}");
|
||||
// Load control: the top held laterally and pressed by a force.
|
||||
let press = -0.5 * scale;
|
||||
s.execute(
|
||||
"model.boundary_condition.edit",
|
||||
&json!({"name": "Press", "type": "displacement", "target": {"set": "top"}, "values": [0, 0, null, null, null, null]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("model.load.create", &json!({"name": "Push", "type": "force", "target": {"set": "top"}, "vector": [0, 0, press]})).unwrap();
|
||||
s.execute("model.step.edit", &json!({"name": "Squeeze", "loads": ["Push"]})).unwrap();
|
||||
s.execute("solve.nonlinear", &json!({})).unwrap();
|
||||
let h = s.execute("solve.history", &json!({"step": "Squeeze", "sets": ["bottom"]})).unwrap();
|
||||
let lambda = floats(&h["load_factor"]);
|
||||
assert!(lambda.len() >= 3, "{h}");
|
||||
for (k, l) in lambda.iter().enumerate() {
|
||||
let base = h["sets"][0]["reaction"][k][2].as_f64().unwrap();
|
||||
assert!((base + l * press).abs() < 1e-6 * press.abs(), "frame {k}: base {base} at λ {l}, applied {}", l * press);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flow_sample_opens_ready_to_solve() {
|
||||
let s = open("flow-past");
|
||||
|
||||
@@ -164,6 +164,12 @@ impl GpuFlow {
|
||||
|
||||
/// [`GpuFlow::new`] with `options`.
|
||||
pub fn with_options(flow: &Flow, policy: Policy, options: Options) -> Result<GpuFlow> {
|
||||
if let Some(zone) = flow.zones().first() {
|
||||
return Err(Error::Invalid(format!(
|
||||
"the GPU backend does not step fans or porous zones yet (`{}`): solve on the CPU (`backend: \"cpu\"`)",
|
||||
zone.name
|
||||
)));
|
||||
}
|
||||
let Some((adapter, tier)) = select::adapter(policy).map_err(gpu_error)? else {
|
||||
return Err(Error::Invalid(format!("{}=cpu: no GPU device; solve with `backend: \"cpu\"`", select::ENV)));
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ fn cell_updates_per_second() {
|
||||
time_step: Some(1.0),
|
||||
frames: 1,
|
||||
magic: None,
|
||||
steady: None,
|
||||
},
|
||||
);
|
||||
let (_, flow) = prepare(&m, &obstacles);
|
||||
|
||||
@@ -192,7 +192,7 @@ fn flow_past_a_sphere_matches_the_cpu_fields_and_forces() {
|
||||
1.0,
|
||||
1.0 / 6.0,
|
||||
d,
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(300), time_step: Some(1.0), frames: 1, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(300), time_step: Some(1.0), frames: 1, magic: None, steady: None },
|
||||
);
|
||||
let (setup, flow) = prepare(&m, std::slice::from_ref(&ball));
|
||||
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
|
||||
@@ -372,6 +372,69 @@ fn natural_convection_matches_the_cpu() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs to steady state take their decisions at the same steps on both backends: the plane
|
||||
/// Poiseuille channel and the de Vahl Davis cavity (Ra 10³, 16 cells) stop as steady at the same
|
||||
/// sample, with the same monitored values within the stated tolerances.
|
||||
#[test]
|
||||
fn runs_to_steady_state_stop_where_the_cpu_does() {
|
||||
let steady = |tolerance: f64, window: f64, monitors: Vec<fieldcraft_model::FlowMonitor>| {
|
||||
Some(fieldcraft_model::Steady { monitors, tolerance, window: Some(window), min_time: None, divergence: None })
|
||||
};
|
||||
let fluid = |end_time: f64, dt: f64, steady: Option<fieldcraft_model::Steady>| Procedure::Fluid {
|
||||
domain: "Flow".into(),
|
||||
end_time: Some(end_time),
|
||||
steps: None,
|
||||
time_step: Some(dt),
|
||||
frames: 2,
|
||||
magic: None,
|
||||
steady,
|
||||
};
|
||||
let (h, nu, n) = (1.0, 1.0 / 6.0, 16usize);
|
||||
let dx = h / n as f64;
|
||||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||||
for f in [BoxFace::XMin, BoxFace::XMax, BoxFace::ZMin, BoxFace::ZMax] {
|
||||
*d.faces.get_mut(f) = PERIODIC;
|
||||
}
|
||||
d.acceleration = [8.0 * nu * 0.1 / (h * h), 0.0, 0.0];
|
||||
let walls = vec![fieldcraft_model::FlowMonitor::Force {
|
||||
faces: vec![fieldcraft_model::FluidBoundary::Box(BoxFace::YMin), fieldcraft_model::FluidBoundary::Box(BoxFace::YMax)],
|
||||
}];
|
||||
let channel = model(1.0, nu, d, fluid(24.0, dx * dx, steady(1e-4, 0.5, walls)));
|
||||
|
||||
let (pr, alpha, u_max) = (0.71, 1.0, 3.65);
|
||||
let nu = pr * alpha;
|
||||
let mut d = domain([0.0; 3], [h, h, dx], dx);
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal { initial_temperature: 0.5, gravity: [0.0, -1e3 * nu * alpha, 0.0], expansion: Some(1.0), ..FluidThermal::default() };
|
||||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||||
t.faces.x_max = ThermalCondition::Temperature { value: 0.0 };
|
||||
d.thermal = Some(t);
|
||||
let dt = (0.05 * dx / u_max).min(dx * dx / 4.0);
|
||||
let heat = vec![fieldcraft_model::FlowMonitor::Heat { faces: vec![fieldcraft_model::FluidBoundary::Box(BoxFace::XMin)] }];
|
||||
let cavity = hot_model(1.0, nu, alpha, d, fluid(4.0, dt, steady(1e-3, 0.05, heat)));
|
||||
|
||||
for (name, m) in [("channel", channel), ("cavity", cavity)] {
|
||||
let limits = fieldcraft_lbm::Limits::default();
|
||||
let (setup, flow) = prepare(&m, &[]);
|
||||
let Some(mut g) = gpu(&flow, Streaming::default()) else { return };
|
||||
let mut cpu = flow;
|
||||
let a = fieldcraft_lbm::run(&setup, &mut cpu, &limits).unwrap();
|
||||
let b = fieldcraft_lbm::run(&setup, &mut g, &limits).unwrap();
|
||||
let (ra, rb) = (a.steady.unwrap(), b.steady.unwrap());
|
||||
println!("{name}: CPU {:?} at step {}, GPU {:?} at step {}", ra.reason, a.stats.steps, rb.reason, b.stats.steps);
|
||||
assert_eq!(ra.reason, fieldcraft_lbm::StopReason::Steady, "{ra:?}");
|
||||
assert_eq!((rb.reason, b.stats.steps), (ra.reason, a.stats.steps), "{rb:?}");
|
||||
let (sa, sb) = (ra.samples.last().unwrap(), rb.samples.last().unwrap());
|
||||
for (va, vb) in sa.values.iter().zip(&sb.values) {
|
||||
let scale = va.iter().map(|v| v.abs()).fold(0.0, f64::max);
|
||||
for (x, y) in va.iter().zip(vb) {
|
||||
assert!((x - y).abs() <= 1e-4 * scale, "{name}: {sa:?} vs {sb:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Readbacks and force caches must describe the same state on both AA orientations,
|
||||
/// including calls that cross a 64-step submission boundary. The CPU defines the result.
|
||||
fn compare_incremental(mut cpu: Flow, heated: bool, streaming: Streaming) {
|
||||
@@ -421,7 +484,7 @@ fn odd_even_and_chunk_readbacks_match_the_cpu_with_heat_open_and_slip_faces() {
|
||||
0.1,
|
||||
0.05,
|
||||
d,
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(194), time_step: Some(1.0), frames: 1, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(194), time_step: Some(1.0), frames: 1, magic: None, steady: None },
|
||||
);
|
||||
let (_, flow) = prepare(&m, &[]);
|
||||
compare_incremental(flow, true, streaming);
|
||||
@@ -443,9 +506,27 @@ fn odd_even_and_chunk_readbacks_match_the_cpu_around_an_obstacle() {
|
||||
1.0,
|
||||
1.0 / 6.0,
|
||||
d,
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(194), time_step: Some(1.0), frames: 1, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(194), time_step: Some(1.0), frames: 1, magic: None, steady: None },
|
||||
);
|
||||
let (_, flow) = prepare(&m, &[ball]);
|
||||
compare_incremental(flow, false, streaming);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fans and porous zones run on the CPU only for now: the backend refuses them by name, before it
|
||||
/// looks for a device.
|
||||
#[test]
|
||||
fn fans_and_porous_zones_are_refused_clearly() {
|
||||
let mut d = domain([0.0; 3], [10.0, 2.0, 2.0], 1.0);
|
||||
d.porous = vec![fieldcraft_model::PorousZone {
|
||||
name: "Vent".into(),
|
||||
min: [4.0, 0.0, 0.0],
|
||||
max: [6.0, 2.0, 2.0],
|
||||
axis: fieldcraft_model::Axis::X,
|
||||
resistance: fieldcraft_model::Resistance::LossCoefficient { coefficient: 1.0 },
|
||||
}];
|
||||
let m = model(1.0, 0.1, d, step(10.0, 1.0, None));
|
||||
let (_, flow) = prepare(&m, &[]);
|
||||
let e = GpuFlow::with_options(&flow, Policy::from_env().expect("FIELDCRAFT_GPU names a policy"), Options::default()).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("Vent") && e.contains("cpu")), "{e:?}");
|
||||
}
|
||||
@@ -40,6 +40,11 @@ pub trait Backend {
|
||||
/// The heat each force group received in the last step (lattice units: temperature times
|
||||
/// cell faces per step); empty without heat.
|
||||
fn heat(&mut self) -> Result<Vec<f64>>;
|
||||
/// Each fan's and porous zone's flow and pressure now (lattice units). A backend that does
|
||||
/// not step zones refuses a flow with any when it is made, so the default has none.
|
||||
fn zones(&mut self) -> Result<Vec<crate::flow::ZoneSample>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn groups(&self) -> &[Group];
|
||||
fn fluid_cells(&self) -> usize;
|
||||
fn is_fluid(&self, cell: usize) -> bool;
|
||||
|
||||
+219
-8
@@ -26,6 +26,15 @@
|
||||
//! Luo, *Force evaluation in the lattice Boltzmann method involving curved geometry*, PRE
|
||||
//! 65:041203, 2002): a population `f*` leaving a fluid cell toward the wall and the one `f'`
|
||||
//! coming back transfer `c_out f* − c_in f'` to the wall.
|
||||
//! - **Fans and porous zones:** a porous zone (a vent, grille, filter or perforated plate) adds
|
||||
//! the Darcy–Forchheimer force per volume `−ρ (α u + β |u| u)` (α = ν/K, β = C₂/2) to its
|
||||
//! cells through Guo's term; because it depends on the velocity it defines, the velocity comes
|
||||
//! from the exact root of that quadratic (Z. Guo, T. S. Zhao, *Lattice Boltzmann model for
|
||||
//! incompressible flows through porous media*, PRE 66:036304, 2002). A fan pushes its cells
|
||||
//! along its direction with `Δp / t` (t its thickness): before every step its volume flow `Q`
|
||||
//! (the mean over its cell layers of `Σ u_axis`) is read, and its pressure rise moves toward
|
||||
//! its curve's `Δp(Q)` by its relaxation factor, so the operating point is where the curve
|
||||
//! meets the system's resistance. Zones stay off the inlet and outlet layers.
|
||||
//!
|
||||
//! # Heat
|
||||
//! With thermal settings a second lattice carries the temperature: D3Q7 (the rest direction and
|
||||
@@ -55,7 +64,7 @@ use fieldcraft_model::{BoxFace, FaceCondition, Profile, ThermalCondition};
|
||||
use fieldcraft_parallel as parallel;
|
||||
|
||||
use crate::lattice::{C, Q, W, c, equilibrium, moving_wall, opposite};
|
||||
use crate::setup::{MAX_LATTICE_SPEED, Obstacle, Setup};
|
||||
use crate::setup::{MAX_LATTICE_SPEED, Obstacle, Setup, ZoneKind, ZoneSetup};
|
||||
use crate::voxel::{Grid, TriangleTree, voxelise};
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -109,6 +118,61 @@ pub struct Group {
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// What a fan or porous zone does to its cells in the current step.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
struct ZoneAction {
|
||||
/// A force per volume (a fan's push, lattice units).
|
||||
push: [f64; 3],
|
||||
/// Porous resistance: the acceleration `−α u − β |u| u`.
|
||||
alpha: f64,
|
||||
beta: f64,
|
||||
}
|
||||
|
||||
/// A zone's flow and pressure at one time (lattice units).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct ZoneSample {
|
||||
/// The volume flow through it along its axis (a fan: along its direction), the mean over its
|
||||
/// cell layers.
|
||||
pub flow: f64,
|
||||
/// The static-pressure difference between the fluid cell layers just outside it (so it
|
||||
/// includes the wall friction over its thickness plus one cell): the drop
|
||||
/// `p_upstream − p_downstream` for a porous zone (upstream by the flow's sign), the rise
|
||||
/// `p_downstream − p_upstream` along a fan's direction.
|
||||
pub pressure: f64,
|
||||
/// A fan's imposed pressure rise this step (zero for a porous zone).
|
||||
pub fan_rise: f64,
|
||||
}
|
||||
|
||||
/// The velocity (with the half force) and the force per volume of a cell from its density `rho`
|
||||
/// and momentum `j`, with body acceleration `a` and its zone's action. Porous resistance depends
|
||||
/// on the velocity it defines, `u = (j + F/2)/ρ`; solving that quadratic exactly keeps strong
|
||||
/// resistances stable (Z. Guo, T. S. Zhao, *Lattice Boltzmann model for incompressible flows
|
||||
/// through porous media*, PRE 66:036304, 2002): with `v = (j + (ρ a + push)/2)/ρ`,
|
||||
/// `u = v / (c₀ + √(c₀² + c₁ |v|))`, `c₀ = (1 + α/2)/2`, `c₁ = β/2`.
|
||||
#[inline(always)]
|
||||
fn velocity_force(rho: f64, j: [f64; 3], a: [f64; 3], zone: Option<&ZoneAction>) -> ([f64; 3], [f64; 3]) {
|
||||
let mut force = [rho * a[0], rho * a[1], rho * a[2]];
|
||||
let Some(z) = zone else {
|
||||
return ([0, 1, 2].map(|k| (j[k] + 0.5 * force[k]) / rho), force);
|
||||
};
|
||||
for k in 0..3 {
|
||||
force[k] += z.push[k];
|
||||
}
|
||||
let v = [0, 1, 2].map(|k| (j[k] + 0.5 * force[k]) / rho);
|
||||
if z.alpha == 0.0 && z.beta == 0.0 {
|
||||
return (v, force);
|
||||
}
|
||||
let c0 = 0.5 * (1.0 + 0.5 * z.alpha);
|
||||
let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
let scale = 1.0 / (c0 + (c0 * c0 + 0.5 * z.beta * speed).sqrt());
|
||||
let u = v.map(|c| c * scale);
|
||||
let us = speed * scale;
|
||||
for k in 0..3 {
|
||||
force[k] -= rho * (z.alpha + z.beta * us) * u[k];
|
||||
}
|
||||
(u, force)
|
||||
}
|
||||
|
||||
/// The scalar lattice: directions 0..7 of D3Q19 (rest and the axes).
|
||||
const QT: usize = 7;
|
||||
/// Its weights.
|
||||
@@ -202,6 +266,15 @@ pub struct Flow {
|
||||
cell_force: Vec<[f64; 3]>,
|
||||
/// With heat: every cell's velocity from the last collision, for the scalar equilibrium.
|
||||
cell_u: Vec<[f64; 3]>,
|
||||
/// The fans and porous zones (porous first), their fluid cells, and per cell its zone
|
||||
/// (`NONE` outside them; empty without zones).
|
||||
zones: Vec<ZoneSetup>,
|
||||
zone_cells: Vec<Vec<u32>>,
|
||||
zone_of: Vec<u32>,
|
||||
/// Each zone's action this step.
|
||||
actions: Vec<ZoneAction>,
|
||||
/// Each fan's pressure rise (lattice units; zero for porous zones), relaxed toward its curve.
|
||||
fan_rise: Vec<f64>,
|
||||
}
|
||||
|
||||
const NONE: u32 = u32::MAX;
|
||||
@@ -402,6 +475,44 @@ impl Flow {
|
||||
}
|
||||
}
|
||||
|
||||
// Fans and porous zones: their fluid cells (the first zone that contains a cell owns it),
|
||||
// kept off the inlet and outlet layers, whose boundary rule knows only the body force.
|
||||
let mut zone_of = if setup.zones.is_empty() { Vec::new() } else { filled(n, NONE, "zone cells")? };
|
||||
let mut zone_cells = Vec::with_capacity(setup.zones.len());
|
||||
for (z, zone) in setup.zones.iter().enumerate() {
|
||||
let mut cells = Vec::new();
|
||||
for k in zone.lo[2]..=zone.hi[2] {
|
||||
for j in zone.lo[1]..=zone.hi[1] {
|
||||
for i in zone.lo[0]..=zone.hi[0] {
|
||||
let cell = grid.index([i, j, k]);
|
||||
if fluid.get(cell).copied().unwrap_or(false) && zone_of.get(cell) == Some(&NONE) {
|
||||
zone_of[cell] = z as u32;
|
||||
cells.push(cell as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cells.is_empty() {
|
||||
return Err(Error::Invalid(format!("`{}` has no fluid cell: it lies inside obstacles or another zone", zone.name)));
|
||||
}
|
||||
zone_cells.push(cells);
|
||||
}
|
||||
if let Some(oc) = open.iter().find(|oc| zone_of.get(oc.cell as usize).is_some_and(|z| *z != NONE)) {
|
||||
let name = zone_of.get(oc.cell as usize).and_then(|z| setup.zones.get(*z as usize)).map_or("", |z| z.name.as_str());
|
||||
return Err(Error::Invalid(format!(
|
||||
"`{name}` reaches the inlet or outlet layer of box face {}: keep fans and porous zones at least one cell inside it",
|
||||
BoxFace::of(oc.axis, oc.inward < 0.0).name()
|
||||
)));
|
||||
}
|
||||
let actions: Vec<ZoneAction> = setup
|
||||
.zones
|
||||
.iter()
|
||||
.map(|z| match z.kind {
|
||||
ZoneKind::Porous { alpha, beta } => ZoneAction { push: [0.0; 3], alpha, beta },
|
||||
ZoneKind::Fan { .. } => ZoneAction::default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The initial state: equilibrium at density 1 and the initial velocity.
|
||||
let mut f = filled(q_n, 0.0, "populations")?;
|
||||
let g = filled(q_n, 0.0, "populations")?;
|
||||
@@ -482,9 +593,97 @@ impl Flow {
|
||||
thermal,
|
||||
cell_force,
|
||||
cell_u,
|
||||
fan_rise: vec![0.0; setup.zones.len()],
|
||||
zones: setup.zones.clone(),
|
||||
zone_cells,
|
||||
zone_of,
|
||||
actions,
|
||||
})
|
||||
}
|
||||
|
||||
/// The action of a cell's zone, if it is in one.
|
||||
#[inline(always)]
|
||||
fn action_at(&self, cell: usize) -> Option<&ZoneAction> {
|
||||
self.zone_of.get(cell).and_then(|z| self.actions.get(*z as usize))
|
||||
}
|
||||
|
||||
/// The fans and porous zones (porous first).
|
||||
pub fn zones(&self) -> &[ZoneSetup] {
|
||||
&self.zones
|
||||
}
|
||||
|
||||
/// Each zone's fluid cells.
|
||||
pub fn zone_cells(&self) -> &[Vec<u32>] {
|
||||
&self.zone_cells
|
||||
}
|
||||
|
||||
/// The volume flow through zone `z` along its axis, signed along a fan's direction: the
|
||||
/// mean over its layers of the flux through each, `Σ u_axis / layers`.
|
||||
fn zone_flow(&self, z: usize) -> f64 {
|
||||
let (Some(zone), Some(cells)) = (self.zones.get(z), self.zone_cells.get(z)) else { return 0.0 };
|
||||
let axis = zone.axis.min(2);
|
||||
let sum: f64 = cells.iter().filter_map(|c| self.moments(*c as usize)).map(|(_, u)| u[axis]).sum();
|
||||
let sign = if let ZoneKind::Fan { sign, .. } = zone.kind { sign } else { 1.0 };
|
||||
sign * sum / zone.layers() as f64
|
||||
}
|
||||
|
||||
/// Before a step: every fan's flow, its pressure rise moved toward its curve's value, and
|
||||
/// its push (the rise over its thickness, along its direction).
|
||||
fn refresh_fans(&mut self) {
|
||||
for z in 0..self.zones.len() {
|
||||
let Some(ZoneKind::Fan { sign, curve, relaxation }) = self.zones.get(z).map(|zone| &zone.kind) else { continue };
|
||||
let (sign, relaxation) = (*sign, *relaxation);
|
||||
let target = fieldcraft_model::fluid_zones::curve_value(curve, self.zone_flow(z));
|
||||
let (Some(rise), Some(zone)) = (self.fan_rise.get_mut(z), self.zones.get(z)) else { continue };
|
||||
*rise += relaxation * (target - *rise);
|
||||
let mut push = [0.0; 3];
|
||||
push[zone.axis.min(2)] = sign * *rise / zone.layers() as f64;
|
||||
if let Some(a) = self.actions.get_mut(z) {
|
||||
a.push = push;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every zone's flow and pressure difference now (lattice units).
|
||||
pub fn zone_samples(&self) -> Vec<ZoneSample> {
|
||||
(0..self.zones.len()).map(|z| self.zone_sample(z)).collect()
|
||||
}
|
||||
|
||||
fn zone_sample(&self, z: usize) -> ZoneSample {
|
||||
let Some(zone) = self.zones.get(z) else { return ZoneSample::default() };
|
||||
let flow = self.zone_flow(z);
|
||||
let axis = zone.axis.min(2);
|
||||
// The mean density of the layer `layer` along the axis over the zone's footprint.
|
||||
let layer_density = |layer: usize| {
|
||||
let (mut sum, mut count) = (0.0, 0usize);
|
||||
let (t1, t2) = ((axis + 1) % 3, (axis + 2) % 3);
|
||||
for b in zone.lo[t2]..=zone.hi[t2] {
|
||||
for a in zone.lo[t1]..=zone.hi[t1] {
|
||||
let mut ijk = [0usize; 3];
|
||||
ijk[axis] = layer;
|
||||
ijk[t1] = a;
|
||||
ijk[t2] = b;
|
||||
if let Some((rho, _)) = self.moments(self.grid.index(ijk)) {
|
||||
sum += rho;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(count > 0).then(|| sum / count as f64)
|
||||
};
|
||||
// The layers just outside the zone (its own end layers at the box's ends).
|
||||
let before = layer_density(zone.lo[axis].saturating_sub(1)).or_else(|| layer_density(zone.lo[axis])).unwrap_or(1.0);
|
||||
let after =
|
||||
layer_density((zone.hi[axis] + 1).min(self.grid.cells[axis].saturating_sub(1))).or_else(|| layer_density(zone.hi[axis])).unwrap_or(1.0);
|
||||
let up_minus_down = crate::lattice::CS2 * (before - after);
|
||||
match zone.kind {
|
||||
ZoneKind::Fan { sign, .. } => {
|
||||
ZoneSample { flow, pressure: -sign * up_minus_down, fan_rise: self.fan_rise.get(z).copied().unwrap_or(0.0) }
|
||||
}
|
||||
ZoneKind::Porous { .. } => ZoneSample { flow, pressure: if flow < 0.0 { -up_minus_down } else { up_minus_down }, fan_rise: 0.0 },
|
||||
}
|
||||
}
|
||||
|
||||
/// The body acceleration of a cell: the constant one, plus the buoyant one with heat.
|
||||
#[inline(always)]
|
||||
fn force_at(&self, cell: usize) -> [f64; 3] {
|
||||
@@ -611,8 +810,15 @@ impl Flow {
|
||||
continue;
|
||||
}
|
||||
let (rho, u) = state(cell);
|
||||
let force = self.force_at(cell);
|
||||
let bare = [0, 1, 2].map(|a| u[a] - 0.5 * force[a]);
|
||||
let a = self.force_at(cell);
|
||||
let mut force = a.map(|c| rho * c);
|
||||
if let Some(z) = self.action_at(cell) {
|
||||
let speed = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
|
||||
for k in 0..3 {
|
||||
force[k] += z.push[k] - rho * (z.alpha + z.beta * speed) * u[k];
|
||||
}
|
||||
}
|
||||
let bare = [0, 1, 2].map(|k| u[k] - 0.5 * force[k] / rho);
|
||||
for q in 0..Q {
|
||||
self.f[q * s + cell] = equilibrium(q, rho, bare);
|
||||
}
|
||||
@@ -634,8 +840,7 @@ impl Flow {
|
||||
j[1] += fq * cq[1];
|
||||
j[2] += fq * cq[2];
|
||||
}
|
||||
let force = self.force_at(cell);
|
||||
let u = [0, 1, 2].map(|a| (j[a] + 0.5 * rho * force[a]) / rho);
|
||||
let (u, _) = velocity_force(rho, j, self.force_at(cell), self.action_at(cell));
|
||||
Some((rho, u))
|
||||
}
|
||||
|
||||
@@ -656,6 +861,7 @@ impl Flow {
|
||||
/// number or a lattice speed exceeds [`MAX_LATTICE_SPEED`].
|
||||
pub fn step(&mut self) -> Result<()> {
|
||||
self.refresh_buoyancy();
|
||||
self.refresh_fans();
|
||||
let max_u2 = self.collide();
|
||||
if !(max_u2 <= MAX_LATTICE_SPEED * MAX_LATTICE_SPEED) {
|
||||
return Err(Error::Unstable(format!(
|
||||
@@ -847,8 +1053,9 @@ impl Flow {
|
||||
let (sp, sm) = (1.0 - 0.5 * wp, 1.0 - 0.5 * wm);
|
||||
let constant = self.acceleration;
|
||||
let cell_force = &self.cell_force;
|
||||
let forced = constant != [0.0; 3] || !cell_force.is_empty();
|
||||
let forced = constant != [0.0; 3] || !cell_force.is_empty() || !self.zone_of.is_empty();
|
||||
let fluid = &self.fluid;
|
||||
let (zone_of, actions) = (&self.zone_of, &self.actions);
|
||||
let mut blocks = cell_blocks(&mut self.f, self.stride, n);
|
||||
let mut maxima = vec![0.0f64; blocks.len()];
|
||||
// with heat, each block's velocities go to its slice of cell_u (empty otherwise)
|
||||
@@ -880,8 +1087,8 @@ impl Flow {
|
||||
**max_u2 = f64::NAN;
|
||||
return;
|
||||
}
|
||||
let force = [rho * a[0], rho * a[1], rho * a[2]];
|
||||
let u = [(j[0] + 0.5 * force[0]) / rho, (j[1] + 0.5 * force[1]) / rho, (j[2] + 0.5 * force[2]) / rho];
|
||||
let zone = zone_of.get(b * CELLS + i).and_then(|z| actions.get(*z as usize));
|
||||
let (u, force) = velocity_force(rho, j, a, zone);
|
||||
let uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
|
||||
**max_u2 = max_u2.max(uu);
|
||||
if let Some(slot) = u_out.as_mut().and_then(|u| u.get_mut(i)) {
|
||||
@@ -1098,6 +1305,10 @@ impl crate::backend::Backend for Flow {
|
||||
Ok(Flow::heat(self).to_vec())
|
||||
}
|
||||
|
||||
fn zones(&mut self) -> Result<Vec<ZoneSample>> {
|
||||
Ok(self.zone_samples())
|
||||
}
|
||||
|
||||
fn mass(&mut self) -> Result<f64> {
|
||||
Ok(Flow::mass(self))
|
||||
}
|
||||
|
||||
+10
-4
@@ -9,8 +9,10 @@
|
||||
//! - [`setup`]: the step in lattice units, the time step, and the stability limits.
|
||||
//! - [`flow`]: the D3Q19 two-relaxation-time scheme with Guo forcing, halfway bounce-back
|
||||
//! walls (moving walls after Ladd), slip faces, Zou–He inlets and outlets, and the
|
||||
//! momentum-exchange force on every wall.
|
||||
//! momentum-exchange force on every wall; fans (a pressure–flow curve) and porous zones
|
||||
//! (Darcy–Forchheimer resistance) as body forces.
|
||||
//! - [`solve`]: the time loop, output frames and force histories in model units.
|
||||
//! - [`steady`]: the steady-state monitor and stop rule of a run.
|
||||
//!
|
||||
//! # Sources
|
||||
//! - T. Krüger, H. Kusumaatmaja, A. Kuzmin, O. Shardt, G. Silva, E. M. Viggen, *The Lattice
|
||||
@@ -30,6 +32,8 @@
|
||||
//! - Q. Zou, X. He, *On pressure and velocity boundary conditions for the lattice Boltzmann BGK
|
||||
//! model*, Phys. Fluids 9(6):1591–1598, 1997; M. Hecht, J. Harting, J. Stat. Mech. 2010,
|
||||
//! P01018 (the D3Q19 transverse correction).
|
||||
//! - Z. Guo, T. S. Zhao, *Lattice Boltzmann model for incompressible flows through porous
|
||||
//! media*, PRE 66:036304, 2002 (the implicit velocity with a Darcy–Forchheimer force).
|
||||
//! - P. Lallemand, L.-S. Luo, *Theory of the lattice Boltzmann method: dispersion, dissipation,
|
||||
//! isotropy, Galilean invariance, and stability*, PRE 61:6546, 2000 (stability limits).
|
||||
//! - H. Edelsbrunner, E. P. Mücke, *Simulation of Simplicity*, ACM TOG 9(1):66–104, 1990;
|
||||
@@ -53,13 +57,15 @@ pub mod flow;
|
||||
pub mod lattice;
|
||||
pub mod setup;
|
||||
pub mod solve;
|
||||
pub mod steady;
|
||||
pub mod voxel;
|
||||
|
||||
pub use backend::{Backend, Choice, Fields, register_backend};
|
||||
pub use flow::{Flow, Group, Link, OpenCell, Rule, Target, ThermalState};
|
||||
pub use flow::{Flow, Group, Link, OpenCell, Rule, Target, ThermalState, ZoneSample};
|
||||
pub use setup::ThermalSetup;
|
||||
pub use setup::{Obstacle, Setup, fluid_step};
|
||||
pub use solve::{FluidResult, FluidStats, ForceHistory, Frame, HeatHistory, run, solve_fluid, solve_fluid_on};
|
||||
pub use setup::{Obstacle, Setup, ZoneKind, ZoneSetup, fluid_step};
|
||||
pub use solve::{FluidResult, FluidStats, ForceHistory, Frame, HeatHistory, ZoneHistory, run, solve_fluid, solve_fluid_on};
|
||||
pub use steady::{MonitorSample, SteadyReport, SteadySetup, StopReason};
|
||||
pub use voxel::Grid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
|
||||
+104
-2
@@ -26,7 +26,8 @@
|
||||
//! density stops the run with [`crate::Error::Unstable`].
|
||||
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::{BoxFace, Directional, FaceCondition, FluidDomain, Model, Procedure, SurfaceCondition, ThermalCondition};
|
||||
use fieldcraft_model::fluid_zones::DEFAULT_FAN_RELAXATION;
|
||||
use fieldcraft_model::{BoxFace, Directional, FaceCondition, FluidDomain, Model, Procedure, Resistance, SurfaceCondition, ThermalCondition};
|
||||
|
||||
use crate::voxel::Grid;
|
||||
use crate::{Error, Limits, Result};
|
||||
@@ -114,9 +115,99 @@ pub struct Setup {
|
||||
pub obstacles: Vec<ObstacleFaces>,
|
||||
/// Heat in the fluid, when the domain has thermal settings.
|
||||
pub thermal: Option<ThermalSetup>,
|
||||
/// The stop rule, when the step runs to steady state.
|
||||
pub steady: Option<crate::steady::SteadySetup>,
|
||||
/// The domain's porous zones, then its fans, in lattice units.
|
||||
pub zones: Vec<ZoneSetup>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a zone of the domain does, in lattice units.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ZoneKind {
|
||||
/// Porous resistance: the acceleration `−α u − β |u| u` (α = ν/K, β = C₂/2).
|
||||
Porous { alpha: f64, beta: f64 },
|
||||
/// A fan pushing along its axis with `sign`; its curve's `[Q, Δp]` points in lattice units
|
||||
/// (cell volumes per step, `ρ₀ Δx²/Δt²`) and its relaxation per step.
|
||||
Fan { sign: f64, curve: Vec<[f64; 2]>, relaxation: f64 },
|
||||
}
|
||||
|
||||
/// A fan or porous zone: the cells whose centres its box contains, `lo..=hi` along each axis.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ZoneSetup {
|
||||
pub name: String,
|
||||
pub kind: ZoneKind,
|
||||
pub lo: [usize; 3],
|
||||
pub hi: [usize; 3],
|
||||
/// The axis the flow goes through it along (its thickness and its reported flow).
|
||||
pub axis: usize,
|
||||
}
|
||||
|
||||
impl ZoneSetup {
|
||||
/// The cell layers along its axis.
|
||||
pub fn layers(&self) -> usize {
|
||||
let a = self.axis.min(2);
|
||||
self.hi[a] + 1 - self.lo[a]
|
||||
}
|
||||
|
||||
pub fn is_fan(&self) -> bool {
|
||||
matches!(self.kind, ZoneKind::Fan { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells whose centres lie in `[min, max]`, per axis `lo..=hi`, or `None` when there are none.
|
||||
fn cell_range(grid: &Grid, min: [f64; 3], max: [f64; 3]) -> Option<([usize; 3], [usize; 3])> {
|
||||
const TOL: f64 = 1e-9;
|
||||
let (mut lo, mut hi) = ([0usize; 3], [0usize; 3]);
|
||||
for a in 0..3 {
|
||||
let h = grid.spacing;
|
||||
let first = ((min[a] - grid.origin[a]) / h - 0.5 - TOL).ceil().max(0.0);
|
||||
let last = ((max[a] - grid.origin[a]) / h - 0.5 + TOL).floor().min(grid.cells[a] as f64 - 1.0);
|
||||
if !(first <= last) {
|
||||
return None;
|
||||
}
|
||||
(lo[a], hi[a]) = (first as usize, last as usize);
|
||||
}
|
||||
Some((lo, hi))
|
||||
}
|
||||
|
||||
/// The domain's porous zones and fans in lattice units.
|
||||
fn zones(domain: &FluidDomain, grid: &Grid, viscosity: f64, dt: f64, density: f64) -> Result<Vec<ZoneSetup>> {
|
||||
let h = grid.spacing;
|
||||
let pressure_scale = density * h * h / (dt * dt);
|
||||
let volume_flow_scale = h * h * h / dt;
|
||||
let place = |kind: &str, name: &str, min, max| {
|
||||
cell_range(grid, min, max).ok_or_else(|| {
|
||||
Error::Invalid(format!("{kind} `{name}` contains no cell centre: make it at least one spacing ({h}) thick and put it inside the box"))
|
||||
})
|
||||
};
|
||||
let mut out = Vec::with_capacity(domain.porous.len() + domain.fans.len());
|
||||
for z in &domain.porous {
|
||||
let (lo, hi) = place("porous zone", &z.name, z.min, z.max)?;
|
||||
let axis = z.axis.index();
|
||||
let layers = (hi[axis] + 1 - lo[axis]) as f64;
|
||||
let (alpha, beta) = match z.resistance {
|
||||
Resistance::DarcyForchheimer { permeability, inertial } => (viscosity * dt / permeability, 0.5 * inertial * h),
|
||||
Resistance::LossCoefficient { coefficient } => (0.0, 0.5 * coefficient / layers),
|
||||
};
|
||||
if !(alpha.is_finite() && beta.is_finite()) {
|
||||
return Err(Error::Invalid(format!("porous zone `{}`: its resistance is too large for this lattice", z.name)));
|
||||
}
|
||||
out.push(ZoneSetup { name: z.name.clone(), kind: ZoneKind::Porous { alpha, beta }, lo, hi, axis });
|
||||
}
|
||||
for f in &domain.fans {
|
||||
let (lo, hi) = place("fan", &f.name, f.min, f.max)?;
|
||||
let (axis, sign) = f.direction.axis_sign();
|
||||
let curve: Vec<[f64; 2]> = f.curve.iter().map(|p| [p[0] / volume_flow_scale, p[1] / pressure_scale]).collect();
|
||||
if curve.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Invalid(format!("fan `{}`: its curve does not fit this lattice", f.name)));
|
||||
}
|
||||
let relaxation = f.relaxation.unwrap_or(DEFAULT_FAN_RELAXATION);
|
||||
out.push(ZoneSetup { name: f.name.clone(), kind: ZoneKind::Fan { sign, curve, relaxation }, lo, hi, axis });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn norm(v: [f64; 3]) -> f64 {
|
||||
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
|
||||
}
|
||||
@@ -150,7 +241,7 @@ impl Setup {
|
||||
// The model checked the domain and its material when they were defined.
|
||||
let (s, domain) = fluid_step(model, step)?;
|
||||
let domain = domain.clone();
|
||||
let Procedure::Fluid { end_time, steps, time_step, frames, magic, .. } = &s.procedure else {
|
||||
let Procedure::Fluid { end_time, steps, time_step, frames, magic, steady, .. } = &s.procedure else {
|
||||
return Err(Error::Invalid(format!("step `{}` is not a fluid step", s.name)));
|
||||
};
|
||||
let material = model.material(&domain.material);
|
||||
@@ -200,6 +291,9 @@ impl Setup {
|
||||
u_char = u_char.max(norm(velocity));
|
||||
}
|
||||
}
|
||||
for fan in &domain.fans {
|
||||
u_char = u_char.max((2.0 * fan.max_pressure_rise() / density).sqrt());
|
||||
}
|
||||
let lo = pressures.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = pressures.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
if hi > lo {
|
||||
@@ -385,6 +479,7 @@ impl Setup {
|
||||
}
|
||||
domain_obstacles.push(ObstacleFaces { index, names, velocities, thermal: thermal_faces });
|
||||
}
|
||||
let zones = zones(&domain, &grid, viscosity, dt, density)?;
|
||||
if mach > 0.1 + 1e-12 {
|
||||
warnings.push(format!(
|
||||
"lattice Mach number {mach:.3}: the compressibility error, of order Ma² ≈ {:.1}%, may be visible",
|
||||
@@ -413,11 +508,18 @@ impl Setup {
|
||||
initial_velocity: domain.initial_velocity.map(|c| c * dt / h),
|
||||
obstacles: domain_obstacles,
|
||||
thermal,
|
||||
steady: steady.as_ref().map(|r| crate::steady::SteadySetup::new(r, n, dt)).transpose()?,
|
||||
zones,
|
||||
warnings,
|
||||
domain,
|
||||
})
|
||||
}
|
||||
|
||||
/// Model units per lattice unit of volume flow (cell volumes per step).
|
||||
pub fn volume_flow_scale(&self) -> f64 {
|
||||
self.grid.spacing.powi(3) / self.time_step
|
||||
}
|
||||
|
||||
/// Model units per lattice unit of velocity, pressure and force.
|
||||
pub fn velocity_scale(&self) -> f64 {
|
||||
self.grid.spacing / self.time_step
|
||||
|
||||
+94
-7
@@ -5,6 +5,7 @@ use fieldcraft_model::Model;
|
||||
use crate::backend::{Backend, Choice, Fields};
|
||||
use crate::flow::Flow;
|
||||
use crate::setup::{Obstacle, Setup};
|
||||
use crate::steady::{Monitor, SteadyReport, StopReason};
|
||||
use crate::voxel::Grid;
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
@@ -38,6 +39,23 @@ pub struct HeatHistory {
|
||||
pub power: Vec<f64>,
|
||||
}
|
||||
|
||||
/// A fan's or porous zone's flow and pressure over time (model units).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ZoneHistory {
|
||||
pub name: String,
|
||||
/// Whether it is a fan (else a porous zone: a vent, grille, filter or plate).
|
||||
pub fan: bool,
|
||||
/// One value per [`FluidResult::history_times`]: the volume flow through it (along its axis;
|
||||
/// a fan's along its direction).
|
||||
pub flow: Vec<f64>,
|
||||
/// The static-pressure difference between the fluid cell layers just outside it (so it
|
||||
/// includes the wall friction over its thickness plus one cell): the drop across a porous
|
||||
/// zone, the rise across a fan.
|
||||
pub pressure: Vec<f64>,
|
||||
/// A fan's pressure rise from its curve at that time (empty for a porous zone).
|
||||
pub fan_rise: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The force the fluid exerts on one wall over time.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ForceHistory {
|
||||
@@ -86,8 +104,12 @@ pub struct FluidResult {
|
||||
pub forces: Vec<ForceHistory>,
|
||||
/// The heat every wall receives, with heat (empty otherwise), the same walls and times.
|
||||
pub heat: Vec<HeatHistory>,
|
||||
/// Every fan and porous zone, the same times.
|
||||
pub zones: Vec<ZoneHistory>,
|
||||
pub stats: FluidStats,
|
||||
pub warnings: Vec<String>,
|
||||
/// How the run to steady state went, when the step has a stop rule.
|
||||
pub steady: Option<SteadyReport>,
|
||||
}
|
||||
|
||||
impl FluidResult {
|
||||
@@ -144,7 +166,10 @@ fn frame(setup: &Setup, flow: &mut dyn Backend, step: u64) -> Result<Frame> {
|
||||
|
||||
/// Run a prepared flow (fresh from [`Flow::new`], possibly with its own initial state, on any
|
||||
/// backend) through the setup's steps, recording frames and force histories. The steps go to
|
||||
/// the backend in chunks, up to the next frame, history sample or progress check.
|
||||
/// the backend in chunks, up to the next frame, history sample, monitor sample or progress
|
||||
/// check. With a stop rule ([`crate::steady`]) the run may end early: steady, or diverged
|
||||
/// (an unstable flow then ends the run with the frames saved so far instead of an error); a
|
||||
/// frame is added at the stop when the flow is still sound.
|
||||
pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<FluidResult> {
|
||||
let n = setup.grid.len();
|
||||
let total = setup.steps;
|
||||
@@ -166,10 +191,21 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
let mut history_times = Vec::new();
|
||||
let mut forces: Vec<Vec<[f64; 3]>> = vec![Vec::new(); flow.groups().len()];
|
||||
let mut heats: Vec<Vec<f64>> = vec![Vec::new(); if setup.thermal.is_some() { flow.groups().len() } else { 0 }];
|
||||
let mut zones: Vec<ZoneHistory> = setup
|
||||
.zones
|
||||
.iter()
|
||||
.map(|z| ZoneHistory { name: z.name.clone(), fan: z.is_fan(), flow: Vec::new(), pressure: Vec::new(), fan_rise: Vec::new() })
|
||||
.collect();
|
||||
let (qs, ps) = (setup.volume_flow_scale(), setup.pressure_scale());
|
||||
let mut next_frame = 1u64;
|
||||
// Frame j (1..=frames) at step round(j · total / frames).
|
||||
let frame_step = |j: u64| (j * total + frames / 2) / frames;
|
||||
fieldcraft_model::progress::stage("Fluid steps", Some(total as f64), "");
|
||||
let mut monitor = Monitor::new(setup, flow)?;
|
||||
if let Some(m) = monitor.as_mut() {
|
||||
m.sample(setup, flow, 0)?;
|
||||
}
|
||||
let mut sound = true;
|
||||
let mut k = 0u64;
|
||||
while k < total {
|
||||
fieldcraft_model::progress::check()?;
|
||||
@@ -179,7 +215,18 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
if next_frame <= frames {
|
||||
next = next.min(frame_step(next_frame).max(k + 1));
|
||||
}
|
||||
flow.advance(next - k)?;
|
||||
if let Some(m) = &monitor {
|
||||
next = next.min((k / m.setup.sample + 1) * m.setup.sample);
|
||||
}
|
||||
match (flow.advance(next - k), monitor.as_mut()) {
|
||||
(Ok(()), _) => {}
|
||||
(Err(Error::Unstable(e)), Some(m)) => {
|
||||
m.unstable(e);
|
||||
sound = false;
|
||||
break;
|
||||
}
|
||||
(Err(e), _) => return Err(e),
|
||||
}
|
||||
k = next;
|
||||
if k.is_multiple_of(64) || k == total {
|
||||
fieldcraft_model::progress::count(k as f64);
|
||||
@@ -189,6 +236,15 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
for (h, f) in forces.iter_mut().zip(flow.forces()?) {
|
||||
h.push(f.map(|c| c * fs));
|
||||
}
|
||||
if !zones.is_empty() {
|
||||
for (h, z) in zones.iter_mut().zip(flow.zones()?) {
|
||||
h.flow.push(z.flow * qs);
|
||||
h.pressure.push(z.pressure * ps);
|
||||
if h.fan {
|
||||
h.fan_rise.push(z.fan_rise * ps);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !heats.is_empty() {
|
||||
for (h, q) in heats.iter_mut().zip(flow.heat()?) {
|
||||
h.push(q * hs);
|
||||
@@ -199,13 +255,42 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
out_frames.push(frame(setup, flow, k)?);
|
||||
next_frame += 1;
|
||||
}
|
||||
if let Some(m) = monitor.as_mut()
|
||||
&& m.samples_at(k)
|
||||
&& let Some(reason) = m.sample(setup, flow, k)?
|
||||
{
|
||||
sound = reason != StopReason::Diverged;
|
||||
if sound && out_frames.last().is_some_and(|f| f.step != k) {
|
||||
out_frames.push(frame(setup, flow, k)?);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut warnings = setup.warnings.clone();
|
||||
let steady = monitor.map(Monitor::report);
|
||||
if let Some(r) = &steady {
|
||||
match r.reason {
|
||||
StopReason::Diverged => warnings.push(format!(
|
||||
"the run diverged at time {}: {}; the frames end there",
|
||||
k as f64 * setup.time_step,
|
||||
r.message.as_deref().unwrap_or("a monitored quantity blew up")
|
||||
)),
|
||||
StopReason::MaxTime => warnings.push(format!(
|
||||
"the flow did not reach steady state (relative changes below {:e}) within the step's length; run longer or loosen the tolerance",
|
||||
r.tolerance
|
||||
)),
|
||||
StopReason::Steady => {}
|
||||
}
|
||||
}
|
||||
// The weakly compressible scheme turns pressure differences into density differences of
|
||||
// Δp / (ρ c_s²) in lattice units; large ones show the time step is too long for the flow.
|
||||
let end = flow.fields()?;
|
||||
let spread = (0..n).filter(|c| flow.is_fluid(*c)).filter_map(|c| end.density.get(c)).map(|rho| (rho - 1.0).abs()).fold(0.0, f64::max);
|
||||
let spread = if sound {
|
||||
let end = flow.fields()?;
|
||||
(0..n).filter(|c| flow.is_fluid(*c)).filter_map(|c| end.density.get(c)).map(|rho| (rho - 1.0).abs()).fold(0.0, f64::max)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if spread > COMPRESSIBILITY_WARNING {
|
||||
warnings.push(format!(
|
||||
"the density varies by {:.1}% at the end: the weakly compressible error is visible; a shorter time step reduces it (quadratically)",
|
||||
@@ -227,23 +312,25 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
frames: out_frames,
|
||||
history_times,
|
||||
forces: flow.groups().iter().zip(forces).map(|(g, force)| ForceHistory { name: g.name.clone(), body: g.body.clone(), force }).collect(),
|
||||
zones,
|
||||
heat: flow.groups().iter().zip(heats).map(|(g, power)| HeatHistory { name: g.name.clone(), body: g.body.clone(), power }).collect(),
|
||||
stats: FluidStats {
|
||||
cells: n,
|
||||
fluid_cells: flow.fluid_cells(),
|
||||
steps: total,
|
||||
steps: k,
|
||||
time_step: setup.time_step,
|
||||
end_time: total as f64 * setup.time_step,
|
||||
end_time: k as f64 * setup.time_step,
|
||||
tau_plus: setup.tau_plus,
|
||||
tau_minus: setup.tau_minus,
|
||||
magic: setup.magic,
|
||||
velocity: setup.velocity,
|
||||
lattice_velocity: setup.lattice_velocity,
|
||||
mach: setup.mach,
|
||||
cell_updates: (flow.fluid_cells() as u64).saturating_mul(total),
|
||||
cell_updates: (flow.fluid_cells() as u64).saturating_mul(k),
|
||||
backend: flow.name(),
|
||||
tau_thermal: setup.thermal.as_ref().map(|t| t.tau),
|
||||
},
|
||||
warnings,
|
||||
steady,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! Running a fluid step to steady state: the monitored quantities and the stop rule
|
||||
//! ([`fieldcraft_model::Steady`]).
|
||||
//!
|
||||
//! The run samples every monitored quantity [`SUB_SAMPLES`] times per `window` time steps, at
|
||||
//! the same chunk boundaries on every backend (a device backend reads its fields back a few times
|
||||
//! per window, never per step), so CPU and GPU take their decisions at the same steps. Each
|
||||
//! quantity `q` (a whole field for the residuals, a vector or a number otherwise) has, over each
|
||||
//! window, the relative spread `‖max q − min q‖ / max ‖q‖`: the componentwise extremes over the
|
||||
//! window's samples (both ends included), the Euclidean norm, and the largest norm over every
|
||||
//! sample so far; it is 0 while `q` has always been 0. The spread, unlike the difference of the
|
||||
//! window's ends, sees an oscillation whose period divides the window. Temperatures count from
|
||||
//! the initial temperature, so an offset (kelvin, say) does not hide a change.
|
||||
//!
|
||||
//! The run stops as steady at the end of the second consecutive full window, at or after
|
||||
//! `min_time`, in which every relative spread is below the tolerance; as diverged when one
|
||||
//! exceeds the divergence limit after `min_time`, is not a finite number, or the backend reports
|
||||
//! the flow unstable. A last window cut short by the end of the step takes no decision.
|
||||
|
||||
use fieldcraft_model::{BoxFace, FlowMonitor, FluidBoundary, GeometryRef, Steady};
|
||||
|
||||
use crate::backend::{Backend, Fields};
|
||||
use crate::setup::Setup;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Why a run to steady state stopped.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StopReason {
|
||||
/// Every monitored quantity stopped changing.
|
||||
Steady,
|
||||
/// The step's full length passed first.
|
||||
MaxTime,
|
||||
/// A monitored quantity blew up, or the flow went unstable.
|
||||
Diverged,
|
||||
}
|
||||
|
||||
impl StopReason {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
StopReason::Steady => "steady",
|
||||
StopReason::MaxTime => "max_time",
|
||||
StopReason::Diverged => "diverged",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples per window.
|
||||
pub const SUB_SAMPLES: u64 = 8;
|
||||
|
||||
/// Consecutive steady windows before the run stops as steady.
|
||||
pub const STEADY_WINDOWS: u32 = 2;
|
||||
|
||||
/// The stop rule in time steps.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SteadySetup {
|
||||
pub rule: Steady,
|
||||
/// Time steps per window: a whole number of samples.
|
||||
pub window: u64,
|
||||
/// Time steps between samples.
|
||||
pub sample: u64,
|
||||
/// The first step a decision may be taken at.
|
||||
pub min_steps: u64,
|
||||
}
|
||||
|
||||
impl SteadySetup {
|
||||
/// The rule for a step of `steps` time steps of `dt`. The window is at least one step and
|
||||
/// one [`crate::solve::MAX_HISTORY`]-th of the step, so the history stays bounded.
|
||||
pub fn new(rule: &Steady, steps: u64, dt: f64) -> Result<SteadySetup> {
|
||||
rule.validate()?;
|
||||
let floor = steps.div_ceil(crate::solve::MAX_HISTORY).max(1);
|
||||
let window = match rule.window {
|
||||
Some(w) => {
|
||||
let n = (w / dt).round();
|
||||
if !n.is_finite() {
|
||||
return Err(Error::Invalid(format!("the steady-state window {w} is not a number of time steps of {dt}")));
|
||||
}
|
||||
// Saturating: a window beyond the step's length samples only at its end.
|
||||
(n as u64).clamp(1, steps.max(1))
|
||||
}
|
||||
None => steps.div_ceil(20).max(1),
|
||||
}
|
||||
.max(floor);
|
||||
let sample = (window / SUB_SAMPLES).max(1);
|
||||
let window = sample * (window / sample);
|
||||
let min_steps = rule.min_time.map_or(0, |t| ((t / dt).ceil().max(0.0) as u64).min(steps));
|
||||
Ok(SteadySetup { rule: rule.clone(), window, sample, min_steps })
|
||||
}
|
||||
}
|
||||
|
||||
/// One sample of every monitored quantity.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MonitorSample {
|
||||
pub step: u64,
|
||||
pub time: f64,
|
||||
/// Per quantity: its value in model units (the root mean square over the fluid cells for a
|
||||
/// residual's field, the vector for a force, the number otherwise).
|
||||
pub values: Vec<Vec<f64>>,
|
||||
/// Per quantity: its relative spread over the window that ends here (`None` at the start).
|
||||
pub changes: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
/// How a run to steady state went.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SteadyReport {
|
||||
pub reason: StopReason,
|
||||
pub tolerance: f64,
|
||||
pub window: f64,
|
||||
/// The monitored quantities: `velocity`, `temperature` (with heat), then the rule's
|
||||
/// monitors by [`FlowMonitor::label`].
|
||||
pub quantities: Vec<String>,
|
||||
pub samples: Vec<MonitorSample>,
|
||||
/// Why it diverged.
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Kind {
|
||||
Velocity,
|
||||
Temperature,
|
||||
MassFlow(BoxFace),
|
||||
Force(Vec<usize>),
|
||||
Heat(Vec<usize>),
|
||||
MeanTemperature,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Quantity {
|
||||
kind: Kind,
|
||||
/// The componentwise extremes over the current window's samples so far.
|
||||
low: Vec<f64>,
|
||||
high: Vec<f64>,
|
||||
peak: f64,
|
||||
}
|
||||
|
||||
/// The monitor of a run: its quantities and their history.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Monitor {
|
||||
pub setup: SteadySetup,
|
||||
/// Consecutive steady windows so far.
|
||||
steady_windows: u32,
|
||||
quantities: Vec<Quantity>,
|
||||
report: SteadyReport,
|
||||
}
|
||||
|
||||
/// The force groups a monitor's faces name; every face must name at least one.
|
||||
fn groups(flow: &dyn Backend, faces: &[FluidBoundary]) -> Result<Vec<usize>> {
|
||||
let mut out = Vec::new();
|
||||
for face in faces {
|
||||
let before = out.len();
|
||||
for (i, g) in flow.groups().iter().enumerate() {
|
||||
let hit = match (face, &g.body) {
|
||||
(FluidBoundary::Box(b), None) => g.name == b.name(),
|
||||
(FluidBoundary::Surface(r), Some(body)) => GeometryRef::face(body.as_str(), g.name.as_str()).is_ok_and(|tag| r.selects(&tag)),
|
||||
_ => false,
|
||||
};
|
||||
if hit && !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
if out.len() == before {
|
||||
let name = match face {
|
||||
FluidBoundary::Box(b) => format!("box face {}", b.name()),
|
||||
FluidBoundary::Surface(r) => r.to_string(),
|
||||
};
|
||||
return Err(Error::NotFound(format!("the monitored {name} is not a wall of the domain")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn norm(v: &[f64]) -> f64 {
|
||||
v.iter().map(|x| x * x).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// The monitor of `setup`'s rule on `flow`, its faces resolved; `None` without a rule.
|
||||
pub fn new(setup: &Setup, flow: &dyn Backend) -> Result<Option<Monitor>> {
|
||||
let Some(steady) = &setup.steady else { return Ok(None) };
|
||||
let thermal = setup.thermal.is_some();
|
||||
let mut labels = vec!["velocity".to_string()];
|
||||
let mut kinds = vec![Kind::Velocity];
|
||||
if thermal {
|
||||
labels.push("temperature".into());
|
||||
kinds.push(Kind::Temperature);
|
||||
}
|
||||
for m in &steady.rule.monitors {
|
||||
let kind = match m {
|
||||
FlowMonitor::MassFlow { face } => Kind::MassFlow(*face),
|
||||
FlowMonitor::Force { faces } => Kind::Force(groups(flow, faces)?),
|
||||
FlowMonitor::Heat { faces } => Kind::Heat(groups(flow, faces)?),
|
||||
FlowMonitor::MeanTemperature {} => Kind::MeanTemperature,
|
||||
};
|
||||
if matches!(kind, Kind::Heat(_) | Kind::MeanTemperature) && !thermal {
|
||||
return Err(Error::Invalid(format!("the monitored {} needs heat in the fluid domain", m.label())));
|
||||
}
|
||||
labels.push(m.label());
|
||||
kinds.push(kind);
|
||||
}
|
||||
Ok(Some(Monitor {
|
||||
quantities: kinds.into_iter().map(|kind| Quantity { kind, low: Vec::new(), high: Vec::new(), peak: 0.0 }).collect(),
|
||||
report: SteadyReport {
|
||||
reason: StopReason::MaxTime,
|
||||
tolerance: steady.rule.tolerance,
|
||||
window: steady.window as f64 * setup.time_step,
|
||||
quantities: labels,
|
||||
samples: Vec::new(),
|
||||
message: None,
|
||||
},
|
||||
setup: steady.clone(),
|
||||
steady_windows: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether step `k` is a sample point.
|
||||
pub fn samples_at(&self, k: u64) -> bool {
|
||||
k.is_multiple_of(self.setup.sample)
|
||||
}
|
||||
|
||||
/// Sample every quantity at step `k` (a sample point) and, at the end of a window, decide:
|
||||
/// `Some` reason to stop now.
|
||||
pub fn sample(&mut self, setup: &Setup, flow: &mut dyn Backend, k: u64) -> Result<Option<StopReason>> {
|
||||
let Fields { density, velocity, temperature } = flow.fields()?;
|
||||
let fluid: Vec<usize> = (0..setup.grid.len()).filter(|c| flow.is_fluid(*c)).collect();
|
||||
let n = fluid.len().max(1) as f64;
|
||||
let vs = setup.velocity_scale();
|
||||
let t0 = setup.thermal.as_ref().map_or(0.0, |t| t.initial_temperature);
|
||||
let forces = if self.quantities.iter().any(|q| matches!(q.kind, Kind::Force(_))) { flow.forces()? } else { Vec::new() };
|
||||
let heat = if self.quantities.iter().any(|q| matches!(q.kind, Kind::Heat(_))) { flow.heat()? } else { Vec::new() };
|
||||
let end = k.is_multiple_of(self.setup.window);
|
||||
let mut values = Vec::with_capacity(self.quantities.len());
|
||||
let mut changes = Vec::with_capacity(self.quantities.len());
|
||||
for q in &mut self.quantities {
|
||||
// The monitored vector (temperatures from the initial one) and the reported value.
|
||||
let (state, value): (Vec<f64>, Vec<f64>) = match &q.kind {
|
||||
Kind::Velocity => {
|
||||
let s: Vec<f64> = fluid.iter().flat_map(|c| velocity.get(*c).copied().unwrap_or([0.0; 3]).map(|u| u * vs)).collect();
|
||||
let rms = norm(&s) / n.sqrt();
|
||||
(s, vec![rms])
|
||||
}
|
||||
Kind::Temperature => {
|
||||
let s: Vec<f64> = fluid.iter().map(|c| temperature.get(*c).copied().unwrap_or(t0) - t0).collect();
|
||||
let rms = norm(&s) / n.sqrt();
|
||||
(s, vec![rms])
|
||||
}
|
||||
Kind::MeanTemperature => {
|
||||
let mean = fluid.iter().map(|c| temperature.get(*c).copied().unwrap_or(t0) - t0).sum::<f64>() / n;
|
||||
(vec![mean], vec![mean + t0])
|
||||
}
|
||||
Kind::MassFlow(face) => {
|
||||
let axis = face.axis();
|
||||
let (layer, sign) = match face {
|
||||
BoxFace::XMin | BoxFace::YMin | BoxFace::ZMin => (0, -1.0),
|
||||
_ => (setup.grid.cells.get(axis).copied().unwrap_or(1).saturating_sub(1), 1.0),
|
||||
};
|
||||
let mut flux = 0.0;
|
||||
for &c in &fluid {
|
||||
if setup.grid.coords(c).get(axis) == Some(&layer) {
|
||||
let rho = density.get(c).copied().unwrap_or(1.0);
|
||||
let u = velocity.get(c).and_then(|u| u.get(axis).copied()).unwrap_or(0.0);
|
||||
flux += sign * rho * u;
|
||||
}
|
||||
}
|
||||
let m = flux * vs * setup.density * setup.grid.spacing * setup.grid.spacing;
|
||||
(vec![m], vec![m])
|
||||
}
|
||||
Kind::Force(ids) => {
|
||||
let fs = setup.force_scale();
|
||||
let mut f = [0.0; 3];
|
||||
for i in ids {
|
||||
if let Some(g) = forces.get(*i) {
|
||||
for a in 0..3 {
|
||||
f[a] += g[a] * fs;
|
||||
}
|
||||
}
|
||||
}
|
||||
(f.to_vec(), f.to_vec())
|
||||
}
|
||||
Kind::Heat(ids) => {
|
||||
let hs = setup.thermal.as_ref().map_or(0.0, |t| t.heat_scale);
|
||||
let p: f64 = ids.iter().filter_map(|i| heat.get(*i)).map(|h| h * hs).sum();
|
||||
(vec![p], vec![p])
|
||||
}
|
||||
};
|
||||
q.peak = q.peak.max(norm(&state));
|
||||
if q.low.len() != state.len() {
|
||||
q.low.clone_from(&state);
|
||||
q.high.clone_from(&state);
|
||||
}
|
||||
for ((lo, hi), x) in q.low.iter_mut().zip(q.high.iter_mut()).zip(&state) {
|
||||
*lo = lo.min(*x);
|
||||
*hi = hi.max(*x);
|
||||
// A NaN is never steady.
|
||||
if x.is_nan() {
|
||||
*hi = f64::INFINITY;
|
||||
}
|
||||
}
|
||||
let change = (end && k > 0).then(|| {
|
||||
let spread = q.low.iter().zip(&q.high).map(|(a, b)| (b - a) * (b - a)).sum::<f64>().sqrt();
|
||||
if q.peak > 0.0 && q.peak.is_finite() {
|
||||
spread / q.peak
|
||||
} else if spread == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
f64::INFINITY
|
||||
}
|
||||
});
|
||||
if end {
|
||||
// The next window starts from this sample.
|
||||
q.low.clone_from(&state);
|
||||
q.high = state;
|
||||
}
|
||||
values.push(value);
|
||||
changes.push(change);
|
||||
}
|
||||
if !end {
|
||||
return Ok(None);
|
||||
}
|
||||
let rule = &self.setup.rule;
|
||||
let late = k >= self.setup.min_steps;
|
||||
let mut decision = None;
|
||||
if let Some((i, c)) = changes.iter().enumerate().find_map(|(i, c)| c.filter(|c| !c.is_finite()).map(|c| (i, c))) {
|
||||
decision = Some(StopReason::Diverged);
|
||||
self.report.message = Some(format!("the {} changed by {c} over the window", self.label(i)));
|
||||
} else if let (true, Some(limit)) = (late, rule.divergence)
|
||||
&& let Some((i, c)) = changes.iter().enumerate().find_map(|(i, c)| c.filter(|c| *c > limit).map(|c| (i, c)))
|
||||
{
|
||||
decision = Some(StopReason::Diverged);
|
||||
self.report.message = Some(format!("the {} changed by {c:.3e} over the window, above the divergence limit {limit:e}", self.label(i)));
|
||||
} else if changes.iter().all(|c| c.is_some_and(|c| c < rule.tolerance)) {
|
||||
self.steady_windows += 1;
|
||||
if late && self.steady_windows >= STEADY_WINDOWS {
|
||||
decision = Some(StopReason::Steady);
|
||||
}
|
||||
} else {
|
||||
self.steady_windows = 0;
|
||||
}
|
||||
self.report.samples.push(MonitorSample { step: k, time: k as f64 * setup.time_step, values, changes });
|
||||
if let Some(r) = decision {
|
||||
self.report.reason = r;
|
||||
}
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
fn label(&self, i: usize) -> &str {
|
||||
self.report.quantities.get(i).map_or("quantity", String::as_str)
|
||||
}
|
||||
|
||||
/// The backend stopped with an error: an unstable flow ends the run as diverged.
|
||||
pub fn unstable(&mut self, message: String) {
|
||||
self.report.reason = StopReason::Diverged;
|
||||
self.report.message = Some(message);
|
||||
}
|
||||
|
||||
pub fn report(self) -> SteadyReport {
|
||||
self.report
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::flow::Group;
|
||||
use crate::{Limits, run};
|
||||
use fieldcraft_model::{BoxFaces, Fluid, FluidDomain, Material, Model, Procedure, Step};
|
||||
|
||||
/// A stand-in backend whose uniform velocity is `u(step)`.
|
||||
struct Scripted {
|
||||
k: u64,
|
||||
cells: usize,
|
||||
u: fn(u64) -> f64,
|
||||
}
|
||||
|
||||
impl Backend for Scripted {
|
||||
fn name(&self) -> String {
|
||||
"cpu".into()
|
||||
}
|
||||
fn advance(&mut self, steps: u64) -> Result<()> {
|
||||
self.k += steps;
|
||||
Ok(())
|
||||
}
|
||||
fn steps_done(&self) -> u64 {
|
||||
self.k
|
||||
}
|
||||
fn fields(&mut self) -> Result<Fields> {
|
||||
Ok(Fields { density: vec![1.0; self.cells], velocity: vec![[(self.u)(self.k), 0.0, 0.0]; self.cells], temperature: Vec::new() })
|
||||
}
|
||||
fn mass(&mut self) -> Result<f64> {
|
||||
Ok(self.cells as f64)
|
||||
}
|
||||
fn forces(&mut self) -> Result<Vec<[f64; 3]>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn heat(&mut self) -> Result<Vec<f64>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn groups(&self) -> &[Group] {
|
||||
&[]
|
||||
}
|
||||
fn fluid_cells(&self) -> usize {
|
||||
self.cells
|
||||
}
|
||||
fn is_fluid(&self, _: usize) -> bool {
|
||||
true
|
||||
}
|
||||
fn empty_obstacles(&self) -> &[String] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// 2000 steps, a window of 50 steps.
|
||||
fn setup() -> Setup {
|
||||
let mut m = Model::default();
|
||||
m.add_material("Fluid", Material { density: Some(1.0), fluid: Some(Fluid::Newtonian { viscosity: 0.1 }), ..Material::default() }).unwrap();
|
||||
let domain = FluidDomain {
|
||||
material: "Fluid".into(),
|
||||
min: [0.0; 3],
|
||||
max: [1.0; 3],
|
||||
spacing: 0.5,
|
||||
faces: BoxFaces::default(),
|
||||
obstacles: vec![],
|
||||
surfaces: vec![],
|
||||
initial_velocity: [0.0; 3],
|
||||
acceleration: [0.0; 3],
|
||||
tolerance: None,
|
||||
thermal: None,
|
||||
porous: vec![],
|
||||
fans: vec![],
|
||||
};
|
||||
m.add_fluid_domain("Flow", domain).unwrap();
|
||||
let rule = Steady { monitors: vec![], tolerance: 1e-3, window: Some(50.0), min_time: None, divergence: None };
|
||||
let p = Procedure::Fluid {
|
||||
domain: "Flow".into(),
|
||||
end_time: None,
|
||||
steps: Some(2000),
|
||||
time_step: Some(1.0),
|
||||
frames: 1,
|
||||
magic: None,
|
||||
steady: Some(rule),
|
||||
};
|
||||
m.add_step(Step::new("Run", p), None).unwrap();
|
||||
Setup::new(&m, None, &[], &Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
fn stop(u: fn(u64) -> f64) -> (StopReason, u64) {
|
||||
stop_with(setup(), u)
|
||||
}
|
||||
|
||||
fn stop_with(s: Setup, u: fn(u64) -> f64) -> (StopReason, u64) {
|
||||
let mut b = Scripted { k: 0, cells: s.grid.len(), u };
|
||||
let r = run(&s, &mut b, &Limits::default()).unwrap();
|
||||
(r.steady.unwrap().reason, r.stats.steps)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oscillation_whose_period_divides_the_window_is_not_steady() {
|
||||
use std::f64::consts::PI;
|
||||
assert_eq!(setup().steady.unwrap().sample, 6);
|
||||
// 50 steps make 8 samples of 6 steps: the window is 48 steps.
|
||||
assert_eq!(setup().steady.unwrap().window, 48);
|
||||
assert_eq!(stop(|k| 0.05 + 0.02 * (2.0 * PI * k as f64 / 48.0).sin()), (StopReason::MaxTime, 2000));
|
||||
assert_eq!(stop(|k| 0.05 + 0.02 * (2.0 * PI * k as f64 / 24.0).sin()).0, StopReason::MaxTime);
|
||||
assert_eq!(stop(|k| 0.05 + 0.02 * (2.0 * PI * k as f64 / 50.0).sin()).0, StopReason::MaxTime);
|
||||
// A constant flow is steady after two windows; a settling one once it has settled.
|
||||
assert_eq!(stop(|_| 0.05), (StopReason::Steady, 96));
|
||||
let (reason, steps) = stop(|k| 0.05 * (1.0 - (-(k as f64) / 100.0).exp()));
|
||||
assert_eq!(reason, StopReason::Steady);
|
||||
assert!((600..800).contains(&steps), "{steps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_cut_short_by_the_end_takes_no_decision() {
|
||||
// Windows of 1496 steps in 2000: one full window, then 504 steps that do not count.
|
||||
let mut s = setup();
|
||||
let rule = s.steady.as_ref().unwrap().rule.clone();
|
||||
s.steady = Some(SteadySetup::new(&Steady { window: Some(1500.0), ..rule }, 2000, 1.0).unwrap());
|
||||
assert_eq!(stop_with(s, |_| 0.05), (StopReason::MaxTime, 2000));
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ fn schafer_turek_2d1(dx: f64, time_step: Option<f64>) -> (f64, f64, f64) {
|
||||
dom.faces.z_max = FaceCondition::Periodic {};
|
||||
dom.obstacles = vec!["Cylinder".into()];
|
||||
let cyl = obstacle("Cylinder", cylinder([0.2, 0.2], d / 2.0, -0.1, 0.1, 1024), &["side", "bottom", "top"]);
|
||||
let procedure = Procedure::Fluid { domain: "Flow".into(), end_time: Some(10.0), steps: None, time_step, frames: 1, magic: None };
|
||||
let procedure = Procedure::Fluid { domain: "Flow".into(), end_time: Some(10.0), steps: None, time_step, frames: 1, magic: None, steady: None };
|
||||
let m = model(1.0, nu, dom, procedure);
|
||||
let r = solve_fluid(&m, None, std::slice::from_ref(&cyl), &Limits::default()).unwrap();
|
||||
let side = r.forces.iter().find(|f| f.name == "side").unwrap();
|
||||
|
||||
@@ -22,12 +22,14 @@ pub fn domain(min: [f64; 3], max: [f64; 3], spacing: f64) -> FluidDomain {
|
||||
acceleration: [0.0; 3],
|
||||
tolerance: None,
|
||||
thermal: None,
|
||||
porous: vec![],
|
||||
fans: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// A fluid step `Run` over `end_time` with a given time step.
|
||||
pub fn step(end_time: f64, time_step: f64, magic: Option<f64>) -> Procedure {
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(end_time), steps: None, time_step: Some(time_step), frames: 2, magic }
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(end_time), steps: None, time_step: Some(time_step), frames: 2, magic, steady: None }
|
||||
}
|
||||
|
||||
/// A model with fluid `Fluid` (density ρ, kinematic viscosity ν), domain `Flow` and step `Run`.
|
||||
@@ -40,6 +42,29 @@ pub fn model(density: f64, nu: f64, domain: FluidDomain, procedure: Procedure) -
|
||||
m
|
||||
}
|
||||
|
||||
/// A fluid with heat: density ρ, kinematic viscosity ν, thermal diffusivity α (conductivity
|
||||
/// ρ α, unit specific heat), so k / (ρ c) = α.
|
||||
pub fn hot_model(density: f64, nu: f64, alpha: f64, domain: fieldcraft_model::FluidDomain, procedure: Procedure) -> Model {
|
||||
let mut m = Model::default();
|
||||
m.add_material(
|
||||
"Fluid",
|
||||
Material {
|
||||
density: Some(density),
|
||||
fluid: Some(Fluid::Newtonian { viscosity: nu * density }),
|
||||
thermal: Some(fieldcraft_model::Thermal {
|
||||
conductivity: Some(fieldcraft_model::Directional::Isotropic(density * alpha)),
|
||||
specific_heat: Some(1.0),
|
||||
..Default::default()
|
||||
}),
|
||||
..Material::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
m.add_fluid_domain("Flow", domain).unwrap();
|
||||
m.add_step(Step::new("Run", procedure), None).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
/// The setup and the flow at its initial state.
|
||||
pub fn prepare(m: &Model, obstacles: &[Obstacle]) -> (Setup, Flow) {
|
||||
let setup = Setup::new(m, None, obstacles, &Limits::default()).unwrap();
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Runs to steady state: plane Poiseuille flow and the de Vahl Davis cavity stop on their own and
|
||||
//! match a fixed long run; an unstable flow and a divergence limit stop as diverged; faces a
|
||||
//! monitor cannot find are refused.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use fieldcraft_lbm::{Error, FluidResult, Limits, StopReason, solve_fluid};
|
||||
use fieldcraft_model::{BoxFace, FaceCondition, FlowMonitor, FluidBoundary, FluidThermal, Model, Procedure, Steady, ThermalCondition};
|
||||
|
||||
const PERIODIC: FaceCondition = FaceCondition::Periodic {};
|
||||
|
||||
fn rule(tolerance: f64) -> Steady {
|
||||
Steady { monitors: vec![], tolerance, window: None, min_time: None, divergence: None }
|
||||
}
|
||||
|
||||
fn fluid(end_time: f64, time_step: f64, steady: Option<Steady>) -> Procedure {
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(end_time), steps: None, time_step: Some(time_step), frames: 4, magic: None, steady }
|
||||
}
|
||||
|
||||
fn solve(m: &Model) -> Result<FluidResult, Error> {
|
||||
solve_fluid(m, None, &[], &Limits::default())
|
||||
}
|
||||
|
||||
/// Plane Poiseuille flow between walls at y = 0 and 1 driven by a body force, periodic in x and
|
||||
/// z, 16 cells across, τ = 1: the peak velocity 0.1 settles on the viscous time h² / (π² ν).
|
||||
fn poiseuille(steady: Option<Steady>) -> Model {
|
||||
poiseuille_for(4.0 * 6.0, steady)
|
||||
}
|
||||
|
||||
fn poiseuille_for(end_time: f64, steady: Option<Steady>) -> Model {
|
||||
let (h, nu, umax) = (1.0, 1.0 / 6.0, 0.1);
|
||||
let dx = h / 16.0;
|
||||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||||
d.faces.x_min = PERIODIC;
|
||||
d.faces.x_max = PERIODIC;
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
d.acceleration = [8.0 * nu * umax / (h * h), 0.0, 0.0];
|
||||
model(1.0, nu, d, fluid(end_time, dx * dx, steady))
|
||||
}
|
||||
|
||||
/// The largest difference between the last frames' velocities, over the largest speed.
|
||||
fn velocity_difference(a: &FluidResult, b: &FluidResult) -> f64 {
|
||||
let (fa, fb) = (a.frames.last().unwrap(), b.frames.last().unwrap());
|
||||
let peak = fb.velocity.iter().map(|v| v[0].abs()).fold(0.0, f32::max);
|
||||
fa.velocity.iter().zip(&fb.velocity).map(|(u, v)| (0..3).map(|k| (u[k] - v[k]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) as f64
|
||||
/ peak as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poiseuille_flow_stops_at_steady_state_with_the_long_run_s_profile() {
|
||||
let fixed = solve(&poiseuille(None)).unwrap();
|
||||
assert!(fixed.steady.is_none());
|
||||
let walls = || {
|
||||
let mut r = rule(1e-5);
|
||||
r.monitors = vec![FlowMonitor::Force { faces: vec![FluidBoundary::Box(BoxFace::YMin), FluidBoundary::Box(BoxFace::YMax)] }];
|
||||
r
|
||||
};
|
||||
let run = solve(&poiseuille(Some(walls()))).unwrap();
|
||||
let report = run.steady.as_ref().unwrap();
|
||||
println!(
|
||||
"Poiseuille: steady after {} of {} steps ({:.0}% of the time), {} samples; velocity difference {:.2e}",
|
||||
run.stats.steps,
|
||||
fixed.stats.steps,
|
||||
100.0 * run.stats.steps as f64 / fixed.stats.steps as f64,
|
||||
report.samples.len(),
|
||||
velocity_difference(&run, &fixed)
|
||||
);
|
||||
assert_eq!(report.reason, StopReason::Steady, "{report:?}");
|
||||
assert_eq!(report.quantities, ["velocity", "force y_min, y_max"]);
|
||||
assert!(run.stats.steps < fixed.stats.steps / 2, "it saves at least half the time: {} of {}", run.stats.steps, fixed.stats.steps);
|
||||
assert_eq!(run.frames.last().unwrap().step, run.stats.steps, "a frame at the stop");
|
||||
assert!(run.frames.windows(2).all(|w| w[0].step < w[1].step));
|
||||
assert!(velocity_difference(&run, &fixed) < 1e-3, "{}", velocity_difference(&run, &fixed));
|
||||
// The walls take the driving force ρ a V between them: the monitored total is that.
|
||||
let last = report.samples.last().unwrap();
|
||||
let drive = 8.0 / 6.0 * 0.1 * (1.0 / 16.0) * (1.0 / 16.0);
|
||||
assert!((last.values[1][0] / drive - 1.0).abs() < 1e-3, "{last:?}");
|
||||
assert!(last.changes.iter().all(|c| c.unwrap() < 1e-5));
|
||||
// The same decisions every run.
|
||||
assert_eq!(solve(&poiseuille(Some(walls()))).unwrap(), run);
|
||||
}
|
||||
|
||||
/// The de Vahl Davis cavity at Ra 10³ on 16 cells, as in the thermal tests: hot wall at 1 on
|
||||
/// x_min, cold wall at 0 on x_max, Pr 0.71.
|
||||
fn cavity(steady: Option<Steady>) -> Model {
|
||||
let (h, pr, ra, u_max) = (1.0, 0.71, 1e3, 3.65);
|
||||
let dx = h / 16.0;
|
||||
let (alpha, nu) = (1.0, pr);
|
||||
let mut d = domain([0.0; 3], [h, h, dx], dx);
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal { initial_temperature: 0.5, gravity: [0.0, -ra * nu * alpha, 0.0], expansion: Some(1.0), ..FluidThermal::default() };
|
||||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||||
t.faces.x_max = ThermalCondition::Temperature { value: 0.0 };
|
||||
d.thermal = Some(t);
|
||||
let dt = (0.05 * dx / u_max).min(dx * dx / 4.0);
|
||||
hot_model(1.0, nu, alpha, d, fluid(4.0, dt, steady))
|
||||
}
|
||||
|
||||
fn hot_wall(r: &FluidResult) -> f64 {
|
||||
*r.heat.iter().find(|w| w.name == "x_min").unwrap().power.last().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_de_vahl_davis_cavity_stops_at_steady_state_with_the_long_run_s_heat() {
|
||||
let fixed = solve(&cavity(None)).unwrap();
|
||||
let mut r = rule(1e-4);
|
||||
r.monitors = vec![
|
||||
FlowMonitor::Heat { faces: vec![FluidBoundary::Box(BoxFace::XMin)] },
|
||||
FlowMonitor::Heat { faces: vec![FluidBoundary::Box(BoxFace::XMax)] },
|
||||
FlowMonitor::MeanTemperature {},
|
||||
];
|
||||
r.window = Some(0.05);
|
||||
let run = solve(&cavity(Some(r))).unwrap();
|
||||
let report = run.steady.as_ref().unwrap();
|
||||
let (q, q0) = (hot_wall(&run), hot_wall(&fixed));
|
||||
println!(
|
||||
"cavity: steady after {} of {} steps ({:.0}% of the time); hot wall {q:.6e} vs {q0:.6e}; velocity difference {:.2e}",
|
||||
run.stats.steps,
|
||||
fixed.stats.steps,
|
||||
100.0 * run.stats.steps as f64 / fixed.stats.steps as f64,
|
||||
velocity_difference(&run, &fixed)
|
||||
);
|
||||
assert_eq!(report.reason, StopReason::Steady, "{report:?}");
|
||||
assert_eq!(report.quantities, ["velocity", "temperature", "heat x_min", "heat x_max", "mean_temperature"]);
|
||||
assert!(run.stats.steps < fixed.stats.steps / 2, "{} of {}", run.stats.steps, fixed.stats.steps);
|
||||
assert!((q / q0 - 1.0).abs() < 1e-3, "{q} vs {q0}");
|
||||
assert!(velocity_difference(&run, &fixed) < 1e-2, "{}", velocity_difference(&run, &fixed));
|
||||
// The mean temperature of the symmetric cavity stays at ½.
|
||||
let mean = report.samples.last().unwrap().values[4][0];
|
||||
assert!((mean - 0.5).abs() < 1e-3, "{mean}");
|
||||
}
|
||||
|
||||
/// A periodic box under a body force with no walls accelerates without bound: the stability
|
||||
/// check stops it, and with a stop rule the run ends as diverged with the frames saved so far.
|
||||
#[test]
|
||||
fn an_unstable_flow_stops_as_diverged() {
|
||||
let dx = 0.1;
|
||||
let mut d = domain([0.0; 3], [dx; 3], dx);
|
||||
for f in BoxFace::ALL {
|
||||
*d.faces.get_mut(f) = PERIODIC;
|
||||
}
|
||||
d.acceleration = [1.0, 0.0, 0.0];
|
||||
// a Δt² / Δx = 0.009: below the acceleration limit; the lattice speed passes 0.5 after 56 steps.
|
||||
let dt = (0.009 * dx).sqrt();
|
||||
let mut r = rule(1e-6);
|
||||
r.window = Some(10.0 * dt);
|
||||
let unstable = |steady| {
|
||||
model(
|
||||
1.0,
|
||||
0.01,
|
||||
d.clone(),
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(200), time_step: Some(dt), frames: 20, magic: None, steady },
|
||||
)
|
||||
};
|
||||
assert!(matches!(solve(&unstable(None)), Err(Error::Unstable(_))), "without a rule it is an error");
|
||||
let run = solve(&unstable(Some(r.clone()))).unwrap();
|
||||
let report = run.steady.as_ref().unwrap();
|
||||
assert_eq!(report.reason, StopReason::Diverged);
|
||||
assert!(report.message.as_deref().unwrap().contains("speed"), "{report:?}");
|
||||
assert!(run.stats.steps < 60 && run.stats.steps >= 50, "{}", run.stats.steps);
|
||||
assert!(run.frames.iter().all(|f| f.step <= run.stats.steps && f.velocity.iter().all(|v| v[0].is_finite())));
|
||||
assert!(run.warnings.iter().any(|w| w.contains("diverged")), "{:?}", run.warnings);
|
||||
|
||||
// A divergence limit stops it sooner: the speed keeps growing by more than the limit.
|
||||
r.divergence = Some(0.05);
|
||||
r.min_time = Some(20.0 * dt);
|
||||
let run = solve(&unstable(Some(r))).unwrap();
|
||||
let report = run.steady.as_ref().unwrap();
|
||||
assert_eq!(report.reason, StopReason::Diverged);
|
||||
assert_eq!(run.stats.steps, 20, "the first sample at or after min_time: {report:?}");
|
||||
assert!(report.message.as_deref().unwrap().contains("divergence limit"), "{report:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flow_that_does_not_settle_runs_its_length_and_says_so() {
|
||||
// Two time units: about three viscous times h² / (π² ν), still settling.
|
||||
let mut r = rule(1e-3);
|
||||
r.window = Some(0.125);
|
||||
let run = solve(&poiseuille_for(2.0, Some(r))).unwrap();
|
||||
let report = run.steady.as_ref().unwrap();
|
||||
assert_eq!(report.reason, StopReason::MaxTime, "{report:?}");
|
||||
assert_eq!(run.stats.steps, solve(&poiseuille_for(2.0, None)).unwrap().stats.steps);
|
||||
assert!(run.warnings.iter().any(|w| w.contains("did not reach steady state")), "{:?}", run.warnings);
|
||||
// 2 time units in windows of ⅛, and the initial sample.
|
||||
assert_eq!(report.samples.len(), 17);
|
||||
assert!(report.samples[0].changes.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitors_the_flow_cannot_measure_are_refused() {
|
||||
let with = |monitors: Vec<FlowMonitor>| {
|
||||
let mut r = rule(1e-3);
|
||||
r.monitors = monitors;
|
||||
solve(&poiseuille(Some(r)))
|
||||
};
|
||||
let face = |body: &str, face: &str| FluidBoundary::Surface(fieldcraft_model::GeometryRef::face(body, face).unwrap());
|
||||
assert!(matches!(with(vec![FlowMonitor::Force { faces: vec![face("Ball", "skin")] }]), Err(Error::NotFound(_))));
|
||||
assert!(matches!(with(vec![FlowMonitor::Heat { faces: vec![FluidBoundary::Box(BoxFace::YMin)] }]), Err(Error::Invalid(_))));
|
||||
assert!(matches!(with(vec![FlowMonitor::MeanTemperature {}]), Err(Error::Invalid(_))));
|
||||
// A mass flow through a periodic face is the flow along the channel: fine.
|
||||
let run = with(vec![FlowMonitor::MassFlow { face: BoxFace::XMax }, FlowMonitor::MassFlow { face: BoxFace::XMin }]).unwrap();
|
||||
let last = run.steady.unwrap().samples.pop().unwrap();
|
||||
// ρ ū H Δx with the mean of the parabola ⅔ of its peak 0.1 (cell-averaged).
|
||||
let expected = 2.0 / 3.0 * 0.1 * 1.0 / 16.0;
|
||||
assert!((last.values[1][0] / expected - 1.0).abs() < 1e-2, "{last:?}");
|
||||
assert!((last.values[1][0] + last.values[2][0]).abs() < 1e-12, "{last:?}");
|
||||
}
|
||||
@@ -6,29 +6,10 @@ mod common;
|
||||
use common::*;
|
||||
use fieldcraft_lbm::backend::Backend;
|
||||
use fieldcraft_lbm::{Limits, run};
|
||||
use fieldcraft_model::{BoxFace, Directional, FaceCondition, Fluid, FluidThermal, Material, Model, Procedure, Step, Thermal, ThermalCondition};
|
||||
use fieldcraft_model::{BoxFace, FaceCondition, FluidThermal, ThermalCondition};
|
||||
|
||||
const PERIODIC: FaceCondition = FaceCondition::Periodic {};
|
||||
|
||||
/// A fluid with heat: density ρ, kinematic viscosity ν, thermal diffusivity α (conductivity
|
||||
/// ρ α, unit specific heat), so k / (ρ c) = α.
|
||||
fn hot_model(density: f64, nu: f64, alpha: f64, domain: fieldcraft_model::FluidDomain, procedure: Procedure) -> Model {
|
||||
let mut m = Model::default();
|
||||
m.add_material(
|
||||
"Fluid",
|
||||
Material {
|
||||
density: Some(density),
|
||||
fluid: Some(Fluid::Newtonian { viscosity: nu * density }),
|
||||
thermal: Some(Thermal { conductivity: Some(Directional::Isotropic(density * alpha)), specific_heat: Some(1.0), ..Default::default() }),
|
||||
..Material::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
m.add_fluid_domain("Flow", domain).unwrap();
|
||||
m.add_step(Step::new("Run", procedure), None).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
/// Plates at y = 0 and y = 1, periodic in x and z, no flow: held at 0 and 1, the steady
|
||||
/// temperature is linear, and anti-bounce-back puts the walls exactly halfway between cells.
|
||||
#[test]
|
||||
|
||||
@@ -33,6 +33,7 @@ fn cell_updates_per_second() {
|
||||
time_step: Some(1.0),
|
||||
frames: 1,
|
||||
magic: None,
|
||||
steady: None,
|
||||
},
|
||||
);
|
||||
let (setup, mut flow) = prepare(&m, &obstacles);
|
||||
|
||||
@@ -239,6 +239,7 @@ fn sphere_drag(r: f64, l: usize, steps: u64) -> (f64, f64) {
|
||||
time_step: Some(1.0),
|
||||
frames: 1,
|
||||
magic: None,
|
||||
steady: None,
|
||||
},
|
||||
);
|
||||
let (setup, mut flow) = prepare(&m, std::slice::from_ref(&ball));
|
||||
@@ -294,6 +295,7 @@ fn channel_with_a_parabolic_inlet_and_a_pressure_outlet_develops_poiseuille_flow
|
||||
time_step: Some(dt),
|
||||
frames: 4,
|
||||
magic: None,
|
||||
steady: None,
|
||||
};
|
||||
let m = model(1.0, nu, d, procedure);
|
||||
let r = solve_fluid(&m, Some("Run"), &[], &Limits::default()).unwrap();
|
||||
@@ -372,7 +374,15 @@ fn results_are_bit_for_bit_reproducible() {
|
||||
1.0,
|
||||
0.01,
|
||||
d,
|
||||
fieldcraft_model::Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 3, magic: None },
|
||||
fieldcraft_model::Procedure::Fluid {
|
||||
domain: "Flow".into(),
|
||||
end_time: Some(1.0),
|
||||
steps: None,
|
||||
time_step: None,
|
||||
frames: 3,
|
||||
magic: None,
|
||||
steady: None,
|
||||
},
|
||||
);
|
||||
let a = solve_fluid(&m, None, std::slice::from_ref(&ball), &Limits::default()).unwrap();
|
||||
let b = solve_fluid(&m, None, std::slice::from_ref(&ball), &Limits::default()).unwrap();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Fans and porous zones against analytic values: a porous plate across a plug flow drops the
|
||||
//! pressure by the Darcy–Forchheimer law (or its loss coefficient), and a fan pushing a periodic
|
||||
//! loop through such a plate settles at the intersection of its curve with the plate's
|
||||
//! resistance curve, conserving mass.
|
||||
//!
|
||||
//! The flows are one-dimensional (periodic across the channel), so the superficial velocity is
|
||||
//! the same everywhere and the resistance law is the whole pressure drop. Model and lattice units
|
||||
//! coincide (ρ = 1, Δx = Δt = 1).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{domain, model, prepare, run_all, step};
|
||||
use fieldcraft_lbm::{Flow, Limits, Setup, solve_fluid};
|
||||
use fieldcraft_model::{Axis, FaceCondition, Fan, FanDirection, PorousZone, Profile, Resistance};
|
||||
|
||||
const NU: f64 = 0.1;
|
||||
|
||||
fn plate(x0: f64, x1: f64, resistance: Resistance) -> PorousZone {
|
||||
PorousZone { name: "Vent".into(), min: [x0, 0.0, 0.0], max: [x1, 2.0, 2.0], axis: Axis::X, resistance }
|
||||
}
|
||||
|
||||
/// A 40 × 2 × 2 channel periodic across, a uniform inlet at `u`, a pressure outlet, and a porous
|
||||
/// plate over x 18..22 (four cell layers): the measured pressure drop and flow through it.
|
||||
fn plate_drop(u: f64, resistance: Resistance) -> (f64, f64) {
|
||||
let mut d = domain([0.0; 3], [40.0, 2.0, 2.0], 1.0);
|
||||
d.faces.x_min = FaceCondition::Velocity { velocity: [u, 0.0, 0.0], profile: Profile::Uniform };
|
||||
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
|
||||
for f in [&mut d.faces.y_min, &mut d.faces.y_max, &mut d.faces.z_min, &mut d.faces.z_max] {
|
||||
*f = FaceCondition::Periodic {};
|
||||
}
|
||||
d.porous = vec![plate(18.0, 22.0, resistance)];
|
||||
let m = model(1.0, NU, d, step(6000.0, 1.0, None));
|
||||
let (setup, mut flow) = prepare(&m, &[]);
|
||||
assert_eq!(setup.zones[0].layers(), 4);
|
||||
run_all(&setup, &mut flow);
|
||||
let s = flow.zone_samples()[0];
|
||||
(s.pressure, s.flow)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_porous_plate_drops_the_pressure_by_the_darcy_forchheimer_law() {
|
||||
let (k, c2, t) = (5.0, 2.0, 4.0);
|
||||
for u in [0.01, 0.03] {
|
||||
let (dp, q) = plate_drop(u, Resistance::DarcyForchheimer { permeability: k, inertial: c2 });
|
||||
assert!((q - 4.0 * u).abs() < 0.01 * 4.0 * u, "u {u}: flow {q}, expected {}", 4.0 * u);
|
||||
// The law at the superficial velocity and the density in the plate: the weakly
|
||||
// compressible scheme raises the density by p / c_s² (1 + 1.5 Δp halfway through the
|
||||
// plate, the outlet at 0), which moves both by about that fraction.
|
||||
let (up, rho) = (q / 4.0, 1.0 + 1.5 * dp);
|
||||
let expected = rho * (NU / k * up * t + 0.5 * c2 * up * up * t);
|
||||
assert!((dp - expected).abs() < 0.005 * expected, "u {u}: Δp {dp}, Darcy–Forchheimer {expected}");
|
||||
}
|
||||
// A loss coefficient: Δp = k ρ u²/2 across the plate, whatever its thickness.
|
||||
let (coefficient, u) = (3.0, 0.03);
|
||||
let (dp, q) = plate_drop(u, Resistance::LossCoefficient { coefficient });
|
||||
let expected = (1.0 + 1.5 * dp) * 0.5 * coefficient * (q / 4.0).powi(2);
|
||||
assert!((dp - expected).abs() < 0.005 * expected, "Δp {dp}, k ρ u²/2 {expected}");
|
||||
}
|
||||
|
||||
/// A periodic 40 × 2 × 2 loop: a fan over x 5..7 blowing +x with a linear curve, and the
|
||||
/// Darcy–Forchheimer plate of the test above.
|
||||
fn fan_loop() -> fieldcraft_model::Model {
|
||||
let mut d = domain([0.0; 3], [40.0, 2.0, 2.0], 1.0);
|
||||
for f in [&mut d.faces.x_min, &mut d.faces.x_max, &mut d.faces.y_min, &mut d.faces.y_max, &mut d.faces.z_min, &mut d.faces.z_max] {
|
||||
*f = FaceCondition::Periodic {};
|
||||
}
|
||||
d.porous = vec![plate(18.0, 22.0, Resistance::DarcyForchheimer { permeability: 5.0, inertial: 2.0 })];
|
||||
d.fans = vec![Fan {
|
||||
name: "Fan".into(),
|
||||
min: [5.0, 0.0, 0.0],
|
||||
max: [7.0, 2.0, 2.0],
|
||||
direction: FanDirection::XPlus,
|
||||
curve: vec![[0.0, 0.004], [0.1, 0.0025], [0.2, 0.0]],
|
||||
relaxation: Some(0.05),
|
||||
}];
|
||||
model(1.0, NU, d, step(5000.0, 1.0, None))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fan_settles_at_the_intersection_of_its_curve_and_the_system_resistance() {
|
||||
let m = fan_loop();
|
||||
let (setup, mut flow) = prepare(&m, &[]);
|
||||
let mass0 = flow.mass();
|
||||
run_all(&setup, &mut flow);
|
||||
// Operating point: the curve's first segment Δp = 0.004 − 0.015 Q meets the plate's
|
||||
// Δp = (ν/K) u t + (C₂/2) u² t with u = Q / 4 (area 2 × 2), t = 4.
|
||||
let (a, b, c) = (2.0 / 16.0 * 4.0 / 2.0, NU / 5.0 * 4.0 / 4.0 + 0.015, -0.004);
|
||||
let q = (-b + (b * b - 4.0 * a * c).sqrt()) / (2.0 * a);
|
||||
let rise = 0.004 - 0.015 * q;
|
||||
assert!(q < 0.1, "the operating point {q} is on the first segment");
|
||||
let samples = flow.zone_samples();
|
||||
let (vent, fan) = (samples[0], samples[1]);
|
||||
assert!((fan.flow - q).abs() < 0.005 * q, "fan flow {}, operating point {q}", fan.flow);
|
||||
assert!((fan.fan_rise - rise).abs() < 0.01 * rise, "fan rise {}, operating point {rise}", fan.fan_rise);
|
||||
assert!((vent.flow - q).abs() < 0.005 * q, "vent flow {}", vent.flow);
|
||||
assert!((vent.pressure - rise).abs() < 0.005 * rise, "the plate drops {} of the fan's {rise}", vent.pressure);
|
||||
assert!((fan.pressure - rise).abs() < 0.005 * rise, "measured rise across the fan {}", fan.pressure);
|
||||
assert!(((flow.mass() - mass0) / mass0).abs() < 1e-12, "mass {} → {}", mass0, flow.mass());
|
||||
|
||||
// The solve reports the same through its zone histories, in model units.
|
||||
let r = solve_fluid(&m, None, &[], &Limits::default()).unwrap();
|
||||
let z = r.zones.iter().find(|z| z.fan).unwrap();
|
||||
assert!((z.flow.last().unwrap() - q).abs() < 0.01 * q);
|
||||
assert!((z.fan_rise.last().unwrap() - rise).abs() < 0.01 * rise);
|
||||
assert_eq!(r.zones.len(), 2);
|
||||
assert_eq!(z.flow.len(), r.history_times.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zones_off_the_grid_or_on_an_open_layer_are_refused() {
|
||||
let base = || {
|
||||
let mut d = domain([0.0; 3], [10.0, 2.0, 2.0], 1.0);
|
||||
d.faces.x_min = FaceCondition::Velocity { velocity: [0.01, 0.0, 0.0], profile: Profile::Uniform };
|
||||
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
|
||||
d
|
||||
};
|
||||
let resistance = Resistance::LossCoefficient { coefficient: 1.0 };
|
||||
for zone in [
|
||||
plate(20.0, 30.0, resistance), // outside the box
|
||||
plate(3.1, 3.4, resistance), // thinner than a cell, no centre in it
|
||||
plate(0.0, 2.0, resistance), // on the inlet layer
|
||||
plate(9.0, 10.0, resistance), // on the outlet layer
|
||||
] {
|
||||
let mut d = base();
|
||||
d.porous = vec![zone.clone()];
|
||||
let m = model(1.0, NU, d, step(10.0, 1.0, None));
|
||||
let made = Setup::new(&m, None, &[], &Limits::default()).and_then(|s| Flow::new(&s, &[]));
|
||||
let e = made.err().unwrap_or_else(|| panic!("{zone:?} was accepted")).to_string();
|
||||
assert!(e.contains("Vent"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "fieldcraft-mesh-gpu"
|
||||
description = "FieldCraft's projection smoothing on the GPU: the coloured Winslow sweeps' free nodes as wgpu compute shaders, driven through fieldcraft-mesh's sweep device and checked against the CPU's coloured sweeps"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-mesh = { workspace = true }
|
||||
fieldcraft-gpu = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Projection smoothing on the GPU: [`GpuSweep`] runs the free (interior) nodes of
|
||||
//! `fieldcraft_mesh`'s coloured Winslow sweeps with the compute shader of `sweep.wgsl`, the CPU
|
||||
//! sweep's update and guarded step written for the device; `fieldcraft_mesh::project` drives it
|
||||
//! through its sweep device ([`fieldcraft_mesh::project::device`]) colour by colour and moves the
|
||||
//! boundary nodes on the geometry itself.
|
||||
//!
|
||||
//! Single precision, positions relative to the mesh's centre: the CPU's coloured sweeps are the
|
||||
//! reference. The device is deterministic on itself and gives the same verdicts as they do with a
|
||||
//! smallest scaled Jacobian within 0.02 of theirs: near the guard's thresholds a step accepted in
|
||||
//! double precision can be refused in single (or the reverse), and over many sweeps the grids part
|
||||
//! (the meshing skill's corpus: up to ±0.015 on one part, on a GB10 and an M4 Max). On curved
|
||||
//! analytic parts the nodes agree within a few millionths of an element (`tests/agreement.rs`). The mesh is certified on the CPU in double
|
||||
//! precision afterwards, as always. The device is the one `fieldcraft_gpu`'s adapter policy picks
|
||||
//! (`FIELDCRAFT_GPU`).
|
||||
//!
|
||||
//! Sources: the same as the CPU smoother (Winslow 1967; Thompson, Warsi and Mastin 1985;
|
||||
//! Thomas and Middlecoff 1980; Knupp 2001 for the scaled Jacobian).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::{GpuSweep, register};
|
||||
|
||||
/// In the browser there is no device yet (WebGPU later): registering is a no-op and a projection
|
||||
/// with the device backend reports that none is registered.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn register() {}
|
||||
@@ -0,0 +1,360 @@
|
||||
//! The sweep device on wgpu (see the crate docs).
|
||||
|
||||
use fieldcraft_gpu::select::{self, Policy};
|
||||
use fieldcraft_mesh::project::device::{Move, Rule, SweepDevice, SweepMesh, register_sweep_device};
|
||||
use fieldcraft_mesh::{Error, Result};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const SHADER: &str = include_str!("sweep.wgsl");
|
||||
|
||||
/// `u32`s per class node in the rule buffer (see `sweep.wgsl`).
|
||||
const STRIDE: usize = 24;
|
||||
|
||||
/// Threads per workgroup (`@workgroup_size` in `sweep.wgsl`).
|
||||
const GROUP: u32 = 64;
|
||||
|
||||
/// Most workgroups along one dispatch dimension.
|
||||
const MAX_GROUPS: u32 = 65_535;
|
||||
|
||||
/// How long one wait for the device may take before smoothing gives up on it.
|
||||
const DEVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
fn gpu_error(e: impl std::fmt::Display) -> Error {
|
||||
Error::Failed(format!("GPU smoothing: {e}"))
|
||||
}
|
||||
|
||||
/// Register the GPU as the process's sweep device (an application does this once at start).
|
||||
pub fn register() {
|
||||
register_sweep_device(Box::new(|mesh| GpuSweep::from_env(mesh).map(|g| Box::new(g) as Box<dyn SweepDevice>)));
|
||||
}
|
||||
|
||||
/// The uniform of one dispatch (`Params` in `sweep.wgsl`).
|
||||
fn params(start: u32, count: u32, floor: f32, min_move: f32, row: u32, around_base: u32) -> Vec<u8> {
|
||||
bytes_u32(&[start, count, floor.to_bits(), min_move.to_bits(), row, around_base, 0, 0])
|
||||
}
|
||||
|
||||
fn bytes_u32(v: &[u32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
fn bytes_f32(v: &[f32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
fn index(n: usize, what: &str) -> Result<u32> {
|
||||
u32::try_from(n).map_err(|_| Error::Limit(format!("{n} {what} are more than the GPU smoother indexes")))
|
||||
}
|
||||
|
||||
/// The free nodes of the coloured sweeps on a GPU.
|
||||
pub struct GpuSweep {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
adapter: String,
|
||||
sweep: wgpu::ComputePipeline,
|
||||
scatter: wgpu::ComputePipeline,
|
||||
bind: wgpu::BindGroup,
|
||||
params: wgpu::Buffer,
|
||||
out: wgpu::Buffer,
|
||||
upd: wgpu::Buffer,
|
||||
upd_node: wgpu::Buffer,
|
||||
/// Where each class starts in the rule and output buffers, and its node count.
|
||||
classes: Vec<(u32, u32)>,
|
||||
/// The most host moves one [`SweepDevice::set`] takes.
|
||||
upd_room: usize,
|
||||
origin: [f64; 3],
|
||||
floor: f32,
|
||||
min_move: f32,
|
||||
around_base: u32,
|
||||
}
|
||||
|
||||
impl GpuSweep {
|
||||
/// The sweeps of `mesh` on the device `FIELDCRAFT_GPU` picks.
|
||||
pub fn from_env(mesh: &SweepMesh) -> Result<GpuSweep> {
|
||||
GpuSweep::new(mesh, Policy::from_env().map_err(gpu_error)?)
|
||||
}
|
||||
|
||||
/// The sweeps of `mesh` on the device `policy` picks.
|
||||
pub fn new(mesh: &SweepMesh, policy: Policy) -> Result<GpuSweep> {
|
||||
let Some((adapter, tier)) = select::adapter(policy).map_err(gpu_error)? else {
|
||||
let why = if policy == Policy::Cpu { format!("{}=cpu", select::ENV) } else { format!("no adapter matches {}={policy:?}", select::ENV) };
|
||||
return Err(Error::Invalid(format!("{why}: no GPU device; smooth with the `cpu` or `coloured` backend")));
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
let have = adapter.limits();
|
||||
let mut limits = wgpu::Limits::default().using_resolution(have.clone());
|
||||
limits.max_buffer_size = have.max_buffer_size;
|
||||
limits.max_storage_buffer_binding_size = have.max_storage_buffer_binding_size;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("fieldcraft smoothing"),
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(gpu_error)?;
|
||||
let adapter_name = format!("{}{}", info.name, if tier == select::Tier::Software { " (software)" } else { "" });
|
||||
|
||||
// Positions relative to the centre of the nodes' box, where single precision is finest.
|
||||
let n = mesh.nodes.len();
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in mesh.nodes {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
let origin = if n == 0 { [0.0; 3] } else { [0, 1, 2].map(|k| 0.5 * (lo[k] + hi[k])) };
|
||||
let mut pos = Vec::with_capacity(4 * n);
|
||||
for p in mesh.nodes {
|
||||
pos.extend([(p[0] - origin[0]) as f32, (p[1] - origin[1]) as f32, (p[2] - origin[2]) as f32, 0.0]);
|
||||
}
|
||||
let hexes: Vec<u32> = mesh.hexes.iter().flatten().copied().collect();
|
||||
let mut around = Vec::with_capacity(n + 1 + 8 * mesh.hexes.len());
|
||||
let mut offset = 0usize;
|
||||
around.push(0);
|
||||
for a in mesh.around {
|
||||
offset += a.len();
|
||||
around.push(index(offset, "element references")?);
|
||||
}
|
||||
let around_base = index(around.len(), "nodes")?;
|
||||
for a in mesh.around {
|
||||
around.extend_from_slice(a);
|
||||
}
|
||||
let mut rules = Vec::new();
|
||||
let mut means = Vec::new();
|
||||
let mut classes = Vec::with_capacity(mesh.classes.len());
|
||||
for class in &mesh.classes {
|
||||
classes.push((index(rules.len() / STRIDE, "class nodes")?, index(class.len(), "class nodes")?));
|
||||
for node in class {
|
||||
let mut r = [0u32; STRIDE];
|
||||
r[0] = node.node;
|
||||
match &node.rule {
|
||||
Rule::Winslow { nb, diag, control } => {
|
||||
for (slot, v) in r[2..8].iter_mut().zip(nb.iter().flatten()) {
|
||||
*slot = *v;
|
||||
}
|
||||
for (slot, v) in r[8..20].iter_mut().zip(diag.iter().flatten().flatten()) {
|
||||
*slot = *v;
|
||||
}
|
||||
for (slot, c) in r[20..23].iter_mut().zip(control) {
|
||||
*slot = (*c as f32).to_bits();
|
||||
}
|
||||
}
|
||||
Rule::Mean(edges) => {
|
||||
r[1] = 1;
|
||||
r[2] = index(means.len(), "neighbour references")?;
|
||||
r[3] = index(edges.len(), "neighbours")?;
|
||||
means.extend_from_slice(edges);
|
||||
}
|
||||
}
|
||||
rules.extend_from_slice(&r);
|
||||
}
|
||||
}
|
||||
let class_nodes = rules.len() / STRIDE;
|
||||
// Every class's boundary nodes may move: room for the largest class's rest.
|
||||
let upd_room = mesh.around.len().max(1);
|
||||
|
||||
let cap = device.limits().max_storage_buffer_binding_size.min(device.limits().max_buffer_size);
|
||||
for (what, bytes) in
|
||||
[("node positions", 16 * n), ("elements", 4 * hexes.len()), ("element lists", 4 * around.len()), ("stencils", 4 * rules.len())]
|
||||
{
|
||||
if bytes as u64 > cap {
|
||||
return Err(Error::Limit(format!("the {what} need {} MB; this GPU allows {} MB per buffer", bytes >> 20, cap >> 20)));
|
||||
}
|
||||
}
|
||||
|
||||
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
|
||||
let storage = |label: &str, bytes: &[u8]| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: if bytes.is_empty() { &[0u8; 16][..] } else { bytes },
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
})
|
||||
};
|
||||
let empty = |label: &str, size: u64| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: size.max(16),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let pos_buf = storage("smoothing positions", &bytes_f32(&pos));
|
||||
let hex_buf = storage("smoothing elements", &bytes_u32(&hexes));
|
||||
let around_buf = storage("smoothing element lists", &bytes_u32(&around));
|
||||
let rule_buf = storage("smoothing stencils", &bytes_u32(&rules));
|
||||
let mean_buf = storage("smoothing neighbours", &bytes_u32(&means));
|
||||
let out = empty("smoothing moves", 16 * class_nodes as u64);
|
||||
let upd = empty("smoothing host moves", 16 * upd_room as u64);
|
||||
let upd_node = empty("smoothing host moved nodes", 4 * upd_room as u64);
|
||||
let params_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("smoothing params"),
|
||||
size: 32,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("fieldcraft smoothing"),
|
||||
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
|
||||
});
|
||||
let entry = |binding: u32, ty: wgpu::BufferBindingType| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
};
|
||||
let read = wgpu::BufferBindingType::Storage { read_only: true };
|
||||
let write = wgpu::BufferBindingType::Storage { read_only: false };
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("fieldcraft smoothing"),
|
||||
entries: &[
|
||||
entry(0, wgpu::BufferBindingType::Uniform),
|
||||
entry(1, write),
|
||||
entry(2, read),
|
||||
entry(3, read),
|
||||
entry(4, read),
|
||||
entry(5, read),
|
||||
entry(6, write),
|
||||
entry(7, read),
|
||||
entry(8, read),
|
||||
],
|
||||
});
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("fieldcraft smoothing"),
|
||||
layout: &layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: params_buf.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: pos_buf.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: hex_buf.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: around_buf.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 4, resource: rule_buf.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 5, resource: mean_buf.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 6, resource: out.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 7, resource: upd.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 8, resource: upd_node.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("fieldcraft smoothing"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = |name: &str| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(name),
|
||||
layout: Some(&pl),
|
||||
module: &module,
|
||||
entry_point: Some(name),
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let (sweep, scatter) = (pipeline("sweep"), pipeline("scatter"));
|
||||
for scope in scopes.into_iter().rev() {
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(gpu_error(e));
|
||||
}
|
||||
}
|
||||
Ok(GpuSweep {
|
||||
device,
|
||||
queue,
|
||||
adapter: adapter_name,
|
||||
sweep,
|
||||
scatter,
|
||||
bind,
|
||||
params: params_buf,
|
||||
out,
|
||||
upd,
|
||||
upd_node,
|
||||
classes,
|
||||
upd_room,
|
||||
origin,
|
||||
floor: mesh.floor as f32,
|
||||
min_move: mesh.min_move as f32,
|
||||
around_base,
|
||||
})
|
||||
}
|
||||
|
||||
/// Workgroups for `count` threads as `(x, y)`, and threads per row.
|
||||
fn grid(count: u32) -> (u32, u32, u32) {
|
||||
let groups = count.div_ceil(GROUP).max(1);
|
||||
let gx = groups.min(MAX_GROUPS);
|
||||
(gx, groups.div_ceil(gx), gx * GROUP)
|
||||
}
|
||||
|
||||
fn dispatch(&self, pipeline: &wgpu::ComputePipeline, start: u32, count: u32, copy: Option<(&wgpu::Buffer, u64)>) {
|
||||
let (gx, gy, row) = GpuSweep::grid(count);
|
||||
self.queue.write_buffer(&self.params, 0, ¶ms(start, count, self.floor, self.min_move, row, self.around_base));
|
||||
let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("fieldcraft smoothing") });
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("fieldcraft smoothing"), timestamp_writes: None });
|
||||
pass.set_bind_group(0, &self.bind, &[]);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.dispatch_workgroups(gx, gy, 1);
|
||||
}
|
||||
if let Some((staging, size)) = copy {
|
||||
enc.copy_buffer_to_buffer(&self.out, 16 * u64::from(start), staging, 0, size);
|
||||
}
|
||||
self.queue.submit([enc.finish()]);
|
||||
}
|
||||
}
|
||||
|
||||
impl SweepDevice for GpuSweep {
|
||||
fn name(&self) -> String {
|
||||
format!("gpu: {}", self.adapter)
|
||||
}
|
||||
|
||||
fn sweep(&mut self, class: usize) -> Result<Vec<Move>> {
|
||||
let &(start, count) = self.classes.get(class).ok_or_else(|| gpu_error(format!("no colour class {class}")))?;
|
||||
if count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let size = 16 * u64::from(count);
|
||||
let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("smoothing readback"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.dispatch(&self.sweep, start, count, Some((&staging, size)));
|
||||
let slice = staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
// A bounded wait: a hung or lost device is an error, never a hang.
|
||||
self.device
|
||||
.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(DEVICE_TIMEOUT) })
|
||||
.map_err(|e| gpu_error(format!("the device did not answer within {} s: {e}", DEVICE_TIMEOUT.as_secs())))?;
|
||||
rx.try_recv().map_err(|_| gpu_error("the readback did not complete"))?.map_err(gpu_error)?;
|
||||
let bytes = slice.get_mapped_range().map_err(gpu_error)?.to_vec();
|
||||
staging.unmap();
|
||||
let o = self.origin;
|
||||
let moves = bytes
|
||||
.as_chunks::<16>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let [x, y, z, w] = c.as_chunks::<4>().0 else { return Move::Stay };
|
||||
let f = |b: &[u8; 4]| f32::from_le_bytes(*b);
|
||||
match f(w) {
|
||||
1.0 => Move::To([o[0] + f64::from(f(x)), o[1] + f64::from(f(y)), o[2] + f64::from(f(z))]),
|
||||
2.0 => Move::Refused,
|
||||
_ => Move::Stay,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(moves)
|
||||
}
|
||||
|
||||
fn set(&mut self, moved: &[(u32, [f64; 3])]) -> Result<()> {
|
||||
for part in moved.chunks(self.upd_room) {
|
||||
let count = index(part.len(), "moved nodes")?;
|
||||
let o = self.origin;
|
||||
let at: Vec<f32> = part.iter().flat_map(|(_, p)| [(p[0] - o[0]) as f32, (p[1] - o[1]) as f32, (p[2] - o[2]) as f32, 0.0]).collect();
|
||||
let nodes: Vec<u32> = part.iter().map(|(v, _)| *v).collect();
|
||||
self.queue.write_buffer(&self.upd, 0, &bytes_f32(&at));
|
||||
self.queue.write_buffer(&self.upd_node, 0, &bytes_u32(&nodes));
|
||||
self.dispatch(&self.scatter, 0, count, None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// The free nodes' part of fieldcraft_mesh's coloured Winslow sweeps (project/smooth.rs), in
|
||||
// single precision. One thread per node of the colour class: Winslow's update from the node's
|
||||
// stencil (or the mean of its edge neighbours), then the guarded step — the full move, else half,
|
||||
// else a quarter, the first that keeps the smallest scaled Jacobian of the elements around the
|
||||
// node at or above the lower of its value before and the floor, and their largest edge ratio at
|
||||
// or below the higher of its value before and the ceiling. No two nodes of a class share an
|
||||
// element, so a thread reads only nodes no other thread of the dispatch writes, and each moves
|
||||
// its own node in place.
|
||||
//
|
||||
// Positions are relative to the mesh's centre (the host adds it back).
|
||||
|
||||
struct Params {
|
||||
// First node of the class in `rules` and `out`, and its node count.
|
||||
start: u32,
|
||||
count: u32,
|
||||
floor: f32,
|
||||
min_move: f32,
|
||||
// Threads per row of the dispatch grid (workgroups in x times 64).
|
||||
row: u32,
|
||||
// Where the element lists start in `around` (after the node count + 1 offsets).
|
||||
around_base: u32,
|
||||
pad0: u32,
|
||||
pad1: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> prm: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> pos: array<vec4<f32>>;
|
||||
// Eight corner nodes per element, in hex8 order.
|
||||
@group(0) @binding(2) var<storage, read> hexes: array<u32>;
|
||||
// Per node the offsets of its elements, then the element lists.
|
||||
@group(0) @binding(3) var<storage, read> around: array<u32>;
|
||||
// Per class node: node, rule (0 Winslow, 1 mean), then for Winslow the six edge neighbours
|
||||
// nb[d][s], the twelve face diagonals diag[pair][sa][sb] and the three control functions (as
|
||||
// bits); for a mean the offset and length of its neighbours in `means`.
|
||||
@group(0) @binding(4) var<storage, read> rules: array<u32>;
|
||||
@group(0) @binding(5) var<storage, read> means: array<u32>;
|
||||
// Per class node: where it is after the sweep, and w = 0 (stayed), 1 (moved), 2 (refused).
|
||||
@group(0) @binding(6) var<storage, read_write> out: array<vec4<f32>>;
|
||||
// Nodes the host moved: where to, and which.
|
||||
@group(0) @binding(7) var<storage, read> upd: array<vec4<f32>>;
|
||||
@group(0) @binding(8) var<storage, read> upd_node: array<u32>;
|
||||
|
||||
const STRIDE: u32 = 24u;
|
||||
const RATIO_CEILING: f32 = 5.0;
|
||||
const BIG: f32 = 3.0e38;
|
||||
|
||||
// The corners of the unit cube in hex8 order.
|
||||
const CORNERS = array<vec3<u32>, 8>(
|
||||
vec3<u32>(0u, 0u, 0u), vec3<u32>(1u, 0u, 0u), vec3<u32>(1u, 1u, 0u), vec3<u32>(0u, 1u, 0u),
|
||||
vec3<u32>(0u, 0u, 1u), vec3<u32>(1u, 0u, 1u), vec3<u32>(1u, 1u, 1u), vec3<u32>(0u, 1u, 1u),
|
||||
);
|
||||
|
||||
// Where the scaled Jacobian is also measured inside: the centre and the six face centres.
|
||||
const INSIDE = array<vec3<f32>, 7>(
|
||||
vec3<f32>(0.5, 0.5, 0.5), vec3<f32>(0.5, 0.5, 0.0), vec3<f32>(0.5, 0.5, 1.0), vec3<f32>(0.5, 0.0, 0.5),
|
||||
vec3<f32>(0.5, 1.0, 0.5), vec3<f32>(0.0, 0.5, 0.5), vec3<f32>(1.0, 0.5, 0.5),
|
||||
);
|
||||
|
||||
fn corner_at(b: vec3<u32>) -> u32 {
|
||||
var k = select(select(0u, 1u, b.x == 1u), select(3u, 2u, b.x == 1u), b.y == 1u);
|
||||
return k + 4u * b.z;
|
||||
}
|
||||
|
||||
fn flip(b: vec3<u32>, d: u32) -> vec3<u32> {
|
||||
var f = b;
|
||||
f[d] = 1u - f[d];
|
||||
return f;
|
||||
}
|
||||
|
||||
fn normalized(a: vec3<f32>, b: vec3<f32>, c: vec3<f32>) -> f32 {
|
||||
let l = length(a) * length(b) * length(c);
|
||||
if (l > 0.0 && l < BIG) {
|
||||
return dot(a, cross(b, c)) / l;
|
||||
}
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
struct Local {
|
||||
sj: f32,
|
||||
ratio: f32,
|
||||
}
|
||||
|
||||
// The smallest scaled Jacobian (corners, centre and face centres) and the edge ratio of one
|
||||
// element (project/smooth.rs: hex_scaled_jacobian, hex_edge_ratio).
|
||||
fn element(x: array<vec3<f32>, 8>) -> Local {
|
||||
var c = x;
|
||||
var corners = CORNERS;
|
||||
var inside = INSIDE;
|
||||
var m = BIG;
|
||||
for (var k = 0u; k < 8u; k++) {
|
||||
let bits = corners[k];
|
||||
var e: array<vec3<f32>, 3>;
|
||||
for (var d = 0u; d < 3u; d++) {
|
||||
var v = c[corner_at(flip(bits, d))] - c[k];
|
||||
if (bits[d] == 1u) {
|
||||
v = -v;
|
||||
}
|
||||
e[d] = v;
|
||||
}
|
||||
m = min(m, normalized(e[0], e[1], e[2]));
|
||||
}
|
||||
for (var q = 0u; q < 7u; q++) {
|
||||
let u = inside[q];
|
||||
var e: array<vec3<f32>, 3>;
|
||||
for (var d = 0u; d < 3u; d++) {
|
||||
var s = vec3<f32>(0.0);
|
||||
for (var k = 0u; k < 8u; k++) {
|
||||
let bits = corners[k];
|
||||
var w = 1.0;
|
||||
for (var j = 0u; j < 3u; j++) {
|
||||
if (j == d) {
|
||||
w *= select(-1.0, 1.0, bits[j] == 1u);
|
||||
} else {
|
||||
w *= select(1.0 - u[j], u[j], bits[j] == 1u);
|
||||
}
|
||||
}
|
||||
s += c[k] * w;
|
||||
}
|
||||
e[d] = s;
|
||||
}
|
||||
m = min(m, normalized(e[0], e[1], e[2]));
|
||||
}
|
||||
var lo = BIG;
|
||||
var hi = 0.0;
|
||||
for (var k = 0u; k < 8u; k++) {
|
||||
let bits = corners[k];
|
||||
for (var d = 0u; d < 3u; d++) {
|
||||
if (bits[d] == 0u) {
|
||||
let l = distance(c[k], c[corner_at(flip(bits, d))]);
|
||||
lo = min(lo, l);
|
||||
hi = max(hi, l);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ratio = BIG;
|
||||
if (lo > 0.0) {
|
||||
ratio = hi / lo;
|
||||
}
|
||||
return Local(m, ratio);
|
||||
}
|
||||
|
||||
// The quality of the elements around node `v` with `v` at `p`.
|
||||
fn quality(v: u32, p: vec3<f32>) -> Local {
|
||||
var l = Local(BIG, 1.0);
|
||||
let first = around[v];
|
||||
let end = around[v + 1u];
|
||||
for (var j = first; j < end; j++) {
|
||||
let h = around[prm.around_base + j];
|
||||
var x: array<vec3<f32>, 8>;
|
||||
for (var k = 0u; k < 8u; k++) {
|
||||
let w = hexes[8u * h + k];
|
||||
x[k] = select(pos[w].xyz, p, w == v);
|
||||
}
|
||||
let e = element(x);
|
||||
l.sj = min(l.sj, e.sj);
|
||||
l.ratio = max(l.ratio, e.ratio);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
fn at(w: u32) -> vec3<f32> {
|
||||
return pos[w].xyz;
|
||||
}
|
||||
|
||||
struct Target {
|
||||
q: vec3<f32>,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
// Winslow's update of a node with a structured stencil (project/smooth.rs: winslow).
|
||||
fn winslow(r: u32) -> Target {
|
||||
var xi: array<vec3<f32>, 3>;
|
||||
for (var d = 0u; d < 3u; d++) {
|
||||
xi[d] = 0.5 * (at(rules[r + 2u + 2u * d + 1u]) - at(rules[r + 2u + 2u * d]));
|
||||
}
|
||||
let g11 = dot(xi[0], xi[0]);
|
||||
let g22 = dot(xi[1], xi[1]);
|
||||
let g33 = dot(xi[2], xi[2]);
|
||||
let g12 = dot(xi[0], xi[1]);
|
||||
let g13 = dot(xi[0], xi[2]);
|
||||
let g23 = dot(xi[1], xi[2]);
|
||||
var cof_diag = array<f32, 3>(g22 * g33 - g23 * g23, g11 * g33 - g13 * g13, g11 * g22 - g12 * g12);
|
||||
var cof_off = array<f32, 3>(g13 * g23 - g12 * g33, g12 * g23 - g13 * g22, g13 * g12 - g11 * g23);
|
||||
let den = 2.0 * (cof_diag[0] + cof_diag[1] + cof_diag[2]);
|
||||
if (!(den > 0.0 && den < BIG)) {
|
||||
return Target(vec3<f32>(0.0), false);
|
||||
}
|
||||
var num = vec3<f32>(0.0);
|
||||
for (var d = 0u; d < 3u; d++) {
|
||||
let m = at(rules[r + 2u + 2u * d]);
|
||||
let pl = at(rules[r + 2u + 2u * d + 1u]);
|
||||
let control = bitcast<f32>(rules[r + 20u + d]);
|
||||
num += (m + pl) * cof_diag[d];
|
||||
num += (pl - m) * (0.5 * cof_diag[d] * control);
|
||||
}
|
||||
for (var q = 0u; q < 3u; q++) {
|
||||
let dg = r + 8u + 4u * q;
|
||||
let cross_d = 0.25 * ((at(rules[dg + 3u]) + at(rules[dg])) - (at(rules[dg + 2u]) + at(rules[dg + 1u])));
|
||||
num += cross_d * (2.0 * cof_off[q]);
|
||||
}
|
||||
let out = num / den;
|
||||
let ok = all(abs(out) < vec3<f32>(BIG));
|
||||
return Target(out, ok);
|
||||
}
|
||||
|
||||
// The mean of a node's edge neighbours.
|
||||
fn mean(r: u32) -> Target {
|
||||
let first = rules[r + 2u];
|
||||
let len = rules[r + 3u];
|
||||
if (len == 0u) {
|
||||
return Target(vec3<f32>(0.0), false);
|
||||
}
|
||||
var s = vec3<f32>(0.0);
|
||||
for (var j = 0u; j < len; j++) {
|
||||
s += at(means[first + j]);
|
||||
}
|
||||
return Target(s * (1.0 / f32(len)), true);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn sweep(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let i = gid.x + gid.y * prm.row;
|
||||
if (i >= prm.count) {
|
||||
return;
|
||||
}
|
||||
let slot = prm.start + i;
|
||||
let r = slot * STRIDE;
|
||||
let v = rules[r];
|
||||
let p = at(v);
|
||||
var t: Target;
|
||||
if (rules[r + 1u] == 0u) {
|
||||
t = winslow(r);
|
||||
} else {
|
||||
t = mean(r);
|
||||
}
|
||||
if (!t.ok || distance(t.q, p) <= prm.min_move) {
|
||||
out[slot] = vec4<f32>(p, 0.0);
|
||||
return;
|
||||
}
|
||||
let before = quality(v, p);
|
||||
let floor = min(before.sj, prm.floor);
|
||||
let ceiling = max(before.ratio, RATIO_CEILING);
|
||||
var steps = array<f32, 3>(1.0, 0.5, 0.25);
|
||||
for (var s = 0u; s < 3u; s++) {
|
||||
var to = t.q;
|
||||
if (s > 0u) {
|
||||
to = p + (t.q - p) * steps[s];
|
||||
}
|
||||
let after = quality(v, to);
|
||||
if (after.sj >= floor && after.ratio <= ceiling) {
|
||||
pos[v] = vec4<f32>(to, 0.0);
|
||||
out[slot] = vec4<f32>(to, 1.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
out[slot] = vec4<f32>(p, 2.0);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn scatter(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let i = gid.x + gid.y * prm.row;
|
||||
if (i >= prm.count) {
|
||||
return;
|
||||
}
|
||||
pos[upd_node[i]] = vec4<f32>(upd[i].xyz, 0.0);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! The GPU sweeps against the CPU's coloured sweeps, their reference: the same projections with
|
||||
//! `Backend::Coloured` and `Backend::Device`. Skipped without a GPU adapter unless
|
||||
//! `FIELDCRAFT_REQUIRE_GPU=1`; `FIELDCRAFT_GPU` picks the adapter.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
use std::time::Instant;
|
||||
|
||||
use fieldcraft_gpu::select::gpu_required;
|
||||
use fieldcraft_mesh::project::{Backend, ProjectOptions, ProjectedMesh, Smoothing, Topology, project_mesh};
|
||||
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
|
||||
use fieldcraft_mesh::tet::geometry::{AnalyticSurface, Tessellation};
|
||||
|
||||
/// A UV sphere of radius `r` about the origin, one face, with its exact surface.
|
||||
fn sphere(r: f64, n: usize) -> (BoundarySurface, Tessellation) {
|
||||
let rings = n / 2;
|
||||
let mut p = vec![[0.0, 0.0, -r], [0.0, 0.0, r]];
|
||||
for j in 1..rings {
|
||||
let th = PI * j as f64 / rings as f64 - PI / 2.0;
|
||||
for i in 0..n {
|
||||
let a = TAU * i as f64 / n as f64;
|
||||
p.push([r * th.cos() * a.cos(), r * th.cos() * a.sin(), r * th.sin()]);
|
||||
}
|
||||
}
|
||||
let id = |j: usize, i: usize| (2 + (j - 1) * n + i % n) as u32;
|
||||
let mut tris = Vec::new();
|
||||
for i in 0..n {
|
||||
tris.push([0, id(1, i + 1), id(1, i)]);
|
||||
tris.push([1, id(rings - 1, i), id(rings - 1, i + 1)]);
|
||||
}
|
||||
for j in 1..rings - 1 {
|
||||
for i in 0..n {
|
||||
tris.push([id(j, i), id(j, i + 1), id(j + 1, i + 1)]);
|
||||
tris.push([id(j, i), id(j + 1, i + 1), id(j + 1, i)]);
|
||||
}
|
||||
}
|
||||
let face = vec![0; tris.len()];
|
||||
let s = BoundarySurface::new(&p, &tris, &face, &SurfaceOptions::default()).unwrap();
|
||||
let t = Tessellation::new(&s, vec![Some(AnalyticSurface::Sphere { center: [0.0; 3], radius: r })]);
|
||||
(s, t)
|
||||
}
|
||||
|
||||
/// A closed cylinder of radius `r` from z = 0 to `h`: faces 0 (bottom), 1 (top), 2 (side).
|
||||
fn cylinder(r: f64, h: f64, n: usize) -> (BoundarySurface, Tessellation) {
|
||||
let mut p = vec![[0.0, 0.0, 0.0], [0.0, 0.0, h]];
|
||||
for z in [0.0, h] {
|
||||
for i in 0..n {
|
||||
let a = TAU * i as f64 / n as f64;
|
||||
p.push([r * a.cos(), r * a.sin(), z]);
|
||||
}
|
||||
}
|
||||
let (b, t) = (|i: usize| (2 + i % n) as u32, |i: usize| (2 + n + i % n) as u32);
|
||||
let mut tris = Vec::new();
|
||||
let mut face = Vec::new();
|
||||
for i in 0..n {
|
||||
tris.push([0, b(i + 1), b(i)]);
|
||||
face.push(0);
|
||||
tris.push([1, t(i), t(i + 1)]);
|
||||
face.push(1);
|
||||
tris.push([b(i), b(i + 1), t(i + 1)]);
|
||||
tris.push([b(i), t(i + 1), t(i)]);
|
||||
face.extend([2, 2]);
|
||||
}
|
||||
let s = BoundarySurface::new(&p, &tris, &face, &SurfaceOptions::default()).unwrap();
|
||||
let analytic = vec![
|
||||
Some(AnalyticSurface::Plane { normal: [0.0, 0.0, -1.0], point: [0.0; 3] }),
|
||||
Some(AnalyticSurface::Plane { normal: [0.0, 0.0, 1.0], point: [0.0, 0.0, h] }),
|
||||
Some(AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: r }),
|
||||
];
|
||||
let t = Tessellation::new(&s, analytic);
|
||||
(s, t)
|
||||
}
|
||||
|
||||
/// The frustum of `fieldcraft-mesh`'s benchmark: 2 wide at the bottom, 1 at the top, 1 high.
|
||||
fn tapered() -> (Topology, BoundarySurface, Tessellation) {
|
||||
let (half, top, h): (f64, f64, f64) = (1.0, 0.5, 1.0);
|
||||
let p: Vec<[f64; 3]> = vec![
|
||||
[-half, -half, 0.0],
|
||||
[half, -half, 0.0],
|
||||
[half, half, 0.0],
|
||||
[-half, half, 0.0],
|
||||
[-top, -top, h],
|
||||
[top, -top, h],
|
||||
[top, top, h],
|
||||
[-top, top, h],
|
||||
];
|
||||
let quads: [[u32; 4]; 6] = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]];
|
||||
let (mut tris, mut face, mut analytic) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (f, q) in quads.iter().enumerate() {
|
||||
tris.push([q[0], q[1], q[2]]);
|
||||
tris.push([q[0], q[2], q[3]]);
|
||||
face.extend([f as u32; 2]);
|
||||
let [a, b, c] = [q[0], q[1], q[2]].map(|v| p[v as usize]);
|
||||
let (u, w) = ([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]);
|
||||
let n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
|
||||
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
analytic.push(Some(AnalyticSurface::Plane { normal: n.map(|x| x / l), point: a }));
|
||||
}
|
||||
let s = BoundarySurface::new(&p, &tris, &face, &SurfaceOptions::default()).unwrap();
|
||||
let t = Tessellation::new(&s, analytic);
|
||||
(Topology { vertices: p, blocks: vec![[0, 1, 2, 3, 4, 5, 6, 7]] }, s, t)
|
||||
}
|
||||
|
||||
/// The projection on the GPU, or `None` to skip (a failure when a GPU is required).
|
||||
fn on_gpu(topo: &Topology, s: &BoundarySurface, t: &Tessellation, opts: &ProjectOptions) -> Option<ProjectedMesh> {
|
||||
fieldcraft_mesh_gpu::register();
|
||||
match project_mesh(topo, s, t, &ProjectOptions { backend: Backend::Device, ..opts.clone() }) {
|
||||
Ok(m) => Some(m),
|
||||
Err(e) if gpu_required() => panic!("a GPU is required: {e}"),
|
||||
// Only a missing device skips: a shader that does not compile is a failure anywhere.
|
||||
Err(e) if e.to_string().contains("no GPU device") => {
|
||||
println!("skipping: {e}");
|
||||
None
|
||||
}
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest distance between the two meshes' nodes.
|
||||
fn largest_gap(a: &ProjectedMesh, b: &ProjectedMesh) -> f64 {
|
||||
a.nodes.iter().zip(&b.nodes).map(|(p, q)| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Both on the curved sphere and disc and on the tapered block: the same element count, the
|
||||
/// GPU's mesh the same on a second run, and its quality next to the CPU's.
|
||||
#[test]
|
||||
fn gpu_sweeps_agree_with_the_coloured_cpu_sweeps() {
|
||||
let r = 10.0;
|
||||
let (ss, st) = sphere(r, 96);
|
||||
let ball = Topology::sphere([0.0; 3], r, 0.55).unwrap();
|
||||
let (cs, ct) = cylinder(r, 2.0, 128);
|
||||
let disc = Topology::butterfly([0.0; 3], [0.0, 0.0, 1.0], r, &[0.0, 2.0], 0.55).unwrap();
|
||||
let (block, bs, bt) = tapered();
|
||||
let block_opts = ProjectOptions { size: 1.0 / 16.0, divisions: (0..3).map(|d| (0, d, 16)).collect(), ..ProjectOptions::default() };
|
||||
let cases = [
|
||||
("sphere", &ball, &ss, &st, ProjectOptions { size: 1.5, ..ProjectOptions::default() }),
|
||||
("disc", &disc, &cs, &ct, ProjectOptions { size: 1.0, ..ProjectOptions::default() }),
|
||||
("tapered block", &block, &bs, &bt, block_opts),
|
||||
];
|
||||
for (name, topo, s, t, opts) in cases {
|
||||
let cpu = project_mesh(topo, s, t, &ProjectOptions { backend: Backend::Coloured, ..opts.clone() }).unwrap();
|
||||
let Some(gpu) = on_gpu(topo, s, t, &opts) else { return };
|
||||
let again = on_gpu(topo, s, t, &opts).unwrap();
|
||||
assert_eq!(gpu.nodes, again.nodes, "{name}: the GPU's mesh changed between runs");
|
||||
assert_eq!(cpu.hexes, gpu.hexes, "{name}");
|
||||
let (a, b) = (&cpu.report, &gpu.report);
|
||||
let gap = largest_gap(&cpu, &gpu) / opts.size;
|
||||
println!(
|
||||
"{name}: min SJ cpu {:.5} gpu {:.5}, mean SJ cpu {:.6} gpu {:.6}, largest node gap {gap:.2e} sizes, sweeps cpu {} gpu {}, refused cpu {} gpu {}",
|
||||
a.min_scaled_jacobian,
|
||||
b.min_scaled_jacobian,
|
||||
a.mean_scaled_jacobian,
|
||||
b.mean_scaled_jacobian,
|
||||
a.smoothing.iterations,
|
||||
b.smoothing.iterations,
|
||||
a.smoothing.refused,
|
||||
b.smoothing.refused
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Throughput of three sweeps on the 1M-node tapered block (99 divisions a side; only its
|
||||
/// boundary nodes move) and ten on a 7-block sphere of about a million nodes (its interior moves
|
||||
/// too), on the CPU (sequential and coloured, every core) and on the GPU:
|
||||
/// `FIELDCRAFT_BENCH_DIVISIONS`, `FIELDCRAFT_BENCH_SPHERE_SIZE`, `FIELDCRAFT_BENCH_REPEATS`.
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn throughput() {
|
||||
let env = |name: &str, default: f64| std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default);
|
||||
let divisions = env("FIELDCRAFT_BENCH_DIVISIONS", 99.0) as usize;
|
||||
let repeats = env("FIELDCRAFT_BENCH_REPEATS", 3.0).max(1.0) as usize;
|
||||
let sphere_size = env("FIELDCRAFT_BENCH_SPHERE_SIZE", 0.16);
|
||||
let (block, bs, bt) = tapered();
|
||||
let (ss, st) = sphere(10.0, 192);
|
||||
let ball = Topology::sphere([0.0; 3], 10.0, 0.55).unwrap();
|
||||
let block_opts = ProjectOptions {
|
||||
size: 1.0 / divisions as f64,
|
||||
divisions: (0..3).map(|d| (0, d, divisions)).collect(),
|
||||
iterations: 3,
|
||||
..ProjectOptions::default()
|
||||
};
|
||||
let sphere_opts = ProjectOptions { size: sphere_size, iterations: 10, ..ProjectOptions::default() };
|
||||
fieldcraft_mesh_gpu::register();
|
||||
println!("mean of {repeats} runs; smoothing = pipeline - the same pipeline without smoothing");
|
||||
println!("| case | backend | nodes | sweeps | pipeline (s) | smoothing (s) | min SJ | mean SJ | refused |");
|
||||
for (name, topo, s, t, opts) in [("tapered block", &block, &bs, &bt, block_opts), ("sphere", &ball, &ss, &st, sphere_opts)] {
|
||||
let timed = |o: &ProjectOptions| -> Option<(f64, ProjectedMesh)> {
|
||||
let t0 = Instant::now();
|
||||
let mut last = None;
|
||||
for _ in 0..repeats {
|
||||
match project_mesh(topo, s, t, o) {
|
||||
Ok(m) => last = Some(m),
|
||||
Err(e) if !gpu_required() && e.to_string().contains("no GPU device") => {
|
||||
println!("skipping the GPU: {e}");
|
||||
return None;
|
||||
}
|
||||
Err(e) => panic!("{name}: {e}"),
|
||||
}
|
||||
}
|
||||
last.map(|m| (t0.elapsed().as_secs_f64() / repeats as f64, m))
|
||||
};
|
||||
let Some((bare, _)) = timed(&ProjectOptions { smoothing: Smoothing::None, ..opts.clone() }) else { return };
|
||||
for backend in [Backend::Cpu, Backend::Coloured, Backend::Device] {
|
||||
let Some((all, m)) = timed(&ProjectOptions { backend, ..opts.clone() }) else { return };
|
||||
let r = &m.report;
|
||||
println!(
|
||||
"| {name} | {backend:?} | {} | {} | {all:.2} | {:.2} | {:.4} | {:.4} | {} |",
|
||||
m.nodes.len(),
|
||||
r.smoothing.iterations,
|
||||
all - bare,
|
||||
r.min_scaled_jacobian,
|
||||
r.mean_scaled_jacobian,
|
||||
r.smoothing.refused
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! The tapered-block benchmark of the projection pipeline (#409) and the determinism of its
|
||||
//! parallel parts.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::smooth::Order;
|
||||
use super::*;
|
||||
use crate::surface::{BoundarySurface, SurfaceOptions};
|
||||
use crate::tet::geometry::{AnalyticSurface, Tessellation};
|
||||
use crate::vec3::{cross, norm, sub};
|
||||
|
||||
/// A frustum of a square pyramid, `2·half` wide at z = 0 and `2·top` wide at z = `h`, with its
|
||||
/// six planes, as one block.
|
||||
fn tapered(half: f64, top: f64, h: f64) -> (Topology, BoundarySurface, Tessellation) {
|
||||
let p = vec![
|
||||
[-half, -half, 0.0],
|
||||
[half, -half, 0.0],
|
||||
[half, half, 0.0],
|
||||
[-half, half, 0.0],
|
||||
[-top, -top, h],
|
||||
[top, -top, h],
|
||||
[top, top, h],
|
||||
[-top, top, h],
|
||||
];
|
||||
let quads: [[u32; 4]; 6] = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]];
|
||||
let mut tris = Vec::new();
|
||||
let mut face = Vec::new();
|
||||
let mut analytic = Vec::new();
|
||||
for (f, q) in quads.iter().enumerate() {
|
||||
tris.push([q[0], q[1], q[2]]);
|
||||
tris.push([q[0], q[2], q[3]]);
|
||||
face.extend([f as u32; 2]);
|
||||
let n = cross(sub(p[q[1] as usize], p[q[0] as usize]), sub(p[q[2] as usize], p[q[0] as usize]));
|
||||
let normal = scale(n, 1.0 / norm(n));
|
||||
analytic.push(Some(AnalyticSurface::Plane { normal, point: p[q[0] as usize] }));
|
||||
}
|
||||
let s = BoundarySurface::new(&p, &tris, &face, &SurfaceOptions::default()).unwrap();
|
||||
let t = Tessellation::new(&s, analytic);
|
||||
(Topology { vertices: p, blocks: vec![[0, 1, 2, 3, 4, 5, 6, 7]] }, s, t)
|
||||
}
|
||||
|
||||
fn options(divisions: usize, smoothing: Smoothing, iterations: usize) -> ProjectOptions {
|
||||
// The element size sets the smoother's stopping tolerance (1e-4 of it).
|
||||
ProjectOptions {
|
||||
size: 1.0 / divisions as f64,
|
||||
divisions: (0..3).map(|d| (0, d, divisions)).collect(),
|
||||
smoothing,
|
||||
iterations,
|
||||
..ProjectOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The parallel parts give the same mesh on one thread and on many: the sequential sweeps bit for
|
||||
/// bit as before (only the measurements run in parallel), the coloured sweeps whatever the thread
|
||||
/// count.
|
||||
#[test]
|
||||
fn parallel_smoothing_does_not_depend_on_the_thread_count() {
|
||||
let (topo, s, t) = tapered(1.0, 0.5, 1.0);
|
||||
let opts = options(8, Smoothing::Elliptic, 20);
|
||||
for order in [Order::Sequential, Order::Coloured] {
|
||||
let one = fieldcraft_parallel::with_threads(1, || project_ordered(&topo, &s, &t, &opts, order)).unwrap();
|
||||
let four = fieldcraft_parallel::with_threads(4, || project_ordered(&topo, &s, &t, &opts, order)).unwrap();
|
||||
assert_eq!(one.nodes, four.nodes, "{order:?}");
|
||||
assert_eq!(one.report, four.report, "{order:?}");
|
||||
assert!(one.report.min_scaled_jacobian > 0.5, "{order:?}: {}", one.report.min_scaled_jacobian);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds of the fastest of `repeats` runs of `f`, and its last result.
|
||||
fn timed<R>(repeats: usize, mut f: impl FnMut() -> R) -> (f64, f64, R) {
|
||||
let mut times = Vec::new();
|
||||
let mut last = None;
|
||||
for _ in 0..repeats.max(1) {
|
||||
let t0 = Instant::now();
|
||||
last = Some(f());
|
||||
times.push(t0.elapsed().as_secs_f64());
|
||||
}
|
||||
let mean = times.iter().sum::<f64>() / times.len() as f64;
|
||||
let min = times.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
(mean, min, last.unwrap())
|
||||
}
|
||||
|
||||
fn env(name: &str, default: usize) -> usize {
|
||||
std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// The 1M-node tapered block (99 divisions a side: 1 000 000 nodes, 970 299 hexahedra) through
|
||||
/// the projection pipeline with three Winslow sweeps, sequential and on every core:
|
||||
/// `FIELDCRAFT_BENCH_DIVISIONS`, `FIELDCRAFT_BENCH_REPEATS`, `FIELDCRAFT_BENCH_SWEEPS` and
|
||||
/// `FIELDCRAFT_THREADS` change it.
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn tapered_block_benchmark() {
|
||||
let divisions = env("FIELDCRAFT_BENCH_DIVISIONS", 99);
|
||||
let repeats = env("FIELDCRAFT_BENCH_REPEATS", 3);
|
||||
let sweeps = env("FIELDCRAFT_BENCH_SWEEPS", 3);
|
||||
let threads = fieldcraft_parallel::default_threads();
|
||||
let (topo, s, t) = tapered(1.0, 0.5, 1.0);
|
||||
let full = options(divisions, Smoothing::Elliptic, sweeps);
|
||||
let none = options(divisions, Smoothing::None, sweeps);
|
||||
println!("tapered block: {} nodes, {} hexahedra, {sweeps} sweeps, {repeats} runs, {threads} threads", (divisions + 1).pow(3), divisions.pow(3));
|
||||
println!("| threads | order | pipeline (s) | without smoothing (s) | smoothing (s) | certified quality (s) | min SJ | mean SJ | refused |");
|
||||
for (n, order) in [(1, Order::Sequential), (1, Order::Coloured), (threads, Order::Sequential), (threads, Order::Coloured)] {
|
||||
fieldcraft_parallel::with_threads(n, || {
|
||||
let (all, all_min, m) = timed(repeats, || project_ordered(&topo, &s, &t, &full, order).unwrap());
|
||||
let (bare, bare_min, _) = timed(repeats, || project_ordered(&topo, &s, &t, &none, order).unwrap());
|
||||
let (certified, _, _) = timed(repeats, || {
|
||||
fieldcraft_parallel::map_grained(m.hexes.len(), smooth::HEX_GRAIN, |e| {
|
||||
let c = m.hexes[e].map(|v| m.nodes[v as usize]);
|
||||
element_quality(ElementKind::Hex8, &c).map(|q| q.size).unwrap_or(0.0)
|
||||
})
|
||||
});
|
||||
let r = &m.report;
|
||||
println!(
|
||||
"| {n} | {order:?} | {all:.2} (min {all_min:.2}) | {bare:.2} (min {bare_min:.2}) | {:.2} | {certified:.2} | {:.4} | {:.4} | {} |",
|
||||
all - bare,
|
||||
r.min_scaled_jacobian,
|
||||
r.mean_scaled_jacobian,
|
||||
r.smoothing.refused
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Smoothing sweeps on a device (a GPU crate above this layer, #409).
|
||||
//!
|
||||
//! The coloured sweep ([`super::smooth`]) moves the nodes of one colour class at a time against a
|
||||
//! grid frozen for that class, since no two nodes of a class share an element. A device takes the
|
||||
//! free (interior) nodes of every class: Winslow's update from the node's structured stencil, or
|
||||
//! the mean of its edge neighbours at an irregular node, then the guarded step (the full move,
|
||||
//! else half, else a quarter, whichever keeps the quality around the node). The host moves the
|
||||
//! class's boundary nodes, which need the geometry, against the same frozen grid, and tells the
|
||||
//! device where they went. So a device sweep is the CPU's coloured sweep with the free nodes
|
||||
//! computed elsewhere; [`reference_move`] is that computation in double precision, the CPU's own.
|
||||
//!
|
||||
//! An application registers the device ([`register_sweep_device`]); the CPU stays the
|
||||
//! reference, and a device is deterministic on itself and agrees with it within the tolerance its
|
||||
//! tests state.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::smooth::{Stencil, guarded, quality_around, winslow};
|
||||
use crate::Result;
|
||||
use crate::vec3::{P, add, scale};
|
||||
|
||||
/// How a device moves one free node.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Rule {
|
||||
/// Winslow's update from the node's structured stencil: its edge neighbours
|
||||
/// `nb[d] = [minus, plus]` along the three grid directions, the face diagonals
|
||||
/// `diag[pair][sa][sb]` for the direction pairs (0, 1), (0, 2), (1, 2), and the
|
||||
/// Thomas–Middlecoff control functions (zero without control).
|
||||
Winslow { nb: [[u32; 2]; 3], diag: [[[u32; 2]; 2]; 3], control: [f64; 3] },
|
||||
/// The mean of these edge neighbours (a node where three, five or more blocks meet).
|
||||
Mean(Vec<u32>),
|
||||
}
|
||||
|
||||
/// A free node a device moves.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DeviceNode {
|
||||
pub node: u32,
|
||||
pub rule: Rule,
|
||||
}
|
||||
|
||||
/// What a device sweeps.
|
||||
pub struct SweepMesh<'a> {
|
||||
/// The node positions when the sweeps start.
|
||||
pub nodes: &'a [P],
|
||||
pub hexes: &'a [[u32; 8]],
|
||||
/// The elements around every node.
|
||||
pub around: &'a [Vec<u32>],
|
||||
/// Per colour class, in sweep order, the free nodes the device moves (in node order).
|
||||
pub classes: Vec<Vec<DeviceNode>>,
|
||||
/// A move is kept when the smallest scaled Jacobian of the elements around the node stays at
|
||||
/// or above the lower of its value before and `floor`, and their largest edge ratio at or
|
||||
/// below the higher of its value before and [`super::smooth::EDGE_RATIO_CEILING`].
|
||||
pub floor: f64,
|
||||
/// A move shorter than this is not made.
|
||||
pub min_move: f64,
|
||||
}
|
||||
|
||||
/// What a sweep does with a node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Move {
|
||||
Stay,
|
||||
To(P),
|
||||
/// Every step would have lowered the quality around the node.
|
||||
Refused,
|
||||
}
|
||||
|
||||
/// A device that runs the free nodes' part of the coloured sweeps.
|
||||
pub trait SweepDevice {
|
||||
/// For reports: `gpu: <adapter>`.
|
||||
fn name(&self) -> String;
|
||||
/// Move class `class`'s nodes on the grid as the device holds it, in place: one move per
|
||||
/// node of [`SweepMesh::classes`]`[class]`, in its order.
|
||||
fn sweep(&mut self, class: usize) -> Result<Vec<Move>>;
|
||||
/// Nodes the host moved since the last sweep, and where to.
|
||||
fn set(&mut self, moved: &[(u32, P)]) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Makes a device for one smoothing run.
|
||||
pub type SweepFactory = dyn Fn(&SweepMesh) -> Result<Box<dyn SweepDevice>> + Send + Sync;
|
||||
|
||||
static FACTORY: OnceLock<Box<SweepFactory>> = OnceLock::new();
|
||||
|
||||
/// Register the process's sweep device (an application does this once at start; a second
|
||||
/// registration is ignored).
|
||||
pub fn register_sweep_device(factory: Box<SweepFactory>) {
|
||||
let _ = FACTORY.set(factory);
|
||||
}
|
||||
|
||||
/// The registered sweep device's factory, if an application registered one.
|
||||
pub fn sweep_device_factory() -> Option<&'static SweepFactory> {
|
||||
FACTORY.get().map(|b| b.as_ref())
|
||||
}
|
||||
|
||||
/// The move of free node `n` on the grid `x`, in double precision: what a device computes, and
|
||||
/// what the CPU sweep does with the same node.
|
||||
pub fn reference_move(mesh: &SweepMesh, x: &[P], n: &DeviceNode) -> Move {
|
||||
let target = match &n.rule {
|
||||
Rule::Winslow { nb, diag, control } => winslow(x, &Stencil { nb: *nb, diag: *diag, control: *control }),
|
||||
Rule::Mean(edges) => {
|
||||
let (mut s, mut k) = ([0.0; 3], 0usize);
|
||||
for &w in edges {
|
||||
s = add(s, x.get(w as usize).copied().unwrap_or([0.0; 3]));
|
||||
k += 1;
|
||||
}
|
||||
(k > 0).then(|| scale(s, 1.0 / k as f64))
|
||||
}
|
||||
};
|
||||
let quality = |p: P| quality_around(x, mesh.hexes, mesh.around.get(n.node as usize).map_or(&[][..], Vec::as_slice), n.node, p);
|
||||
guarded(x, n.node, target, mesh.min_move, mesh.floor, quality, |p| p)
|
||||
}
|
||||
@@ -17,12 +17,15 @@
|
||||
//! index space, carved, projected face → surface, edge → curve, vertex → point, then smoothed);
|
||||
//! the algorithms are the papers cited in each module.
|
||||
|
||||
pub mod device;
|
||||
pub mod fixed;
|
||||
mod lattice;
|
||||
pub mod smooth;
|
||||
pub mod target;
|
||||
pub mod topology;
|
||||
|
||||
#[cfg(test)]
|
||||
mod bench;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -103,6 +106,24 @@ pub struct ProjectOptions {
|
||||
/// chosen: an edge's length is then measured in units of the field ([`crate::size::ideal_divisions`]).
|
||||
/// Its global size is `size`.
|
||||
pub field: Option<crate::size::RegionSize>,
|
||||
/// Where the smoothing sweeps run.
|
||||
pub backend: Backend,
|
||||
}
|
||||
|
||||
/// Where a projection's smoothing sweeps run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Backend {
|
||||
/// On the CPU, node by node: the reference, the same mesh on every machine.
|
||||
#[default]
|
||||
Cpu,
|
||||
/// On the CPU, colour by colour (no two nodes of a colour share an element), each colour in
|
||||
/// parallel: the same mesh on every machine and thread count, a slightly different one from
|
||||
/// [`Backend::Cpu`]'s; the reference of [`Backend::Device`].
|
||||
Coloured,
|
||||
/// Colour by colour with the free nodes on the registered sweep device ([`device`]): single
|
||||
/// precision there, deterministic per device, the same verdicts as [`Backend::Coloured`] and a
|
||||
/// smallest scaled Jacobian within 0.02 of it; an error when none is registered.
|
||||
Device,
|
||||
}
|
||||
|
||||
impl Default for ProjectOptions {
|
||||
@@ -119,6 +140,7 @@ impl Default for ProjectOptions {
|
||||
fixed: Vec::new(),
|
||||
planes: Vec::new(),
|
||||
field: None,
|
||||
backend: Backend::Cpu,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,6 +354,22 @@ fn edge_key(a: u32, b: u32) -> [u32; 2] {
|
||||
|
||||
/// Mesh the topology `t` projected onto the body bounded by `surface`, through `g`.
|
||||
pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, opts: &ProjectOptions) -> Result<ProjectedMesh> {
|
||||
let order = match opts.backend {
|
||||
Backend::Cpu => smooth::Order::Sequential,
|
||||
Backend::Coloured => smooth::Order::Coloured,
|
||||
Backend::Device => smooth::Order::Device,
|
||||
};
|
||||
project_ordered(t, surface, g, opts, order)
|
||||
}
|
||||
|
||||
/// [`project_mesh`] with the smoothing sweeps in `order`.
|
||||
pub(crate) fn project_ordered(
|
||||
t: &Topology,
|
||||
surface: &BoundarySurface,
|
||||
g: &dyn Geometry,
|
||||
opts: &ProjectOptions,
|
||||
order: smooth::Order,
|
||||
) -> Result<ProjectedMesh> {
|
||||
t.check()?;
|
||||
if !(opts.size > 0.0 && opts.size.is_finite()) {
|
||||
return Err(Error::Invalid(format!("the element size must be a positive number, got {}", opts.size)));
|
||||
@@ -779,7 +817,7 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
return Err(Error::Failed("a node of the block lattice was not placed".into()));
|
||||
}
|
||||
let corners_of = |nodes: &[P], h: &[u32; 8]| h.map(|v| nodes.get(v as usize).copied().unwrap_or_default());
|
||||
let initial_min = lat.hexes.iter().map(|h| smooth::hex_scaled_jacobian(&corners_of(&nodes, h))).fold(f64::INFINITY, f64::min);
|
||||
let initial_min = smooth::scaled_jacobians(&nodes, &lat.hexes).into_iter().fold(f64::INFINITY, f64::min);
|
||||
|
||||
// The outer quads (outward, hex8 face order) and the target each lies on.
|
||||
let hex8_faces = ElementKind::Hex8.faces();
|
||||
@@ -810,17 +848,21 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
smooth::Mesh { nodes: &mut nodes, hexes: &lat.hexes, constraint: &constraint, held: &held, boundary: &boundary_quads },
|
||||
g,
|
||||
&targets,
|
||||
opts.smoothing,
|
||||
opts.iterations,
|
||||
1e-4 * opts.size,
|
||||
);
|
||||
smooth::Run { method: opts.smoothing, iterations: opts.iterations, tol: 1e-4 * opts.size, order },
|
||||
)?;
|
||||
|
||||
fieldcraft_model::progress::check()?;
|
||||
// Certified quality, the gap to the geometry and the tags.
|
||||
let (mut min_sj, mut sum_sj, mut volume) = (f64::INFINITY, 0.0, 0.0);
|
||||
let mut invalid: Vec<usize> = Vec::new();
|
||||
for (e, h) in lat.hexes.iter().enumerate() {
|
||||
let q = element_quality(ElementKind::Hex8, &corners_of(&nodes, h))?;
|
||||
let qualities = fieldcraft_parallel::map_grained(lat.hexes.len(), smooth::HEX_GRAIN, |e| {
|
||||
lat.hexes.get(e).map_or_else(
|
||||
|| Err(Error::Failed("an element of the lattice is missing".into())),
|
||||
|h| element_quality(ElementKind::Hex8, &corners_of(&nodes, h)),
|
||||
)
|
||||
});
|
||||
for (e, q) in qualities.into_iter().enumerate() {
|
||||
let q = q?;
|
||||
let sj = q.get(Metric::ScaledJacobian).unwrap_or(-1.0);
|
||||
if q.validity().is_none_or(|v| v != crate::quality::Validity::Valid) {
|
||||
invalid.push(e);
|
||||
|
||||
+361
-132
@@ -25,9 +25,11 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::device::{DeviceNode, Move, Rule, SweepMesh, sweep_device_factory};
|
||||
use super::target::{Constraint, Geometry, place};
|
||||
use super::topology::{CORNERS, corner_at};
|
||||
use crate::vec3::{P, add, cross, dist, dot, norm, scale, sub};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Smoothing never takes an element's scaled Jacobian below this (unless it already was).
|
||||
pub const QUALITY_FLOOR: f64 = 0.5;
|
||||
@@ -151,7 +153,7 @@ pub(crate) fn hex_edge_ratio(x: &[P; 8]) -> f64 {
|
||||
|
||||
/// The quality around a node: the smallest scaled Jacobian and the largest edge ratio.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Local {
|
||||
pub(crate) struct Local {
|
||||
sj: f64,
|
||||
ratio: f64,
|
||||
}
|
||||
@@ -178,10 +180,10 @@ impl Local {
|
||||
/// The structured stencil of a regular interior node: `nb[d] = [minus, plus]` and the face
|
||||
/// diagonals `diag[pair][sa][sb]` for the direction pairs (0, 1), (0, 2), (1, 2).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Stencil {
|
||||
nb: [[u32; 2]; 3],
|
||||
diag: [[[u32; 2]; 2]; 3],
|
||||
control: [f64; 3],
|
||||
pub(crate) struct Stencil {
|
||||
pub nb: [[u32; 2]; 3],
|
||||
pub diag: [[[u32; 2]; 2]; 3],
|
||||
pub control: [f64; 3],
|
||||
}
|
||||
|
||||
const PAIRS: [(usize, usize); 3] = [(0, 1), (0, 2), (1, 2)];
|
||||
@@ -249,7 +251,7 @@ fn at(x: &[P], v: u32) -> P {
|
||||
}
|
||||
|
||||
/// The Winslow update of a regular node.
|
||||
fn winslow(x: &[P], s: &Stencil) -> Option<P> {
|
||||
pub(crate) fn winslow(x: &[P], s: &Stencil) -> Option<P> {
|
||||
let xi: [P; 3] = [0, 1, 2].map(|d| scale(sub(at(x, s.nb[d][1]), at(x, s.nb[d][0])), 0.5));
|
||||
let g = |i: usize, j: usize| dot(xi[i], xi[j]);
|
||||
let (g11, g22, g33, g12, g13, g23) = (g(0, 0), g(1, 1), g(2, 2), g(0, 1), g(0, 2), g(1, 2));
|
||||
@@ -274,6 +276,182 @@ fn winslow(x: &[P], s: &Stencil) -> Option<P> {
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
/// The order the sweeps move the nodes in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Order {
|
||||
/// One node after another in node order, each seeing its neighbours' new places.
|
||||
Sequential,
|
||||
/// Colour by colour ([`colour_classes`]): no two nodes of a colour share an element, so a
|
||||
/// colour's moves are independent and run in parallel, and the result does not depend on the
|
||||
/// thread count.
|
||||
Coloured,
|
||||
/// Colour by colour with the free nodes on the registered sweep device ([`super::device`]).
|
||||
Device,
|
||||
}
|
||||
|
||||
/// Nodes per parallel task in a coloured sweep.
|
||||
const NODE_GRAIN: usize = 1024;
|
||||
|
||||
/// Surface nodes per parallel task when the grid lines are straightened (each projects onto the
|
||||
/// geometry several times, and a colour holds few of them).
|
||||
const SURFACE_GRAIN: usize = 64;
|
||||
|
||||
/// Elements per parallel task when a whole mesh is measured.
|
||||
pub(crate) const HEX_GRAIN: usize = 4096;
|
||||
|
||||
/// What a sweep move of one node reads.
|
||||
struct Sweep<'a> {
|
||||
hexes: &'a [[u32; 8]],
|
||||
around: &'a [Vec<u32>],
|
||||
constraint: &'a [Constraint],
|
||||
held: &'a [bool],
|
||||
stencils: &'a [Option<Stencil>],
|
||||
edges: &'a [Vec<u32>],
|
||||
along_boundary: &'a BTreeMap<u32, BTreeSet<u32>>,
|
||||
g: &'a dyn Geometry,
|
||||
targets: &'a [Vec<u32>],
|
||||
ptol: f64,
|
||||
floor: f64,
|
||||
}
|
||||
|
||||
impl Sweep<'_> {
|
||||
fn cons(&self, v: u32) -> Constraint {
|
||||
self.constraint.get(v as usize).copied().unwrap_or(Constraint::Free)
|
||||
}
|
||||
|
||||
/// Whether a sweep may move `v`.
|
||||
fn movable(&self, v: u32) -> bool {
|
||||
!self.held.get(v as usize).copied().unwrap_or(false) && !matches!(self.cons(v), Constraint::Corner(..))
|
||||
}
|
||||
|
||||
/// Whether `v` is a surface node the straightening moves.
|
||||
fn on_surface(&self, v: u32) -> bool {
|
||||
self.movable(v) && matches!(self.cons(v), Constraint::Surface(_))
|
||||
}
|
||||
|
||||
/// The straightening move of surface node `v` of `x`: toward the mean of its neighbours on
|
||||
/// the surface, as far as no element around it falls below the lower of its own value and
|
||||
/// `floor`.
|
||||
fn straighten(&self, x: &[P], v: u32, floor: f64) -> Move {
|
||||
let c = self.cons(v);
|
||||
let (g, targets, ptol) = (self.g, self.targets, self.ptol);
|
||||
let target = self.along_boundary.get(&v).and_then(|e| {
|
||||
let (mut s, mut k) = ([0.0; 3], 0usize);
|
||||
for &w in e {
|
||||
s = add(s, at(x, w));
|
||||
k += 1;
|
||||
}
|
||||
(k > 0).then(|| place(g, targets, c, scale(s, 1.0 / k as f64), ptol))
|
||||
});
|
||||
guarded(x, v, target, ptol, floor, |to| self.quality(x, v, to), |to| place(g, targets, c, to, ptol))
|
||||
}
|
||||
|
||||
/// The quality around `v` with `v` at `p`.
|
||||
fn quality(&self, x: &[P], v: u32, p: P) -> Local {
|
||||
quality_around(x, self.hexes, self.around.get(v as usize).map_or(&[][..], Vec::as_slice), v, p)
|
||||
}
|
||||
|
||||
/// The move of `v` from the grid `x`: reads only the elements around `v`.
|
||||
fn step(&self, x: &[P], v: u32) -> Move {
|
||||
if !self.movable(v) {
|
||||
return Move::Stay;
|
||||
}
|
||||
let (g, targets, ptol) = (self.g, self.targets, self.ptol);
|
||||
let c = self.cons(v);
|
||||
let mean = |nbrs: &mut dyn Iterator<Item = u32>| -> Option<P> {
|
||||
let (mut s, mut k) = ([0.0; 3], 0usize);
|
||||
for w in nbrs {
|
||||
s = add(s, at(x, w));
|
||||
k += 1;
|
||||
}
|
||||
(k > 0).then(|| scale(s, 1.0 / k as f64))
|
||||
};
|
||||
let target = match c {
|
||||
Constraint::Corner(..) => return Move::Stay,
|
||||
Constraint::Free => match self.stencils.get(v as usize).copied().flatten() {
|
||||
Some(s) => winslow(x, &s),
|
||||
None => self.edges.get(v as usize).and_then(|e| mean(&mut e.iter().copied())),
|
||||
},
|
||||
Constraint::Surface(_) => self.along_boundary.get(&v).and_then(|e| mean(&mut e.iter().copied())).map(|q| place(g, targets, c, q, ptol)),
|
||||
Constraint::Curve(a, b) => {
|
||||
let on_curve = |w: &u32| match self.cons(*w) {
|
||||
Constraint::Curve(x, y) => (x, y) == (a, b),
|
||||
Constraint::Corner(x, y, z) => [x, y, z].contains(&a) && [x, y, z].contains(&b),
|
||||
_ => false,
|
||||
};
|
||||
self.along_boundary.get(&v).and_then(|e| mean(&mut e.iter().copied().filter(on_curve))).map(|q| place(g, targets, c, q, ptol))
|
||||
}
|
||||
};
|
||||
guarded(x, v, target, ptol, self.floor, |to| self.quality(x, v, to), |to| place(g, targets, c, to, ptol))
|
||||
}
|
||||
}
|
||||
|
||||
/// The quality of the elements `around` node `v` with `v` at `p`.
|
||||
pub(crate) fn quality_around(x: &[P], hexes: &[[u32; 8]], around: &[u32], v: u32, p: P) -> Local {
|
||||
let mut l = Local { sj: f64::INFINITY, ratio: 1.0 };
|
||||
for hex in around.iter().filter_map(|&h| hexes.get(h as usize)) {
|
||||
let c = hex.map(|q| if q == v { p } else { at(x, q) });
|
||||
l.sj = l.sj.min(hex_scaled_jacobian(&c));
|
||||
l.ratio = l.ratio.max(hex_edge_ratio(&c));
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// The guarded move of node `v` of `x` toward `target`: none when it is shorter than
|
||||
/// `min_move`; else the full move, else half, else a quarter of it (each `place`d back on the
|
||||
/// node's geometry), whichever keeps `quality` around the node ([`Local::keeps_above`] `floor`).
|
||||
pub(crate) fn guarded(x: &[P], v: u32, target: Option<P>, min_move: f64, floor: f64, quality: impl Fn(P) -> Local, place: impl Fn(P) -> P) -> Move {
|
||||
let p = at(x, v);
|
||||
let Some(q) = target else { return Move::Stay };
|
||||
if dist(q, p) <= min_move {
|
||||
return Move::Stay;
|
||||
}
|
||||
let before = quality(p);
|
||||
for step in [1.0, 0.5, 0.25] {
|
||||
let to = if step == 1.0 { q } else { place(add(p, scale(sub(q, p), step))) };
|
||||
if quality(to).keeps_above(before, floor) {
|
||||
return Move::To(to);
|
||||
}
|
||||
}
|
||||
Move::Refused
|
||||
}
|
||||
|
||||
/// The movable nodes in classes no two nodes of which share an element (a greedy colouring in
|
||||
/// node order; eight classes on a structured grid), each class in node order.
|
||||
fn colour_classes(s: &Sweep, n: usize) -> Vec<Vec<u32>> {
|
||||
let mut colour = vec![usize::MAX; n];
|
||||
let mut classes: Vec<Vec<u32>> = Vec::new();
|
||||
let mut used: Vec<bool> = Vec::new();
|
||||
for v in 0..n as u32 {
|
||||
if !s.movable(v) {
|
||||
continue;
|
||||
}
|
||||
used.clear();
|
||||
used.resize(classes.len(), false);
|
||||
for hex in s.around.get(v as usize).into_iter().flatten().filter_map(|&h| s.hexes.get(h as usize)) {
|
||||
for &w in hex {
|
||||
if let Some(slot) = colour.get(w as usize).and_then(|&c| used.get_mut(c)) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = used.iter().position(|u| !u).unwrap_or(classes.len());
|
||||
if c == classes.len() {
|
||||
classes.push(Vec::new());
|
||||
}
|
||||
if let (Some(class), Some(slot)) = (classes.get_mut(c), colour.get_mut(v as usize)) {
|
||||
class.push(v);
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
classes
|
||||
}
|
||||
|
||||
/// Every element's smallest scaled Jacobian, in element order (measured in parallel).
|
||||
pub(crate) fn scaled_jacobians(x: &[P], hexes: &[[u32; 8]]) -> Vec<f64> {
|
||||
fieldcraft_parallel::map_grained(hexes.len(), HEX_GRAIN, |e| hexes.get(e).map_or(f64::INFINITY, |h| hex_scaled_jacobian(&h.map(|q| at(x, q)))))
|
||||
}
|
||||
|
||||
/// What the smoother reports.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SmoothReport {
|
||||
@@ -293,30 +471,40 @@ pub(crate) struct Mesh<'a> {
|
||||
pub boundary: &'a [[u32; 4]],
|
||||
}
|
||||
|
||||
/// The edge neighbours of every node.
|
||||
fn edge_neighbours(n: usize, hexes: &[[u32; 8]]) -> Vec<BTreeSet<u32>> {
|
||||
let mut edges: Vec<BTreeSet<u32>> = vec![BTreeSet::new(); n];
|
||||
for hex in hexes {
|
||||
for (k, bits) in CORNERS.iter().enumerate() {
|
||||
for d in 0..3 {
|
||||
let mut b = *bits;
|
||||
b[d] = 1 - b[d];
|
||||
if let Some(e) = edges.get_mut(hex[k] as usize) {
|
||||
e.insert(hex[corner_at(b)]);
|
||||
/// The edge neighbours of every node, ascending, from the elements around it (in parallel).
|
||||
fn edge_neighbours(around: &[Vec<u32>], hexes: &[[u32; 8]]) -> Vec<Vec<u32>> {
|
||||
fieldcraft_parallel::map_grained(around.len(), NODE_GRAIN, |v| {
|
||||
let mut e = Vec::with_capacity(24);
|
||||
for hex in around.get(v).into_iter().flatten().filter_map(|&h| hexes.get(h as usize)) {
|
||||
for (k, bits) in CORNERS.iter().enumerate() {
|
||||
if hex[k] as usize != v {
|
||||
continue;
|
||||
}
|
||||
for d in 0..3 {
|
||||
let mut b = *bits;
|
||||
b[d] = 1 - b[d];
|
||||
e.push(hex[corner_at(b)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edges
|
||||
e.sort_unstable();
|
||||
e.dedup();
|
||||
e
|
||||
})
|
||||
}
|
||||
|
||||
/// The smallest scaled Jacobian and the largest edge ratio of a mesh.
|
||||
fn mesh_quality(x: &[P], hexes: &[[u32; 8]]) -> Local {
|
||||
let each = fieldcraft_parallel::map_grained(hexes.len(), HEX_GRAIN, |e| {
|
||||
hexes.get(e).map_or((f64::INFINITY, 1.0), |hex| {
|
||||
let c = hex.map(|q| at(x, q));
|
||||
(hex_scaled_jacobian(&c), hex_edge_ratio(&c))
|
||||
})
|
||||
});
|
||||
let mut l = Local { sj: f64::INFINITY, ratio: 1.0 };
|
||||
for hex in hexes {
|
||||
let c = hex.map(|q| at(x, q));
|
||||
l.sj = l.sj.min(hex_scaled_jacobian(&c));
|
||||
l.ratio = l.ratio.max(hex_edge_ratio(&c));
|
||||
for (sj, ratio) in each {
|
||||
l.sj = l.sj.min(sj);
|
||||
l.ratio = l.ratio.max(ratio);
|
||||
}
|
||||
l
|
||||
}
|
||||
@@ -347,15 +535,24 @@ const START_TIE: f64 = 0.01;
|
||||
|
||||
fn sweep_score(x: &[P], hexes: &[[u32; 8]]) -> SweepScore {
|
||||
let (mut min, mut sum) = (f64::INFINITY, 0.0);
|
||||
for hex in hexes {
|
||||
let sj = hex_scaled_jacobian(&hex.map(|q| at(x, q)));
|
||||
for sj in scaled_jacobians(x, hexes) {
|
||||
min = min.min(sj);
|
||||
sum += sj;
|
||||
}
|
||||
SweepScore { min, mean: if hexes.is_empty() { 0.0 } else { sum / hexes.len() as f64 } }
|
||||
}
|
||||
|
||||
/// Smooth in place: at most `iterations` sweeps, stopping when no node moves more than `tol`.
|
||||
/// How to smooth: the method, at most `iterations` sweeps, stopping when no node moves more than
|
||||
/// `tol`, the nodes moved in `order`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Run {
|
||||
pub method: Smoothing,
|
||||
pub iterations: usize,
|
||||
pub tol: f64,
|
||||
pub order: Order,
|
||||
}
|
||||
|
||||
/// Smooth in place ([`Run`]): at most `iterations` sweeps, stopping when no node moves more than `tol`.
|
||||
///
|
||||
/// The sweeps let a move take an element down to [`SWEEP_FLOOR`] (a sheared block straightens
|
||||
/// only when its nodes move together, and a node-by-node rule that never lowers a poor element
|
||||
@@ -365,26 +562,27 @@ fn sweep_score(x: &[P], hexes: &[[u32; 8]]) -> SweepScore {
|
||||
/// below [`QUALITY_FLOOR`]; and when neither holds it, the grid stays as it was. So smoothing never
|
||||
/// leaves the mesh worse than it found it (a regular grid of graded layers, which Laplace's
|
||||
/// equation would even out, is kept).
|
||||
pub(crate) fn smooth(m: Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing, iterations: usize, tol: f64) -> SmoothReport {
|
||||
pub(crate) fn smooth(m: Mesh, g: &dyn Geometry, targets: &[Vec<u32>], run: Run) -> Result<SmoothReport> {
|
||||
let start = m.nodes.clone();
|
||||
let before = mesh_quality(&start, m.hexes);
|
||||
let mut m = m;
|
||||
let mut report = pass(&mut m, g, targets, method, iterations, tol, SWEEP_FLOOR);
|
||||
let mut report = pass(&mut m, g, targets, run, SWEEP_FLOOR)?;
|
||||
if mesh_quality(m.nodes, m.hexes).holds(before) {
|
||||
return report;
|
||||
return Ok(report);
|
||||
}
|
||||
m.nodes.clone_from(&start);
|
||||
let strict = pass(&mut m, g, targets, method, iterations, tol, QUALITY_FLOOR);
|
||||
let strict = pass(&mut m, g, targets, run, QUALITY_FLOOR)?;
|
||||
report = SmoothReport { iterations: report.iterations + strict.iterations, refused: report.refused + strict.refused };
|
||||
if !mesh_quality(m.nodes, m.hexes).holds(before) {
|
||||
*m.nodes = start;
|
||||
}
|
||||
report
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// One smoothing run; sweep moves keep every element at or above the lower of its scaled
|
||||
/// Jacobian before and `sweep_floor`.
|
||||
fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing, iterations: usize, tol: f64, sweep_floor: f64) -> SmoothReport {
|
||||
fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], run: Run, sweep_floor: f64) -> Result<SmoothReport> {
|
||||
let Run { method, iterations, tol, order } = run;
|
||||
let n = m.nodes.len();
|
||||
let mut around: Vec<Vec<u32>> = vec![Vec::new(); n];
|
||||
for (h, hex) in m.hexes.iter().enumerate() {
|
||||
@@ -395,7 +593,7 @@ fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing,
|
||||
}
|
||||
}
|
||||
// Edge neighbours (all) and boundary neighbours (along boundary quads).
|
||||
let edges = edge_neighbours(n, m.hexes);
|
||||
let edges = edge_neighbours(&around, m.hexes);
|
||||
let mut along_boundary: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
|
||||
for q in m.boundary {
|
||||
for i in 0..4 {
|
||||
@@ -406,25 +604,29 @@ fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing,
|
||||
}
|
||||
let cons = |v: u32| m.constraint.get(v as usize).copied().unwrap_or(Constraint::Free);
|
||||
let is_held = |v: u32| m.held.get(v as usize).copied().unwrap_or(false);
|
||||
let mut stencils: Vec<Option<Stencil>> = vec![None; n];
|
||||
if matches!(method, Smoothing::Elliptic | Smoothing::EllipticControlled) {
|
||||
for v in 0..n as u32 {
|
||||
if cons(v) == Constraint::Free {
|
||||
let mut s = around.get(v as usize).and_then(|a| stencil(v, a, m.hexes));
|
||||
if let (Some(st), Smoothing::EllipticControlled) = (s.as_mut(), method) {
|
||||
let p = at(m.nodes, v);
|
||||
for d in 0..3 {
|
||||
let (mi, pl) = (at(m.nodes, st.nb[d][0]), at(m.nodes, st.nb[d][1]));
|
||||
let xi = scale(sub(pl, mi), 0.5);
|
||||
let xii = sub(add(pl, mi), scale(p, 2.0));
|
||||
let l2 = dot(xi, xi);
|
||||
st.control[d] = if l2 > 0.0 { (-dot(xi, xii) / l2).clamp(-MAX_CONTROL, MAX_CONTROL) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
stencils[v as usize] = s;
|
||||
let stencils: Vec<Option<Stencil>> = if matches!(method, Smoothing::Elliptic | Smoothing::EllipticControlled) {
|
||||
let nodes: &[P] = m.nodes;
|
||||
fieldcraft_parallel::map_grained(n, NODE_GRAIN, |i| {
|
||||
let v = i as u32;
|
||||
if cons(v) != Constraint::Free {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = around.get(i).and_then(|a| stencil(v, a, m.hexes));
|
||||
if let (Some(st), Smoothing::EllipticControlled) = (s.as_mut(), method) {
|
||||
let p = at(nodes, v);
|
||||
for d in 0..3 {
|
||||
let (mi, pl) = (at(nodes, st.nb[d][0]), at(nodes, st.nb[d][1]));
|
||||
let xi = scale(sub(pl, mi), 0.5);
|
||||
let xii = sub(add(pl, mi), scale(p, 2.0));
|
||||
let l2 = dot(xi, xi);
|
||||
st.control[d] = if l2 > 0.0 { (-dot(xi, xii) / l2).clamp(-MAX_CONTROL, MAX_CONTROL) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
s
|
||||
})
|
||||
} else {
|
||||
vec![None; n]
|
||||
};
|
||||
let hexes = m.hexes;
|
||||
let quality = |x: &[P], v: u32| -> Local {
|
||||
let mut l = Local { sj: f64::INFINITY, ratio: 1.0 };
|
||||
@@ -437,68 +639,100 @@ fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing,
|
||||
};
|
||||
let mut report = SmoothReport { iterations: 0, refused: 0 };
|
||||
if method == Smoothing::None {
|
||||
return report;
|
||||
return Ok(report);
|
||||
}
|
||||
let ptol = 1e-3 * tol;
|
||||
// The sweeps do not raise the smallest scaled Jacobian monotonically (a move may take an
|
||||
// element down to the sweep floor), so where they stop decides the result; keep the best grid
|
||||
// seen (smallest scaled Jacobian, then the mean) and lift the worst elements from that.
|
||||
let mut best = (sweep_score(m.nodes, hexes), m.nodes.clone());
|
||||
let sweep = Sweep {
|
||||
hexes,
|
||||
around: &around,
|
||||
constraint: m.constraint,
|
||||
held: m.held,
|
||||
stencils: &stencils,
|
||||
edges: &edges,
|
||||
along_boundary: &along_boundary,
|
||||
g,
|
||||
targets,
|
||||
ptol,
|
||||
floor: sweep_floor,
|
||||
};
|
||||
let classes = if order == Order::Sequential { Vec::new() } else { colour_classes(&sweep, n) };
|
||||
// On a device: each class split into the free nodes the device moves and the rest, moved here.
|
||||
let mut device = None;
|
||||
let mut split: Vec<(Vec<u32>, Vec<u32>)> = Vec::new();
|
||||
if order == Order::Device {
|
||||
let factory = sweep_device_factory().ok_or_else(|| {
|
||||
Error::Unsupported("this build has no GPU smoothing (no sweep device is registered): smooth with the `cpu` or `coloured` backend".into())
|
||||
})?;
|
||||
let mut device_classes = Vec::with_capacity(classes.len());
|
||||
for class in &classes {
|
||||
let (free, rest): (Vec<u32>, Vec<u32>) = class.iter().partition(|&&v| sweep.cons(v) == Constraint::Free);
|
||||
let rule = |v: u32| match stencils.get(v as usize).copied().flatten() {
|
||||
Some(s) => Rule::Winslow { nb: s.nb, diag: s.diag, control: s.control },
|
||||
None => Rule::Mean(edges.get(v as usize).cloned().unwrap_or_default()),
|
||||
};
|
||||
device_classes.push(free.iter().map(|&v| DeviceNode { node: v, rule: rule(v) }).collect());
|
||||
split.push((free, rest));
|
||||
}
|
||||
let mesh = SweepMesh { nodes: m.nodes, hexes, around: &around, classes: device_classes, floor: sweep_floor, min_move: ptol };
|
||||
device = Some(factory(&mesh)?);
|
||||
}
|
||||
for it in 0..iterations {
|
||||
report.iterations = it + 1;
|
||||
let mut moved = 0.0f64;
|
||||
for v in 0..n as u32 {
|
||||
if is_held(v) {
|
||||
continue;
|
||||
}
|
||||
let c = cons(v);
|
||||
let p = at(m.nodes, v);
|
||||
let mean = |nbrs: &mut dyn Iterator<Item = u32>| -> Option<P> {
|
||||
let (mut s, mut k) = ([0.0; 3], 0usize);
|
||||
for w in nbrs {
|
||||
s = add(s, at(m.nodes, w));
|
||||
k += 1;
|
||||
}
|
||||
(k > 0).then(|| scale(s, 1.0 / k as f64))
|
||||
};
|
||||
let target = match c {
|
||||
Constraint::Corner(..) => continue,
|
||||
Constraint::Free => match stencils.get(v as usize).copied().flatten() {
|
||||
Some(s) => winslow(m.nodes, &s),
|
||||
None => edges.get(v as usize).and_then(|e| mean(&mut e.iter().copied())),
|
||||
},
|
||||
Constraint::Surface(_) => along_boundary.get(&v).and_then(|e| mean(&mut e.iter().copied())).map(|q| place(g, targets, c, q, ptol)),
|
||||
Constraint::Curve(a, b) => {
|
||||
let on_curve = |w: &u32| match cons(*w) {
|
||||
Constraint::Curve(x, y) => (x, y) == (a, b),
|
||||
Constraint::Corner(x, y, z) => [x, y, z].contains(&a) && [x, y, z].contains(&b),
|
||||
_ => false,
|
||||
};
|
||||
along_boundary.get(&v).and_then(|e| mean(&mut e.iter().copied().filter(on_curve))).map(|q| place(g, targets, c, q, ptol))
|
||||
}
|
||||
};
|
||||
let Some(q) = target else { continue };
|
||||
if dist(q, p) <= 1e-3 * tol {
|
||||
continue;
|
||||
}
|
||||
// The full move, else half, else a quarter of it, whichever keeps the quality.
|
||||
let before = quality(m.nodes, v);
|
||||
let mut accepted = false;
|
||||
for step in [1.0, 0.5, 0.25] {
|
||||
let x = if step == 1.0 { q } else { place(g, targets, c, add(p, scale(sub(q, p), step)), ptol) };
|
||||
if let Some(slot) = m.nodes.get_mut(v as usize) {
|
||||
let mut apply = |nodes: &mut Vec<P>, v: u32, mv: Move| match mv {
|
||||
Move::Stay => {}
|
||||
Move::To(x) => {
|
||||
if let Some(slot) = nodes.get_mut(v as usize) {
|
||||
moved = moved.max(dist(x, *slot));
|
||||
*slot = x;
|
||||
}
|
||||
if quality(m.nodes, v).keeps_above(before, sweep_floor) {
|
||||
moved = moved.max(dist(x, p));
|
||||
accepted = true;
|
||||
break;
|
||||
}
|
||||
Move::Refused => report.refused += 1,
|
||||
};
|
||||
match order {
|
||||
Order::Sequential => {
|
||||
for v in 0..n as u32 {
|
||||
let mv = sweep.step(m.nodes, v);
|
||||
apply(m.nodes, v, mv);
|
||||
}
|
||||
}
|
||||
if !accepted {
|
||||
report.refused += 1;
|
||||
if let Some(slot) = m.nodes.get_mut(v as usize) {
|
||||
*slot = p;
|
||||
Order::Coloured => {
|
||||
for class in &classes {
|
||||
let nodes: &[P] = m.nodes;
|
||||
let moves =
|
||||
fieldcraft_parallel::map_grained(class.len(), NODE_GRAIN, |i| class.get(i).map_or(Move::Stay, |&v| sweep.step(nodes, v)));
|
||||
for (&v, mv) in class.iter().zip(moves) {
|
||||
apply(m.nodes, v, mv);
|
||||
}
|
||||
}
|
||||
}
|
||||
Order::Device => {
|
||||
let Some(dev) = device.as_mut() else { return Err(Error::Failed("the sweep device is missing".into())) };
|
||||
for (k, (free, rest)) in split.iter().enumerate() {
|
||||
// The device moves the free nodes of the class while the host moves the rest,
|
||||
// both from the grid as it was before the class.
|
||||
let moves = dev.sweep(k)?;
|
||||
if moves.len() != free.len() {
|
||||
return Err(Error::Failed(format!("{} moved {} of {} nodes", dev.name(), moves.len(), free.len())));
|
||||
}
|
||||
let nodes: &[P] = m.nodes;
|
||||
let here =
|
||||
fieldcraft_parallel::map_grained(rest.len(), NODE_GRAIN, |i| rest.get(i).map_or(Move::Stay, |&v| sweep.step(nodes, v)));
|
||||
for (&v, mv) in free.iter().zip(moves) {
|
||||
apply(m.nodes, v, mv);
|
||||
}
|
||||
let mut placed = Vec::new();
|
||||
for (&v, mv) in rest.iter().zip(here) {
|
||||
if let Move::To(x) = mv {
|
||||
placed.push((v, x));
|
||||
}
|
||||
apply(m.nodes, v, mv);
|
||||
}
|
||||
dev.set(&placed)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,6 +753,7 @@ fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing,
|
||||
starts.push(best.1);
|
||||
}
|
||||
let mut finished: Option<(SweepScore, Local, Vec<P>)> = None;
|
||||
let surface_classes: Vec<Vec<u32>> = classes.iter().map(|c| c.iter().copied().filter(|&v| sweep.on_surface(v)).collect()).collect();
|
||||
for start in starts {
|
||||
*m.nodes = start;
|
||||
// The worst elements: each of their nodes searched for the place that raises the smallest
|
||||
@@ -527,8 +762,13 @@ fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing,
|
||||
// IJNME 40:3979–4002, 1997, on Knupp's hexahedral metric), boundary nodes kept on their
|
||||
// targets.
|
||||
for _ in 0..OPTIMIZE_ROUNDS {
|
||||
let mut poor: Vec<u32> =
|
||||
m.hexes.iter().filter(|h| hex_scaled_jacobian(&h.map(|q| at(m.nodes, q))) < LIFT_BELOW).flat_map(|h| h.iter().copied()).collect();
|
||||
let mut poor: Vec<u32> = m
|
||||
.hexes
|
||||
.iter()
|
||||
.zip(scaled_jacobians(m.nodes, m.hexes))
|
||||
.filter(|(_, sj)| *sj < LIFT_BELOW)
|
||||
.flat_map(|(h, _)| h.iter().copied())
|
||||
.collect();
|
||||
poor.sort_unstable();
|
||||
poor.dedup();
|
||||
if poor.is_empty() {
|
||||
@@ -578,40 +818,29 @@ fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing,
|
||||
// toward the mean of its neighbours on the surface, as far as no element round it falls below
|
||||
// the lower of its own value and the mesh's smallest after the search (so the mesh's worst
|
||||
// element is no worse for it).
|
||||
let floor = hexes.iter().map(|h| hex_scaled_jacobian(&h.map(|q| at(m.nodes, q)))).fold(f64::INFINITY, f64::min);
|
||||
let floor = scaled_jacobians(m.nodes, hexes).into_iter().fold(f64::INFINITY, f64::min);
|
||||
for _ in 0..STRAIGHTEN_SWEEPS {
|
||||
let mut moved = false;
|
||||
for v in 0..n as u32 {
|
||||
let c = cons(v);
|
||||
if !matches!(c, Constraint::Surface(_)) || is_held(v) {
|
||||
continue;
|
||||
let mut apply = |nodes: &mut Vec<P>, v: u32, mv: Move| {
|
||||
if let (Move::To(x), Some(slot)) = (mv, nodes.get_mut(v as usize)) {
|
||||
*slot = x;
|
||||
moved = true;
|
||||
}
|
||||
let p = at(m.nodes, v);
|
||||
let Some(q) = along_boundary.get(&v).and_then(|e| {
|
||||
let (mut s, mut k) = ([0.0; 3], 0usize);
|
||||
for &w in e {
|
||||
s = add(s, at(m.nodes, w));
|
||||
k += 1;
|
||||
}
|
||||
(k > 0).then(|| place(g, targets, c, scale(s, 1.0 / k as f64), ptol))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if dist(q, p) <= ptol {
|
||||
continue;
|
||||
};
|
||||
if order == Order::Sequential {
|
||||
for v in (0..n as u32).filter(|&v| sweep.on_surface(v)) {
|
||||
let mv = sweep.straighten(m.nodes, v, floor);
|
||||
apply(m.nodes, v, mv);
|
||||
}
|
||||
let before = quality(m.nodes, v);
|
||||
for step in [1.0, 0.5, 0.25] {
|
||||
let x = if step == 1.0 { q } else { place(g, targets, c, add(p, scale(sub(q, p), step)), ptol) };
|
||||
if let Some(slot) = m.nodes.get_mut(v as usize) {
|
||||
*slot = x;
|
||||
}
|
||||
if quality(m.nodes, v).keeps_above(before, floor) {
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = m.nodes.get_mut(v as usize) {
|
||||
*slot = p;
|
||||
} else {
|
||||
// Colour by colour, as the sweeps went.
|
||||
for surface in &surface_classes {
|
||||
let nodes: &[P] = m.nodes;
|
||||
let moves = fieldcraft_parallel::map_grained(surface.len(), SURFACE_GRAIN, |i| {
|
||||
surface.get(i).map_or(Move::Stay, |&v| sweep.straighten(nodes, v, floor))
|
||||
});
|
||||
for (&v, mv) in surface.iter().zip(moves) {
|
||||
apply(m.nodes, v, mv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -628,5 +857,5 @@ fn pass(m: &mut Mesh, g: &dyn Geometry, targets: &[Vec<u32>], method: Smoothing,
|
||||
if let Some((_, _, nodes)) = finished {
|
||||
*m.nodes = nodes;
|
||||
}
|
||||
report
|
||||
Ok(report)
|
||||
}
|
||||
@@ -17,8 +17,8 @@ const ROUNDS: usize = 60;
|
||||
/// Below this `|n_a × n_b|` two targets are treated as tangent (sin 3°).
|
||||
const TANGENT_SIN: f64 = 0.052;
|
||||
|
||||
/// What the projection needs of the geometry.
|
||||
pub trait Geometry {
|
||||
/// What the projection needs of the geometry (shared by the threads that smooth in parallel).
|
||||
pub trait Geometry: Sync {
|
||||
/// The closest point on B-rep face `face`.
|
||||
fn project(&self, face: u32, p: P) -> Option<P>;
|
||||
/// The unit normal of B-rep face `face` near `p`.
|
||||
|
||||
@@ -144,6 +144,51 @@ fn butterfly_disc_meshes_with_its_rim_on_the_circle() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Both sweep orders on curved blocks (where, unlike a block with planar faces, they give
|
||||
/// different meshes) at `cases` (`(sphere, size)`, else the butterfly disc): each order gives the
|
||||
/// same mesh on one thread and on four, and the coloured order (#409) stays above the quality
|
||||
/// floor and within 0.02 of the sequential order's smallest scaled Jacobian. Returns both.
|
||||
fn compare_sweep_orders(cases: &[(bool, f64)]) -> Vec<[f64; 2]> {
|
||||
use super::smooth::Order;
|
||||
let r = 10.0;
|
||||
let (ss, st) = sphere(r, 96);
|
||||
let ball = Topology::sphere([0.0; 3], r, 0.55).unwrap();
|
||||
let (cs, ct) = cylinder(r, 2.0, 128);
|
||||
let disc = Topology::butterfly([0.0; 3], [0.0, 0.0, 1.0], r, &[0.0, 2.0], 0.55).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for &(is_sphere, size) in cases {
|
||||
let (topo, s, t) = if is_sphere { (&ball, &ss, &st) } else { (&disc, &cs, &ct) };
|
||||
let opts = options(size);
|
||||
let run = |threads: usize, order: Order| fieldcraft_parallel::with_threads(threads, || project_ordered(topo, s, t, &opts, order)).unwrap();
|
||||
let mut min = [0.0; 2];
|
||||
for (k, order) in [Order::Sequential, Order::Coloured].into_iter().enumerate() {
|
||||
let (one, four) = (run(1, order), run(4, order));
|
||||
assert_eq!(one.nodes, four.nodes, "sphere {is_sphere} at {size}, {order:?}");
|
||||
assert_eq!(one.report, four.report, "sphere {is_sphere} at {size}, {order:?}");
|
||||
min[k] = one.report.min_scaled_jacobian;
|
||||
}
|
||||
let [sequential, coloured] = min;
|
||||
assert!(coloured >= 0.5 && coloured >= sequential - 0.02, "sphere {is_sphere} at {size}: coloured {coloured}, sequential {sequential}");
|
||||
out.push(min);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_orders_on_curved_blocks_do_not_depend_on_the_thread_count() {
|
||||
compare_sweep_orders(&[(true, 3.0), (false, 2.0)]);
|
||||
}
|
||||
|
||||
/// [`compare_sweep_orders`] on finer meshes, printing both orders' smallest scaled Jacobians.
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn sweep_orders_on_fine_curved_blocks() {
|
||||
let cases = [(true, 1.5), (true, 0.75), (false, 1.0), (false, 0.5)];
|
||||
for ((is_sphere, size), [sequential, coloured]) in cases.iter().zip(compare_sweep_orders(&cases)) {
|
||||
println!("{} at {size}: sequential {sequential:.4}, coloured {coloured:.4}", if *is_sphere { "sphere" } else { "disc" });
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain grid on a box: corners land on the box's corners, edges on its edges.
|
||||
#[test]
|
||||
fn a_grid_on_a_cylinder_is_refused_where_corners_would_flatten() {
|
||||
@@ -338,3 +383,72 @@ fn a_fixed_patch_the_blocks_do_not_match_is_an_error() {
|
||||
let broken = FixedPatch { quads: vec![[0, 0, 1, 2]], ..patch_of(&m, 0) };
|
||||
assert!(project_mesh(&topo, &s, &t, &ProjectOptions { fixed: vec![broken], ..options(1.0) }).is_err());
|
||||
}
|
||||
|
||||
/// A sweep device in double precision ([`super::device::reference_move`]): the host's split
|
||||
/// into device and host nodes, the frozen grid per class and the updates both ways, without a
|
||||
/// GPU.
|
||||
struct ReferenceDevice {
|
||||
nodes: Vec<P>,
|
||||
hexes: Vec<[u32; 8]>,
|
||||
around: Vec<Vec<u32>>,
|
||||
classes: Vec<Vec<super::device::DeviceNode>>,
|
||||
floor: f64,
|
||||
min_move: f64,
|
||||
}
|
||||
|
||||
impl super::device::SweepDevice for ReferenceDevice {
|
||||
fn name(&self) -> String {
|
||||
"reference".into()
|
||||
}
|
||||
fn sweep(&mut self, class: usize) -> crate::Result<Vec<super::device::Move>> {
|
||||
let mesh = super::device::SweepMesh {
|
||||
nodes: &self.nodes,
|
||||
hexes: &self.hexes,
|
||||
around: &self.around,
|
||||
classes: Vec::new(),
|
||||
floor: self.floor,
|
||||
min_move: self.min_move,
|
||||
};
|
||||
let moves: Vec<_> = self.classes[class].iter().map(|n| super::device::reference_move(&mesh, &self.nodes, n)).collect();
|
||||
for (n, mv) in self.classes[class].iter().zip(&moves) {
|
||||
if let super::device::Move::To(x) = mv {
|
||||
self.nodes[n.node as usize] = *x;
|
||||
}
|
||||
}
|
||||
Ok(moves)
|
||||
}
|
||||
fn set(&mut self, moved: &[(u32, P)]) -> crate::Result<()> {
|
||||
for &(v, x) in moved {
|
||||
self.nodes[v as usize] = x;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The device path with the reference device gives the CPU's coloured sweeps bit for bit.
|
||||
#[test]
|
||||
fn a_reference_sweep_device_gives_the_coloured_sweeps() {
|
||||
use super::smooth::Order;
|
||||
super::device::register_sweep_device(Box::new(|m| {
|
||||
Ok(Box::new(ReferenceDevice {
|
||||
nodes: m.nodes.to_vec(),
|
||||
hexes: m.hexes.to_vec(),
|
||||
around: m.around.to_vec(),
|
||||
classes: m.classes.clone(),
|
||||
floor: m.floor,
|
||||
min_move: m.min_move,
|
||||
}))
|
||||
}));
|
||||
let r = 10.0;
|
||||
let (ss, st) = sphere(r, 96);
|
||||
let ball = Topology::sphere([0.0; 3], r, 0.55).unwrap();
|
||||
let (cs, ct) = cylinder(r, 2.0, 128);
|
||||
let disc = Topology::butterfly([0.0; 3], [0.0, 0.0, 1.0], r, &[0.0, 2.0], 0.55).unwrap();
|
||||
for (topo, s, t, size) in [(&ball, &ss, &st, 3.0), (&disc, &cs, &ct, 2.0)] {
|
||||
let opts = options(size);
|
||||
let coloured = project_ordered(topo, s, t, &opts, Order::Coloured).unwrap();
|
||||
let device = project_mesh(topo, s, t, &ProjectOptions { backend: Backend::Device, ..opts.clone() }).unwrap();
|
||||
assert_eq!(coloured.nodes, device.nodes, "size {size}");
|
||||
assert_eq!(coloured.report, device.report, "size {size}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! The trilinear hexahedron's certificate and metrics without allocation.
|
||||
//!
|
||||
//! The same computation as the generic path ([`super::bernstein`], [`super::jacobian`]) for the
|
||||
//! one domain it is the bottleneck on (a projected block mesh is a million hex8 elements): three
|
||||
//! segment factors of degree at most 2, so every polynomial fits in 27 coefficients on the
|
||||
//! stack. Every operation is the generic one in the same order (the same products, sums and
|
||||
//! skipped zero terms), so the results are the same bit for bit; a test compares the two on
|
||||
//! valid, curved, inverted and degenerate elements. Only elements the first bounds settle are
|
||||
//! decided here (every element of a smooth mesh): an element that needs subdivision returns
|
||||
//! `None` and goes through the generic certificate.
|
||||
|
||||
use super::jacobian::{HEX_CORNERS, JacobianCheck, RELATIVE_TOLERANCE, Validity};
|
||||
use super::shape;
|
||||
|
||||
/// Coefficients of a polynomial of degree at most 2 in each of three variables.
|
||||
const LEN: usize = 27;
|
||||
|
||||
fn factorial(n: usize) -> f64 {
|
||||
(2..=n).map(|k| k as f64).product()
|
||||
}
|
||||
|
||||
/// A polynomial in Bernstein form on the unit cube, degree `deg[f]` along factor `f`, stored as
|
||||
/// [`super::bernstein::Bernstein`] stores it (`((m0 · (d1 + 1)) + m1) · (d2 + 1) + m2`).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Poly {
|
||||
deg: [usize; 3],
|
||||
coef: [f64; LEN],
|
||||
}
|
||||
|
||||
impl Poly {
|
||||
fn zero(deg: [usize; 3]) -> Self {
|
||||
Poly { deg, coef: [0.0; LEN] }
|
||||
}
|
||||
|
||||
fn slot(&self, m: [usize; 3]) -> usize {
|
||||
(m[0] * (self.deg[1] + 1) + m[1]) * (self.deg[2] + 1) + m[2]
|
||||
}
|
||||
|
||||
fn get(&self, m: [usize; 3]) -> f64 {
|
||||
self.coef.get(self.slot(m)).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Every multi-index, in storage order.
|
||||
fn multis(&self) -> impl Iterator<Item = [usize; 3]> + use<> {
|
||||
let d = self.deg;
|
||||
(0..=d[0]).flat_map(move |a| (0..=d[1]).flat_map(move |b| (0..=d[2]).map(move |c| [a, b, c])))
|
||||
}
|
||||
|
||||
/// The multinomial weight of `m`, computed as the generic path computes it.
|
||||
fn weight(&self, m: [usize; 3]) -> f64 {
|
||||
let mut w = 1.0;
|
||||
for (&d, k) in self.deg.iter().zip(m) {
|
||||
w *= factorial(d);
|
||||
w /= factorial(d - k);
|
||||
w /= factorial(k);
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
fn mul(&self, o: &Poly) -> Poly {
|
||||
let mut out = Poly::zero([0, 1, 2].map(|f| self.deg[f] + o.deg[f]));
|
||||
let mut out_w = [0.0; LEN];
|
||||
for m in out.multis() {
|
||||
let s = out.slot(m);
|
||||
if let Some(x) = out_w.get_mut(s) {
|
||||
*x = out.weight(m);
|
||||
}
|
||||
}
|
||||
let terms = |p: &Poly| {
|
||||
let mut t = [([0usize; 3], 0.0f64); LEN];
|
||||
let mut n = 0;
|
||||
for m in p.multis() {
|
||||
let c = p.get(m) * p.weight(m);
|
||||
if c != 0.0
|
||||
&& let Some(slot) = t.get_mut(n)
|
||||
{
|
||||
*slot = (m, c);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
(t, n)
|
||||
};
|
||||
let (left, nl) = terms(self);
|
||||
let (right, nr) = terms(o);
|
||||
for (a, ca) in left.iter().take(nl) {
|
||||
for (b, cb) in right.iter().take(nr) {
|
||||
let s = out.slot([a[0] + b[0], a[1] + b[1], a[2] + b[2]]);
|
||||
if let (Some(c), Some(w)) = (out.coef.get_mut(s), out_w.get(s)) {
|
||||
*c += ca * cb / w;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn axpy(&self, s: f64, o: &Poly) -> Poly {
|
||||
let mut out = *self;
|
||||
for (c, b) in out.coef.iter_mut().zip(&o.coef) {
|
||||
*c += s * b;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The derivative along factor `f`.
|
||||
fn derivative(&self, f: usize) -> Poly {
|
||||
let mut deg = self.deg;
|
||||
deg[f] -= 1;
|
||||
let mut out = Poly::zero(deg);
|
||||
for b in out.multis() {
|
||||
let mut hi = b;
|
||||
hi[f] += 1;
|
||||
let v = self.deg[f] as f64 * (self.get(hi) - self.get(b));
|
||||
let s = out.slot(b);
|
||||
if let Some(c) = out.coef.get_mut(s) {
|
||||
*c += v;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn bounds(&self) -> (f64, f64) {
|
||||
self.multis().map(|m| self.get(m)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), c| (lo.min(c), hi.max(c)))
|
||||
}
|
||||
|
||||
/// The value at a corner of the cube (0 or 1 per factor).
|
||||
fn at_vertex(&self, v: [usize; 3]) -> f64 {
|
||||
self.get([0, 1, 2].map(|f| if v[f] > 0 { self.deg[f] } else { 0 }))
|
||||
}
|
||||
|
||||
fn integral(&self) -> f64 {
|
||||
let per: f64 = self.deg.iter().map(|&d| factorial(d) / factorial(d + 1)).product();
|
||||
self.multis().map(|m| self.get(m)).sum::<f64>() * per
|
||||
}
|
||||
|
||||
/// The value at the centre of the cube.
|
||||
fn centre(&self) -> f64 {
|
||||
self.multis()
|
||||
.map(|m| {
|
||||
let mut t = self.get(m) * self.weight(m);
|
||||
for (&d, k) in self.deg.iter().zip(m) {
|
||||
for c in [d - k, k] {
|
||||
t *= 0.5f64.powi(c as i32);
|
||||
}
|
||||
}
|
||||
t
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// The certificate and Knupp's scaled Jacobian and shape of a hex8 with corners `p` (already
|
||||
/// translated so the first is at the origin, as the generic path does), when the first bounds of
|
||||
/// its determinant settle it; `None` when it needs subdivision or a value is not finite.
|
||||
pub(crate) fn hex8(p: &[[f64; 3]; 8]) -> Option<(JacobianCheck, f64, f64)> {
|
||||
let mut g = [Poly::zero([1, 1, 1]); 3];
|
||||
for (corner, x) in HEX_CORNERS.iter().zip(p) {
|
||||
for (poly, c) in g.iter_mut().zip(x) {
|
||||
let s = poly.slot(*corner);
|
||||
*poly.coef.get_mut(s)? = *c;
|
||||
}
|
||||
}
|
||||
let columns: [[Poly; 3]; 3] = [0, 1, 2].map(|f| g.map(|q| q.derivative(f)));
|
||||
let [a, b, c] = &columns;
|
||||
let cross = |i: usize, j: usize| b[i].mul(&c[j]).axpy(-1.0, &b[j].mul(&c[i]));
|
||||
let det = a[0].mul(&cross(1, 2)).axpy(1.0, &a[1].mul(&cross(2, 0))).axpy(1.0, &a[2].mul(&cross(0, 1)));
|
||||
|
||||
let norm_bound = |col: &[Poly; 3]| {
|
||||
let [x, y, z] = col;
|
||||
x.multis().map(|m| (x.get(m), y.get(m), z.get(m))).map(|(a, b, c)| (a * a + b * b + c * c).sqrt()).fold(0.0, f64::max)
|
||||
};
|
||||
let n: f64 = columns.iter().map(norm_bound).product();
|
||||
let floor = 2.0 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN * n;
|
||||
|
||||
let (lo, hi) = det.bounds();
|
||||
let volume = det.integral();
|
||||
let scale = lo.abs().max(hi.abs());
|
||||
if !scale.is_finite() || !volume.is_finite() || !floor.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let tol = (RELATIVE_TOLERANCE * scale).max(floor);
|
||||
let ratio_of = |v: f64| if scale > 0.0 { v / scale } else { 0.0 };
|
||||
let check = if HEX_CORNERS.iter().any(|v| !(det.at_vertex(*v) > tol)) {
|
||||
JacobianCheck { validity: Validity::Invalid, ratio: ratio_of(lo), volume }
|
||||
} else if lo > tol {
|
||||
JacobianCheck { validity: Validity::Valid, ratio: ratio_of(f64::INFINITY.min(lo)), volume }
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let at = |value: &dyn Fn(&Poly) -> f64| {
|
||||
let [a, b, c] = columns.each_ref().map(|col| col.each_ref().map(value));
|
||||
shape::jacobian_metrics(a, b, c)
|
||||
};
|
||||
let centre = at(&|q| q.centre());
|
||||
let (scaled, knupp) = HEX_CORNERS.iter().map(|corner| at(&|q| q.at_vertex(*corner))).fold(centre, |(s, k), (s2, k2)| (s.min(s2), k.min(k2)));
|
||||
Some((check, scaled, knupp))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fieldcraft_model::ElementKind;
|
||||
|
||||
use super::super::{element_validity, generic_quality, local};
|
||||
use super::*;
|
||||
|
||||
/// A pseudo-random number in [-1, 1) (deterministic).
|
||||
fn noise(seed: &mut u64) -> f64 {
|
||||
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((*seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
|
||||
}
|
||||
|
||||
/// Hexahedra of every kind the certificate meets: near-cubes, sheared, curved (twisted top),
|
||||
/// inverted, collapsed, far from the origin and tiny.
|
||||
fn samples() -> Vec<[[f64; 3]; 8]> {
|
||||
let mut seed = 409;
|
||||
let mut out = Vec::new();
|
||||
for k in 0..4000 {
|
||||
let jitter = [0.02, 0.2, 0.45, 0.9][k % 4];
|
||||
let (offset, size) = match k % 5 {
|
||||
0 => (0.0, 1.0),
|
||||
1 => (1e6, 1.0),
|
||||
2 => (-3.5, 1e-6),
|
||||
3 => (12.0, 250.0),
|
||||
_ => (0.25, 0.1),
|
||||
};
|
||||
let mut x = HEX_CORNERS.map(|c| c.map(|v| offset + size * (v as f64 + jitter * 0.5 * noise(&mut seed))));
|
||||
match k % 7 {
|
||||
// A twisted top face: positive corners, possibly negative inside.
|
||||
0 => {
|
||||
for q in x.iter_mut().skip(4) {
|
||||
let (s, t) = (q[0] - offset, q[1] - offset);
|
||||
let a = 1.2 * noise(&mut seed).abs();
|
||||
q[0] = offset + s * a.cos() - t * a.sin();
|
||||
q[1] = offset + s * a.sin() + t * a.cos();
|
||||
}
|
||||
}
|
||||
// Inverted.
|
||||
1 => x.swap(0, 4),
|
||||
// A collapsed edge.
|
||||
2 => x[1] = x[0],
|
||||
_ => {}
|
||||
}
|
||||
out.push(x);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The fast path agrees with the generic Bernstein path bit for bit wherever it decides,
|
||||
/// and decides valid and invalid elements alike.
|
||||
#[test]
|
||||
fn matches_the_generic_certificate_bit_for_bit() {
|
||||
let (mut valid, mut invalid, mut deferred) = (0, 0, 0);
|
||||
for x in samples() {
|
||||
let fast = super::super::element_quality(ElementKind::Hex8, &x);
|
||||
let p = local(ElementKind::Hex8, &x).unwrap();
|
||||
let generic = generic_quality(ElementKind::Hex8, &x, &p);
|
||||
assert_eq!(format!("{fast:?}"), format!("{generic:?}"), "{x:?}");
|
||||
let checks = (element_validity(ElementKind::Hex8, &x).ok(), generic.as_ref().ok().and_then(|q| q.jacobian));
|
||||
assert_eq!(format!("{:?}", checks.0), format!("{:?}", checks.1), "{x:?}");
|
||||
let c: [[f64; 3]; 8] = p.try_into().unwrap();
|
||||
match hex8(&c).map(|r| r.0.validity) {
|
||||
Some(Validity::Valid) => valid += 1,
|
||||
Some(_) => invalid += 1,
|
||||
None => deferred += 1,
|
||||
}
|
||||
}
|
||||
assert!(valid > 1000 && invalid > 500 && deferred > 0, "valid {valid}, invalid {invalid}, deferred {deferred}");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
mod bernstein;
|
||||
pub mod conformity;
|
||||
mod hex8;
|
||||
pub mod jacobian;
|
||||
mod report;
|
||||
pub(crate) mod shape;
|
||||
@@ -217,6 +218,11 @@ fn exact_tet_sign(xyz: &[[f64; 3]]) -> Option<Sign> {
|
||||
/// hex20) from its node coordinates in node order.
|
||||
pub fn element_validity(kind: ElementKind, xyz: &[[f64; 3]]) -> Result<JacobianCheck> {
|
||||
let p = local(kind, xyz)?;
|
||||
if kind == ElementKind::Hex8
|
||||
&& let Some((check, _, _)) = hex8::hex8(&corners(&p)?)
|
||||
{
|
||||
return Ok(check);
|
||||
}
|
||||
checked(kind, xyz, &p).map(|(_, check)| check)
|
||||
}
|
||||
|
||||
@@ -237,32 +243,49 @@ fn hex_metrics(columns: &Columns) -> (f64, f64) {
|
||||
/// Rods and beams are an error ([`is_measured`]).
|
||||
pub fn element_quality(kind: ElementKind, xyz: &[[f64; 3]]) -> Result<ElementQuality> {
|
||||
let p = local(kind, xyz)?;
|
||||
if kind == ElementKind::Hex8 {
|
||||
let c = corners::<8>(&p)?;
|
||||
if let Some((check, scaled, knupp)) = hex8::hex8(&c) {
|
||||
let metrics = vec![
|
||||
(Metric::EdgeRatio, shape::edge_ratio(&c, &jacobian::HEX_EDGES)),
|
||||
(Metric::ScaledJacobian, scaled),
|
||||
(Metric::Shape, knupp),
|
||||
(Metric::JacobianRatio, check.ratio),
|
||||
];
|
||||
return Ok(ElementQuality { kind, size: check.volume, jacobian: Some(check), metrics });
|
||||
}
|
||||
}
|
||||
generic_quality(kind, xyz, &p)
|
||||
}
|
||||
|
||||
/// [`element_quality`] through the generic Bernstein path (every kind).
|
||||
fn generic_quality(kind: ElementKind, xyz: &[[f64; 3]], p: &[[f64; 3]]) -> Result<ElementQuality> {
|
||||
let mut metrics = match kind {
|
||||
ElementKind::Tri3 | ElementKind::Tri6 => {
|
||||
let (area, metrics) = shape::tri(corners(&p)?);
|
||||
let (area, metrics) = shape::tri(corners(p)?);
|
||||
return Ok(ElementQuality { kind, size: area, jacobian: None, metrics });
|
||||
}
|
||||
ElementKind::Quad4 | ElementKind::Quad8 => {
|
||||
let (area, metrics) = shape::quad(corners(&p)?);
|
||||
let (area, metrics) = shape::quad(corners(p)?);
|
||||
return Ok(ElementQuality { kind, size: area, jacobian: None, metrics });
|
||||
}
|
||||
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => {
|
||||
return Err(Error::Invalid(format!("quality metrics are not defined for {}", kind.name())));
|
||||
}
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => shape::tet(corners(&p)?),
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => shape::tet(corners(p)?),
|
||||
ElementKind::Wedge6 => {
|
||||
let c: [[f64; 3]; 6] = corners(&p)?;
|
||||
let c: [[f64; 3]; 6] = corners(p)?;
|
||||
let edges = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
|
||||
vec![(Metric::ScaledJacobian, shape::wedge_scaled_jacobian(c)), (Metric::EdgeRatio, shape::edge_ratio(&c, &edges))]
|
||||
}
|
||||
ElementKind::Pyramid5 => {
|
||||
let c: [[f64; 3]; 5] = corners(&p)?;
|
||||
let c: [[f64; 3]; 5] = corners(p)?;
|
||||
let edges = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 4), (1, 4), (2, 4), (3, 4)];
|
||||
vec![(Metric::ScaledJacobian, shape::pyramid_scaled_jacobian(c)), (Metric::EdgeRatio, shape::edge_ratio(&c, &edges))]
|
||||
}
|
||||
ElementKind::Hex8 | ElementKind::Hex20 => vec![(Metric::EdgeRatio, shape::edge_ratio(&corners::<8>(&p)?, &jacobian::HEX_EDGES))],
|
||||
ElementKind::Hex8 | ElementKind::Hex20 => vec![(Metric::EdgeRatio, shape::edge_ratio(&corners::<8>(p)?, &jacobian::HEX_EDGES))],
|
||||
};
|
||||
let (columns, check) = checked(kind, xyz, &p)?;
|
||||
let (columns, check) = checked(kind, xyz, p)?;
|
||||
if let Some(columns) = columns.as_ref().filter(|_| matches!(kind, ElementKind::Hex8 | ElementKind::Hex20)) {
|
||||
let (scaled, knupp) = hex_metrics(columns);
|
||||
metrics.extend([(Metric::ScaledJacobian, scaled), (Metric::Shape, knupp)]);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Fans and porous resistances inside a fluid domain: vents, grilles, filters and perforated
|
||||
//! plates as a porous zone instead of every hole, and fans as a zone that raises the pressure by
|
||||
//! their pressure–flow curve, so the operating point comes out of the solve.
|
||||
//!
|
||||
//! Both are axis-aligned boxes in the domain (the cells whose centres they contain). A
|
||||
//! [`PorousZone`] resists the flow with the Darcy–Forchheimer law, a force per volume
|
||||
//! `F = −(μ/K) u − (ρ C₂ / 2) |u| u`, so a plate of thickness `t` crossed at the superficial
|
||||
//! velocity `u` drops the pressure by `Δp = (μ/K) u t + (ρ C₂/2) u² t`; a loss coefficient `k`
|
||||
//! (`Δp = k ρ u²/2`) is `C₂ = k / t` over the zone's thickness along its axis. A [`Fan`] pushes
|
||||
//! along its direction with a uniform force per volume `Δp / t`, `Δp` read from its curve at the
|
||||
//! volume flow `Q` through it.
|
||||
//!
|
||||
//! Units are the model's consistent system, like everything else (permeability a length²,
|
||||
//! `C₂` per length, `Q` a volume per time, `Δp` a pressure); nothing is converted.
|
||||
//!
|
||||
//! Sources: H. Darcy 1856 and P. Forchheimer 1901 through S. Ergun, *Fluid flow through packed
|
||||
//! columns*, Chem. Eng. Prog. 48:89, 1952 (the quadratic resistance law); I. E. Idelchik,
|
||||
//! *Handbook of Hydraulic Resistance*, 3rd ed., 1996 (loss coefficients of grids and screens);
|
||||
//! the fan's operating point is the intersection of its curve with the system's resistance
|
||||
//! curve (any fan engineering text, e.g. ASHRAE Handbook — Fundamentals, ch. 21).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Result, finite, positive};
|
||||
|
||||
/// Most fans and porous zones together in one domain.
|
||||
pub const MAX_ZONES: usize = 1000;
|
||||
/// Most points on one fan curve.
|
||||
pub const MAX_CURVE_POINTS: usize = 1000;
|
||||
/// The fan's default under-relaxation of its pressure rise per time step.
|
||||
pub const DEFAULT_FAN_RELAXATION: f64 = 0.01;
|
||||
|
||||
/// A coordinate axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Axis::X => 0,
|
||||
Axis::Y => 1,
|
||||
Axis::Z => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Axis::X => "x",
|
||||
Axis::Y => "y",
|
||||
Axis::Z => "z",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a porous zone resists the flow.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Resistance {
|
||||
/// `F = −(μ/K) u − (ρ C₂/2) |u| u`: the permeability `K` (a length²) and the inertial
|
||||
/// coefficient `C₂` (per length; 0 for a purely viscous medium).
|
||||
DarcyForchheimer {
|
||||
permeability: f64,
|
||||
#[serde(default)]
|
||||
inertial: f64,
|
||||
},
|
||||
/// `Δp = k ρ u²/2` across the zone along its axis (`C₂ = k / t`, no viscous part).
|
||||
LossCoefficient { coefficient: f64 },
|
||||
}
|
||||
|
||||
/// A vent, grille, filter or perforated plate: a box of porous resistance.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PorousZone {
|
||||
pub name: String,
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
/// The direction the flow goes through it: its thickness for a loss coefficient, and the
|
||||
/// direction its flow is reported in. The resistance itself acts in every direction.
|
||||
pub axis: Axis,
|
||||
pub resistance: Resistance,
|
||||
}
|
||||
|
||||
/// The direction a fan blows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FanDirection {
|
||||
#[serde(rename = "+x")]
|
||||
XPlus,
|
||||
#[serde(rename = "-x")]
|
||||
XMinus,
|
||||
#[serde(rename = "+y")]
|
||||
YPlus,
|
||||
#[serde(rename = "-y")]
|
||||
YMinus,
|
||||
#[serde(rename = "+z")]
|
||||
ZPlus,
|
||||
#[serde(rename = "-z")]
|
||||
ZMinus,
|
||||
}
|
||||
|
||||
impl FanDirection {
|
||||
/// The axis (0, 1, 2) and the sign along it.
|
||||
pub fn axis_sign(self) -> (usize, f64) {
|
||||
match self {
|
||||
FanDirection::XPlus => (0, 1.0),
|
||||
FanDirection::XMinus => (0, -1.0),
|
||||
FanDirection::YPlus => (1, 1.0),
|
||||
FanDirection::YMinus => (1, -1.0),
|
||||
FanDirection::ZPlus => (2, 1.0),
|
||||
FanDirection::ZMinus => (2, -1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fan: a box (usually one or a few cells thick) that raises the pressure along its direction
|
||||
/// by its curve's `Δp(Q)`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Fan {
|
||||
pub name: String,
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
pub direction: FanDirection,
|
||||
/// `[Q, Δp]` points, `Q` (volume per time through the fan) strictly increasing from 0 or
|
||||
/// more, `Δp` not increasing; linear between them. Beyond the last point the fan holds its end
|
||||
/// value (it never brakes the flow past free delivery), before the first the first value.
|
||||
pub curve: Vec<[f64; 2]>,
|
||||
/// How far the pressure rise moves toward the curve's value each time step (0 < r ≤ 1);
|
||||
/// smaller is steadier.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub relaxation: Option<f64>,
|
||||
}
|
||||
|
||||
impl Fan {
|
||||
/// The curve's pressure rise at volume flow `q`.
|
||||
pub fn pressure_rise(&self, q: f64) -> f64 {
|
||||
curve_value(&self.curve, q)
|
||||
}
|
||||
|
||||
/// The largest pressure rise (the shut-off pressure, the first point).
|
||||
pub fn max_pressure_rise(&self) -> f64 {
|
||||
self.curve.iter().map(|p| p[1]).fold(0.0, f64::max)
|
||||
}
|
||||
}
|
||||
|
||||
/// `[x, y]` points' piecewise linear value at `x`, the end values beyond them.
|
||||
pub fn curve_value(points: &[[f64; 2]], x: f64) -> f64 {
|
||||
let (Some(first), Some(last)) = (points.first(), points.last()) else { return 0.0 };
|
||||
if !(x > first[0]) {
|
||||
return first[1];
|
||||
}
|
||||
if x >= last[0] {
|
||||
return last[1];
|
||||
}
|
||||
for w in points.windows(2) {
|
||||
if let [a, b] = w
|
||||
&& x <= b[0]
|
||||
{
|
||||
let s = (x - a[0]) / (b[0] - a[0]);
|
||||
return a[1] + s * (b[1] - a[1]);
|
||||
}
|
||||
}
|
||||
last[1]
|
||||
}
|
||||
|
||||
fn check_box(min: [f64; 3], max: [f64; 3]) -> Result<()> {
|
||||
for a in 0..3 {
|
||||
let (lo, hi) = (min.get(a).copied().unwrap_or(0.0), max.get(a).copied().unwrap_or(0.0));
|
||||
finite("min", lo)?;
|
||||
finite("max", hi)?;
|
||||
if !(hi >= lo) {
|
||||
return Err(Error::Invalid(format!("the box's {} range {lo}..{hi} is empty", ["x", "y", "z"].get(a).copied().unwrap_or("?"))));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PorousZone {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
check_box(self.min, self.max)?;
|
||||
match self.resistance {
|
||||
Resistance::DarcyForchheimer { permeability, inertial } => {
|
||||
positive("permeability", permeability)?;
|
||||
finite("inertial coefficient", inertial)?;
|
||||
if inertial < 0.0 {
|
||||
return Err(Error::Invalid(format!("the inertial coefficient {inertial} is negative")));
|
||||
}
|
||||
}
|
||||
Resistance::LossCoefficient { coefficient } => {
|
||||
positive("loss coefficient", coefficient)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Fan {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
check_box(self.min, self.max)?;
|
||||
if self.curve.len() < 2 {
|
||||
return Err(Error::Invalid("a fan curve needs at least two [Q, Δp] points".into()));
|
||||
}
|
||||
if self.curve.len() > MAX_CURVE_POINTS {
|
||||
return Err(Error::Limit(format!("a fan curve holds at most {MAX_CURVE_POINTS} points")));
|
||||
}
|
||||
for (i, p) in self.curve.iter().enumerate() {
|
||||
finite("fan curve flow", p[0])?;
|
||||
finite("fan curve pressure", p[1])?;
|
||||
if p[0] < 0.0 {
|
||||
return Err(Error::Invalid(format!("fan curve point {i}: the flow {} is negative", p[0])));
|
||||
}
|
||||
if let Some(prev) = i.checked_sub(1).and_then(|j| self.curve.get(j)) {
|
||||
if !(p[0] > prev[0]) {
|
||||
return Err(Error::Invalid(format!("fan curve point {i}: the flows must increase ({} after {})", p[0], prev[0])));
|
||||
}
|
||||
if p[1] > prev[1] {
|
||||
return Err(Error::Invalid(format!(
|
||||
"fan curve point {i}: the pressure rise must not increase with the flow ({} after {}); a rising branch has no stable operating point",
|
||||
p[1], prev[1]
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !(self.max_pressure_rise() > 0.0) {
|
||||
return Err(Error::Invalid("a fan curve needs a positive pressure rise somewhere".into()));
|
||||
}
|
||||
if let Some(r) = self.relaxation
|
||||
&& !(r > 0.0 && r <= 1.0)
|
||||
{
|
||||
return Err(Error::Invalid(format!("the fan relaxation {r} must be in (0, 1]")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the zones of a domain: each valid, names unique and not empty, within the caps.
|
||||
pub(crate) fn validate_zones(porous: &[PorousZone], fans: &[Fan]) -> Result<()> {
|
||||
if porous.len() + fans.len() > MAX_ZONES {
|
||||
return Err(Error::Limit(format!("a fluid domain holds at most {MAX_ZONES} fans and porous zones")));
|
||||
}
|
||||
let names: Vec<&str> = porous.iter().map(|z| z.name.as_str()).chain(fans.iter().map(|f| f.name.as_str())).collect();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
if n.trim().is_empty() {
|
||||
return Err(Error::Invalid("a fan or porous zone needs a name".into()));
|
||||
}
|
||||
if names.get(..i).is_some_and(|before| before.contains(n)) {
|
||||
return Err(Error::Invalid(format!("two fans or porous zones are called `{n}`")));
|
||||
}
|
||||
}
|
||||
for z in porous {
|
||||
z.validate().map_err(|e| crate::context("porous zone", &z.name, e))?;
|
||||
}
|
||||
for f in fans {
|
||||
f.validate().map_err(|e| crate::context("fan", &f.name, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fan() -> Fan {
|
||||
Fan {
|
||||
name: "F".into(),
|
||||
min: [0.0; 3],
|
||||
max: [1.0; 3],
|
||||
direction: FanDirection::XPlus,
|
||||
curve: vec![[0.0, 10.0], [5.0, 6.0], [10.0, 0.0]],
|
||||
relaxation: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fan_curve_interpolates_and_holds_its_ends() {
|
||||
let f = fan();
|
||||
assert_eq!(f.pressure_rise(-1.0), 10.0);
|
||||
assert_eq!(f.pressure_rise(2.5), 8.0);
|
||||
assert_eq!(f.pressure_rise(7.5), 3.0);
|
||||
assert_eq!(f.pressure_rise(20.0), 0.0);
|
||||
assert_eq!(f.pressure_rise(f64::NAN), 10.0);
|
||||
assert!(f.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_zones_are_refused() {
|
||||
let bad_fans = [
|
||||
Fan { curve: vec![[0.0, 1.0]], ..fan() },
|
||||
Fan { curve: vec![[0.0, 1.0], [0.0, 0.5]], ..fan() },
|
||||
Fan { curve: vec![[0.0, 1.0], [1.0, 2.0]], ..fan() },
|
||||
Fan { curve: vec![[-1.0, 1.0], [1.0, 0.0]], ..fan() },
|
||||
Fan { curve: vec![[0.0, f64::NAN], [1.0, 0.0]], ..fan() },
|
||||
Fan { curve: vec![[0.0, 0.0], [1.0, -1.0]], ..fan() },
|
||||
Fan { relaxation: Some(0.0), ..fan() },
|
||||
Fan { relaxation: Some(2.0), ..fan() },
|
||||
Fan { max: [-1.0, 1.0, 1.0], ..fan() },
|
||||
Fan { min: [f64::INFINITY, 0.0, 0.0], ..fan() },
|
||||
];
|
||||
for f in &bad_fans {
|
||||
assert!(f.validate().is_err(), "{f:?}");
|
||||
}
|
||||
let zone = |r| PorousZone { name: "V".into(), min: [0.0; 3], max: [1.0; 3], axis: Axis::X, resistance: r };
|
||||
for r in [
|
||||
Resistance::DarcyForchheimer { permeability: 0.0, inertial: 0.0 },
|
||||
Resistance::DarcyForchheimer { permeability: 1.0, inertial: -1.0 },
|
||||
Resistance::DarcyForchheimer { permeability: f64::NAN, inertial: 0.0 },
|
||||
Resistance::LossCoefficient { coefficient: -2.0 },
|
||||
] {
|
||||
assert!(zone(r).validate().is_err(), "{r:?}");
|
||||
}
|
||||
let ok = zone(Resistance::LossCoefficient { coefficient: 2.0 });
|
||||
assert!(validate_zones(std::slice::from_ref(&ok), &[]).is_ok());
|
||||
assert!(validate_zones(&[ok.clone(), ok.clone()], &[]).is_err());
|
||||
assert!(validate_zones(&[PorousZone { name: "F".into(), ..ok }], &[fan()]).is_err());
|
||||
}
|
||||
}
|
||||
+77
-10
@@ -15,6 +15,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fluid_zones::{Fan, PorousZone};
|
||||
use crate::geometry::{Entity, GeometryRef};
|
||||
use crate::{Error, Material, Model, Result, context, finite, insert_new, not_in_use, positive, remove_existing, replace_existing, require};
|
||||
|
||||
@@ -453,6 +454,12 @@ pub struct FluidDomain {
|
||||
/// Heat in the fluid (a temperature field, buoyancy); `None` for an isothermal flow.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thermal: Option<FluidThermal>,
|
||||
/// Vents, grilles, filters and perforated plates as porous resistance zones.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub porous: Vec<PorousZone>,
|
||||
/// Fans: zones that raise the pressure by their pressure–flow curve.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub fans: Vec<Fan>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &[f64; 3]) -> bool {
|
||||
@@ -546,6 +553,7 @@ impl FluidDomain {
|
||||
if let Some(t) = self.tolerance {
|
||||
positive("tolerance", t)?;
|
||||
}
|
||||
crate::fluid_zones::validate_zones(&self.porous, &self.fans)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -684,6 +692,8 @@ mod tests {
|
||||
acceleration: [0.0; 3],
|
||||
tolerance: None,
|
||||
thermal: None,
|
||||
porous: vec![],
|
||||
fans: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,7 +803,8 @@ mod tests {
|
||||
assert!(matches!(m.remove_material("Water"), Err(Error::InUse(_))));
|
||||
|
||||
// A step refers to the domain, which then cannot be deleted.
|
||||
let fluid = Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: None };
|
||||
let fluid =
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: None, steady: None };
|
||||
m.add_step(Step::new("Run", fluid.clone()), None).unwrap();
|
||||
assert!(matches!(m.remove_fluid_domain("Flow"), Err(Error::InUse(_))));
|
||||
// The JSON form reads back to the same model, through the same checks.
|
||||
@@ -805,18 +816,74 @@ mod tests {
|
||||
listed.output.insert(crate::OutputVariable::Stress);
|
||||
assert!(m.add_step(listed, None).is_err());
|
||||
for bad in [
|
||||
Procedure::Fluid { domain: "Nope".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: Some(5), time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: None, time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(-1.0), steps: None, time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(0), time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: Some(0.0), frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 0, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: Some(0.0) },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: Some(f64::NAN) },
|
||||
Procedure::Fluid { domain: "Nope".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: None, steady: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: Some(5), time_step: None, frames: 4, magic: None, steady: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: None, time_step: None, frames: 4, magic: None, steady: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(-1.0), steps: None, time_step: None, frames: 4, magic: None, steady: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(0), time_step: None, frames: 4, magic: None, steady: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: Some(0.0), frames: 4, magic: None, steady: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 0, magic: None, steady: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: Some(0.0), steady: None },
|
||||
Procedure::Fluid {
|
||||
domain: "Flow".into(),
|
||||
end_time: Some(1.0),
|
||||
steps: None,
|
||||
time_step: None,
|
||||
frames: 4,
|
||||
magic: Some(f64::NAN),
|
||||
steady: None,
|
||||
},
|
||||
] {
|
||||
assert!(m.add_step(Step::new("Bad", bad.clone()), None).is_err(), "{bad:?}");
|
||||
}
|
||||
// The steady-state rule's numbers are checked too.
|
||||
let steady = |st: crate::Steady| Procedure::Fluid {
|
||||
domain: "Flow".into(),
|
||||
end_time: Some(1.0),
|
||||
steps: None,
|
||||
time_step: None,
|
||||
frames: 4,
|
||||
magic: None,
|
||||
steady: Some(st),
|
||||
};
|
||||
let rule = crate::Steady { monitors: vec![], tolerance: 1e-3, window: None, min_time: None, divergence: None };
|
||||
let monitored = crate::Steady {
|
||||
monitors: vec![
|
||||
crate::FlowMonitor::MassFlow { face: BoxFace::XMax },
|
||||
crate::FlowMonitor::Force {
|
||||
faces: vec![FluidBoundary::Box(BoxFace::YMin), FluidBoundary::Surface(crate::GeometryRef::face("Ball", "F3").unwrap())],
|
||||
},
|
||||
crate::FlowMonitor::Heat { faces: vec![FluidBoundary::Box(BoxFace::XMin)] },
|
||||
crate::FlowMonitor::MeanTemperature {},
|
||||
],
|
||||
tolerance: 1e-4,
|
||||
window: Some(0.1),
|
||||
min_time: Some(0.2),
|
||||
divergence: Some(0.5),
|
||||
};
|
||||
m.add_step(Step::new("Steady", steady(monitored)), None).unwrap();
|
||||
// The rule and its monitors read back from JSON to the same model.
|
||||
let text = serde_json::to_string(&m).unwrap();
|
||||
assert!(text.contains("\"mean_temperature\"") && text.contains("\"F3\""), "{text}");
|
||||
assert_eq!(serde_json::from_str::<Model>(&text).unwrap(), m);
|
||||
m.remove_step("Steady").unwrap();
|
||||
let force = |n: usize| crate::FlowMonitor::Force { faces: vec![FluidBoundary::Box(BoxFace::XMin); n] };
|
||||
for bad in [
|
||||
crate::Steady { tolerance: 0.0, ..rule.clone() },
|
||||
crate::Steady { tolerance: f64::NAN, ..rule.clone() },
|
||||
crate::Steady { tolerance: 1.0, ..rule.clone() },
|
||||
crate::Steady { window: Some(-1.0), ..rule.clone() },
|
||||
crate::Steady { window: Some(f64::INFINITY), ..rule.clone() },
|
||||
crate::Steady { min_time: Some(-1.0), ..rule.clone() },
|
||||
crate::Steady { min_time: Some(f64::NAN), ..rule.clone() },
|
||||
crate::Steady { divergence: Some(1e-4), ..rule.clone() },
|
||||
crate::Steady { divergence: Some(f64::INFINITY), ..rule.clone() },
|
||||
crate::Steady { monitors: vec![force(0)], ..rule.clone() },
|
||||
crate::Steady { monitors: vec![force(1001)], ..rule.clone() },
|
||||
crate::Steady { monitors: vec![crate::FlowMonitor::MeanTemperature {}; 101], ..rule.clone() },
|
||||
] {
|
||||
assert!(m.add_step(Step::new("Bad", steady(bad.clone())), None).is_err(), "{bad:?}");
|
||||
}
|
||||
m.remove_step("Run").unwrap();
|
||||
m.remove_fluid_domain("Flow").unwrap();
|
||||
m.remove_material("Water").unwrap();
|
||||
|
||||
@@ -37,6 +37,7 @@ pub mod constraints;
|
||||
pub mod contact;
|
||||
pub mod datums;
|
||||
pub mod discrete;
|
||||
pub mod fluid_zones;
|
||||
pub mod fluids;
|
||||
pub mod geometry;
|
||||
pub mod loads;
|
||||
@@ -66,6 +67,7 @@ pub use constraints::{Coupling, CouplingKind, ReferencePoint};
|
||||
pub use contact::{Contact, ContactSurfaces, Friction, InitialPenetration};
|
||||
pub use datums::{Datum, Partition};
|
||||
pub use discrete::{Direction, Mass, PointMass, ResolvedDirection, ResolvedMass, ResolvedSpring, Spring};
|
||||
pub use fluid_zones::{Axis, Fan, FanDirection, PorousZone, Resistance};
|
||||
pub use fluids::{
|
||||
BoxFace, BoxFaces, BoxThermalFaces, FaceCondition, FluidBoundary, FluidDomain, FluidThermal, Profile, SurfaceBoundary, SurfaceCondition,
|
||||
SurfaceThermalBoundary, ThermalCondition,
|
||||
@@ -84,9 +86,9 @@ pub use seeds::{Bias, BiasToward, Divisions, Seed, SeedConstraint};
|
||||
pub use sets::Set;
|
||||
pub use sizes::{SizeControl, SizeRegion};
|
||||
pub use steps::{
|
||||
ArcConstraint, ArcLength, BulkViscosity, DisplacementLimit, ExplicitDynamic, Hourglass, HourglassKind, Imperfection, InitialVelocity,
|
||||
MAX_LOAD_PATH_POINTS, MAX_NONLINEAR_INCREMENTS, MAX_NONLINEAR_ITERATIONS, MassScaling, NonlinearStatic, OutputVariable, Procedure, RigidWall,
|
||||
Step, StepInputs, StressRate, Tolerances, Transient,
|
||||
ArcConstraint, ArcLength, BulkViscosity, DisplacementLimit, ExplicitDynamic, FlowMonitor, Hourglass, HourglassKind, Imperfection,
|
||||
InitialVelocity, MAX_LOAD_PATH_POINTS, MAX_NONLINEAR_INCREMENTS, MAX_NONLINEAR_ITERATIONS, MassScaling, NonlinearStatic, OutputVariable,
|
||||
Procedure, RigidWall, Steady, Step, StepInputs, StressRate, Tolerances, Transient,
|
||||
};
|
||||
pub use tie::Tie;
|
||||
pub use units::UnitSystem;
|
||||
|
||||
+111
-1
@@ -71,6 +71,8 @@ pub enum Procedure {
|
||||
/// and pressure fields are written, evenly spaced, after the initial one; forces on every
|
||||
/// wall are recorded throughout. `magic` is the lattice Boltzmann two-relaxation-time
|
||||
/// parameter Λ (default 3/16, which puts bounce-back walls exactly halfway between cells).
|
||||
/// With `steady`, the run stops on its own once the flow stops changing ([`Steady`]); its
|
||||
/// length is then the most it may run.
|
||||
Fluid {
|
||||
domain: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -83,6 +85,8 @@ pub enum Procedure {
|
||||
frames: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
magic: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
steady: Option<Steady>,
|
||||
},
|
||||
/// Heat conduction: the steady temperature field, or its history over time when `transient`
|
||||
/// is given. Temperature boundary conditions and heat loads (flux, convection, sources)
|
||||
@@ -101,6 +105,109 @@ pub enum Procedure {
|
||||
NonlinearStatic(NonlinearStatic),
|
||||
}
|
||||
|
||||
/// Most quantities a steady-state rule monitors besides the residual.
|
||||
pub const MAX_FLOW_MONITORS: usize = 100;
|
||||
|
||||
/// Most faces one monitored quantity sums over.
|
||||
pub const MAX_MONITOR_FACES: usize = 1000;
|
||||
|
||||
fn default_steady_tolerance() -> f64 {
|
||||
1e-3
|
||||
}
|
||||
|
||||
/// The stop rule of a fluid step run to steady state.
|
||||
///
|
||||
/// The run samples its monitored quantities 8 times per `window` of flow time: the velocity
|
||||
/// field (and the temperature field when the flow carries heat) and every quantity of
|
||||
/// `monitors`. `tolerance` is the relative change per window: the spread (largest minus smallest
|
||||
/// sample) of each quantity over the window, relative to the largest magnitude it has had. The
|
||||
/// run stops as steady after two consecutive full windows, ending at or after `min_time`, with
|
||||
/// every relative change below `tolerance`; when `divergence` is given, a relative change above
|
||||
/// it after `min_time` stops the run as diverged. Otherwise it runs the step's full length.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Steady {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub monitors: Vec<FlowMonitor>,
|
||||
#[serde(default = "default_steady_tolerance")]
|
||||
pub tolerance: f64,
|
||||
/// Flow time the change is measured over (default: a twentieth of the step's length).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub window: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub min_time: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub divergence: Option<f64>,
|
||||
}
|
||||
|
||||
impl Steady {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if !(self.tolerance > 0.0 && self.tolerance < 1.0) {
|
||||
return Err(Error::Invalid(format!("the steady-state tolerance is a relative change above 0 and below 1, got {}", self.tolerance)));
|
||||
}
|
||||
if let Some(w) = self.window {
|
||||
positive("steady-state window", w)?;
|
||||
}
|
||||
if let Some(t) = self.min_time
|
||||
&& !(t >= 0.0 && t.is_finite())
|
||||
{
|
||||
return Err(Error::Invalid(format!("the steady-state minimum time is not negative, got {t}")));
|
||||
}
|
||||
if let Some(d) = self.divergence
|
||||
&& !(d > self.tolerance && d.is_finite())
|
||||
{
|
||||
return Err(Error::Invalid(format!("the divergence limit is a relative change above the tolerance, got {d}")));
|
||||
}
|
||||
if self.monitors.len() > MAX_FLOW_MONITORS {
|
||||
return Err(Error::Invalid(format!("a steady-state rule monitors at most {MAX_FLOW_MONITORS} quantities, got {}", self.monitors.len())));
|
||||
}
|
||||
for m in &self.monitors {
|
||||
if let FlowMonitor::Force { faces } | FlowMonitor::Heat { faces } = m
|
||||
&& !(1..=MAX_MONITOR_FACES).contains(&faces.len())
|
||||
{
|
||||
return Err(Error::Invalid(format!("a monitored force or heat sums 1 to {MAX_MONITOR_FACES} faces, got {}", faces.len())));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A quantity a steady-state rule monitors besides the residual.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FlowMonitor {
|
||||
/// The mass flow out through a box face.
|
||||
MassFlow { face: crate::BoxFace },
|
||||
/// The total force of the fluid on walls (box faces or obstacle faces).
|
||||
Force { faces: Vec<crate::FluidBoundary> },
|
||||
/// The total heat the fluid gives walls.
|
||||
Heat { faces: Vec<crate::FluidBoundary> },
|
||||
/// The mean temperature of the fluid.
|
||||
MeanTemperature {},
|
||||
}
|
||||
|
||||
impl FlowMonitor {
|
||||
/// A short label for reports: `mass_flow x_max`, `force x_min, Body1/F3`, `mean_temperature`.
|
||||
pub fn label(&self) -> String {
|
||||
let faces = |faces: &[crate::FluidBoundary]| {
|
||||
faces
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
crate::FluidBoundary::Box(b) => b.name().to_string(),
|
||||
crate::FluidBoundary::Surface(r) => format!("{}/{}", r.body, r.entity.name().unwrap_or_default()),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
match self {
|
||||
FlowMonitor::MassFlow { face } => format!("mass_flow {}", face.name()),
|
||||
FlowMonitor::Force { faces: f } => format!("force {}", faces(f)),
|
||||
FlowMonitor::Heat { faces: f } => format!("heat {}", faces(f)),
|
||||
FlowMonitor::MeanTemperature {} => "mean_temperature".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most load increments a nonlinear static step may take.
|
||||
pub const MAX_NONLINEAR_INCREMENTS: u32 = 100_000;
|
||||
|
||||
@@ -710,7 +817,10 @@ impl Procedure {
|
||||
if let Procedure::NonlinearStatic(n) = self {
|
||||
return n.validate();
|
||||
}
|
||||
if let Procedure::Fluid { end_time, steps, time_step, frames, magic, .. } = *self {
|
||||
if let Procedure::Fluid { end_time, steps, time_step, frames, magic, ref steady, .. } = *self {
|
||||
if let Some(st) = steady {
|
||||
st.validate()?;
|
||||
}
|
||||
match (end_time, steps) {
|
||||
(Some(t), None) => {
|
||||
crate::positive("end time", t)?;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Physical dimensions as integer exponents of the SI base dimensions.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A physical dimension: the exponents of length L, mass M, time T, temperature Θ and electric
|
||||
/// current I. Dimensionless quantities (Poisson's ratio, strain, angles) have every exponent 0.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Dimension {
|
||||
pub length: i8,
|
||||
pub mass: i8,
|
||||
pub time: i8,
|
||||
pub temperature: i8,
|
||||
pub current: i8,
|
||||
}
|
||||
|
||||
impl Dimension {
|
||||
pub const NONE: Dimension = Dimension::new(0, 0, 0, 0);
|
||||
pub const LENGTH: Dimension = Dimension::new(1, 0, 0, 0);
|
||||
pub const MASS: Dimension = Dimension::new(0, 1, 0, 0);
|
||||
pub const TIME: Dimension = Dimension::new(0, 0, 1, 0);
|
||||
pub const TEMPERATURE: Dimension = Dimension::new(0, 0, 0, 1);
|
||||
pub const CURRENT: Dimension = Dimension { current: 1, ..Dimension::NONE };
|
||||
|
||||
/// L^length M^mass T^time Θ^temperature (no current: FieldCraft has no electrical quantities yet).
|
||||
pub const fn new(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension {
|
||||
Dimension { length, mass, time, temperature, current: 0 }
|
||||
}
|
||||
|
||||
fn exponents(self) -> [i8; 5] {
|
||||
[self.length, self.mass, self.time, self.temperature, self.current]
|
||||
}
|
||||
|
||||
fn from_exponents([length, mass, time, temperature, current]: [i8; 5]) -> Dimension {
|
||||
Dimension { length, mass, time, temperature, current }
|
||||
}
|
||||
|
||||
fn zip(self, other: Dimension, f: impl Fn(i8, i8) -> Option<i8>) -> Option<Dimension> {
|
||||
let (a, b) = (self.exponents(), other.exponents());
|
||||
let mut out = [0i8; 5];
|
||||
for ((o, x), y) in out.iter_mut().zip(a).zip(b) {
|
||||
*o = f(x, y)?;
|
||||
}
|
||||
Some(Dimension::from_exponents(out))
|
||||
}
|
||||
|
||||
/// The dimension of a product, or `None` if an exponent overflows.
|
||||
pub fn checked_mul(self, other: Dimension) -> Option<Dimension> {
|
||||
self.zip(other, i8::checked_add)
|
||||
}
|
||||
|
||||
/// The dimension of a quotient, or `None` if an exponent overflows.
|
||||
pub fn checked_div(self, other: Dimension) -> Option<Dimension> {
|
||||
self.zip(other, i8::checked_sub)
|
||||
}
|
||||
|
||||
/// The dimension raised to an integer power, or `None` if an exponent overflows.
|
||||
pub fn checked_pow(self, power: i8) -> Option<Dimension> {
|
||||
self.zip(Dimension::NONE, |x, _| x.checked_mul(power))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Dimension {
|
||||
/// `L M T^-2` style; `1` for dimensionless.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut first = true;
|
||||
for (symbol, e) in ["L", "M", "T", "Θ", "I"].into_iter().zip(self.exponents()) {
|
||||
if e == 0 {
|
||||
continue;
|
||||
}
|
||||
if !first {
|
||||
f.write_str(" ")?;
|
||||
}
|
||||
first = false;
|
||||
if e == 1 { write!(f, "{symbol}")? } else { write!(f, "{symbol}^{e}")? }
|
||||
}
|
||||
if first { f.write_str("1") } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_display() {
|
||||
let force = Dimension::new(1, 1, -2, 0);
|
||||
let area = Dimension::LENGTH.checked_pow(2).unwrap();
|
||||
let pressure = force.checked_div(area).unwrap();
|
||||
assert_eq!(pressure, Dimension::new(-1, 1, -2, 0));
|
||||
assert_eq!(pressure.to_string(), "L^-1 M T^-2");
|
||||
assert_eq!(Dimension::NONE.to_string(), "1");
|
||||
assert_eq!(pressure.checked_mul(area), Some(force));
|
||||
assert_eq!(Dimension::new(100, 0, 0, 0).checked_pow(2), None);
|
||||
assert_eq!(Dimension::new(127, 0, 0, 0).checked_mul(Dimension::LENGTH), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Display systems: which unit each quantity is shown and typed in. Choosing one never changes a
|
||||
//! stored value.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Quantity, Unit, UnitError};
|
||||
|
||||
/// A consistent set of display units.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Preset {
|
||||
/// Strict SI: m, kg, s, N, Pa, K.
|
||||
#[serde(rename = "SI")]
|
||||
Si,
|
||||
/// SI with temperatures in °C and angles in degrees.
|
||||
#[serde(rename = "m-kg-s")]
|
||||
MKgS,
|
||||
/// Millimetre, tonne, second, newton, megapascal, °C: what most structural CAE users expect.
|
||||
#[default]
|
||||
#[serde(rename = "mm-t-s-N-MPa")]
|
||||
MmTonneS,
|
||||
/// Inch, lbf·s²/in, second, pound-force, psi, °F.
|
||||
#[serde(rename = "in-lbf-s-psi")]
|
||||
InLbfS,
|
||||
}
|
||||
|
||||
impl Preset {
|
||||
pub const ALL: [Preset; 4] = [Preset::Si, Preset::MKgS, Preset::MmTonneS, Preset::InLbfS];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Preset::Si => "SI",
|
||||
Preset::MKgS => "m-kg-s",
|
||||
Preset::MmTonneS => "mm-t-s-N-MPa",
|
||||
Preset::InLbfS => "in-lbf-s-psi",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_label(label: &str) -> Option<Preset> {
|
||||
Preset::ALL.into_iter().find(|p| p.label() == label)
|
||||
}
|
||||
|
||||
/// The unit this preset shows `q` in.
|
||||
pub fn symbol(self, q: Quantity) -> &'static str {
|
||||
use Quantity as Q;
|
||||
match self {
|
||||
Preset::Si => q.si_symbol(),
|
||||
Preset::MKgS => match q {
|
||||
Q::Temperature => "°C",
|
||||
Q::Angle => "deg",
|
||||
_ => q.si_symbol(),
|
||||
},
|
||||
Preset::MmTonneS => match q {
|
||||
Q::Dimensionless => "1",
|
||||
Q::Angle => "deg",
|
||||
Q::Length => "mm",
|
||||
Q::Area => "mm²",
|
||||
Q::Volume => "mm³",
|
||||
Q::SecondMomentOfArea => "mm⁴",
|
||||
Q::Mass => "t",
|
||||
Q::Density => "t/mm³",
|
||||
Q::Time => "s",
|
||||
Q::Frequency => "Hz",
|
||||
Q::Velocity => "mm/s",
|
||||
Q::Acceleration => "mm/s²",
|
||||
Q::Force => "N",
|
||||
Q::Moment => "N·mm",
|
||||
Q::Pressure => "MPa",
|
||||
Q::Stiffness => "N/mm",
|
||||
Q::Damping => "N·s/mm",
|
||||
Q::Energy => "mJ",
|
||||
Q::Power => "mW",
|
||||
Q::DynamicViscosity => "MPa·s",
|
||||
Q::KinematicViscosity => "mm²/s",
|
||||
Q::Temperature => "°C",
|
||||
Q::TemperatureDifference => "K",
|
||||
Q::ThermalExpansion => "K⁻¹",
|
||||
Q::ThermalConductivity => "mW/(mm·K)",
|
||||
Q::SpecificHeat => "mJ/(t·K)",
|
||||
Q::HeatTransferCoefficient => "mW/(mm²·K)",
|
||||
Q::HeatFlux => "mW/mm²",
|
||||
Q::HeatGeneration => "mW/mm³",
|
||||
},
|
||||
Preset::InLbfS => match q {
|
||||
Q::Dimensionless => "1",
|
||||
Q::Angle => "deg",
|
||||
Q::Length => "in",
|
||||
Q::Area => "in²",
|
||||
Q::Volume => "in³",
|
||||
Q::SecondMomentOfArea => "in⁴",
|
||||
Q::Mass => "lbf·s²/in",
|
||||
Q::Density => "lbf·s²/in⁴",
|
||||
Q::Time => "s",
|
||||
Q::Frequency => "Hz",
|
||||
Q::Velocity => "in/s",
|
||||
Q::Acceleration => "in/s²",
|
||||
Q::Force => "lbf",
|
||||
Q::Moment => "lbf·in",
|
||||
Q::Pressure => "psi",
|
||||
Q::Stiffness => "lbf/in",
|
||||
Q::Damping => "lbf·s/in",
|
||||
Q::Energy => "in·lbf",
|
||||
Q::Power => "in·lbf/s",
|
||||
Q::DynamicViscosity => "psi·s",
|
||||
Q::KinematicViscosity => "in²/s",
|
||||
Q::Temperature => "°F",
|
||||
Q::TemperatureDifference => "°F",
|
||||
Q::ThermalExpansion => "°F⁻¹",
|
||||
Q::ThermalConductivity => "lbf/(s·°F)",
|
||||
Q::SpecificHeat => "in²/(s²·°F)",
|
||||
Q::HeatTransferCoefficient => "lbf/(in·s·°F)",
|
||||
Q::HeatFlux => "lbf/(in·s)",
|
||||
Q::HeatGeneration => "lbf/(in²·s)",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The units values are shown and typed in: a preset with optional per-quantity overrides (say
|
||||
/// mm-t-s-N-MPa with energies in J).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct DisplaySystem {
|
||||
preset: Preset,
|
||||
overrides: Vec<(Quantity, Unit)>,
|
||||
}
|
||||
|
||||
impl DisplaySystem {
|
||||
pub fn new(preset: Preset) -> DisplaySystem {
|
||||
DisplaySystem { preset, overrides: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn preset(&self) -> Preset {
|
||||
self.preset
|
||||
}
|
||||
|
||||
/// Shows `q` in `unit` instead of the preset's unit; refused when the dimension differs.
|
||||
pub fn with_override(mut self, q: Quantity, unit: &str) -> Result<DisplaySystem, UnitError> {
|
||||
let unit = Unit::parse(unit)?;
|
||||
q.express(0.0, &unit)?;
|
||||
self.overrides.retain(|(o, _)| *o != q);
|
||||
self.overrides.push((q, unit));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// The unit `q` is shown and typed in.
|
||||
pub fn unit(&self, q: Quantity) -> Result<Unit, UnitError> {
|
||||
match self.overrides.iter().find(|(o, _)| *o == q) {
|
||||
Some((_, u)) => Ok(u.clone()),
|
||||
None => Unit::parse(self.preset.symbol(q)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,52 @@
|
||||
//! The unit system label.
|
||||
//! Units: the unit system label a model carries today, and the SI unit table every conversion
|
||||
//! goes through.
|
||||
//!
|
||||
//! The model never converts: numbers are stored as entered. The label records which consistent
|
||||
//! system they are in, so the check can warn when a magnitude is implausible for it (a steel
|
||||
//! modulus of 210 000 in a metre–pascal model, a density of 7850 in a millimetre–tonne one), and
|
||||
//! so writers can say which system a deck is in.
|
||||
//! **The table** ([`Dimension`], [`Unit`], [`Quantity`], [`DisplaySystem`]) is the one place
|
||||
//! FieldCraft writes down a conversion factor. Each [`Quantity`] has a dimension and an SI unit;
|
||||
//! a [`Unit`] parses expressions such as `MPa`, `t/mm³` or `W/(m·K)` to a dimension and a factor
|
||||
//! to SI; [`Quantity::parse`] reads `"210 GPa"` or `"20 °C"` with a dimension check, and
|
||||
//! [`Quantity::format`] shows an SI value in a [`DisplaySystem`]. Absolute temperatures
|
||||
//! ([`Quantity::Temperature`]) take the °C/°F offset; temperature differences and every compound
|
||||
//! unit never do. Issue #579 moves model storage to SI on top of this table; until then the
|
||||
//! model still stores numbers as entered.
|
||||
//!
|
||||
//! **The label** ([`UnitSystem`]): the model never converts yet: numbers are stored as entered.
|
||||
//! The label records which consistent system they are in, so the check can warn when a magnitude
|
||||
//! is implausible for it (a steel modulus of 210 000 in a metre–pascal model, a density of 7850
|
||||
//! in a millimetre–tonne one), and so writers can say which system a deck is in.
|
||||
|
||||
mod dimension;
|
||||
mod display;
|
||||
mod quantity;
|
||||
mod unit;
|
||||
|
||||
pub use dimension::Dimension;
|
||||
pub use display::{DisplaySystem, Preset};
|
||||
pub use quantity::{Quantity, ValueInput, WithUnit, format_number};
|
||||
pub use unit::Unit;
|
||||
|
||||
/// Why a unit or a value with a unit was refused.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum UnitError {
|
||||
#[error("no value or unit given")]
|
||||
Empty,
|
||||
#[error("unit text is too long")]
|
||||
TooLong,
|
||||
#[error("`{0}` is not a finite number")]
|
||||
NotFinite(String),
|
||||
#[error("unknown unit `{0}`")]
|
||||
UnknownUnit(String),
|
||||
#[error("cannot read `{0}` as a value with a unit (for example `210 GPa`, `t/mm³`, `W/(m·K)`)")]
|
||||
Syntax(String),
|
||||
#[error("`{0}` is out of range")]
|
||||
OutOfRange(String),
|
||||
#[error("`{0}` is below absolute zero")]
|
||||
BelowAbsoluteZero(String),
|
||||
#[error("`{unit}` is not a unit of {quantity} (angles take rad, deg or °; ratios take 1 or %)")]
|
||||
WrongKind { unit: String, quantity: &'static str },
|
||||
#[error("`{unit}` is {found}, but a {quantity} is {expected}")]
|
||||
WrongDimension { unit: String, found: Dimension, quantity: &'static str, expected: Dimension },
|
||||
}
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! The kinds of physical quantity FieldCraft stores, and parsing and formatting of values with
|
||||
//! units ("210 GPa", `{"value": 20, "unit": "°C"}`) against them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unit::MAX_TEXT;
|
||||
use super::{Dimension, DisplaySystem, Unit, UnitError};
|
||||
|
||||
macro_rules! quantities {
|
||||
($($variant:ident = $name:literal, $dim:expr, $si:literal;)*) => {
|
||||
/// A kind of physical quantity. Each has one dimension and one SI unit, which is how it is
|
||||
/// stored.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Quantity {
|
||||
$($variant,)*
|
||||
}
|
||||
|
||||
impl Quantity {
|
||||
pub const ALL: &[Quantity] = &[$(Quantity::$variant,)*];
|
||||
|
||||
/// The name used in commands and files (`thermal_conductivity`, …).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self { $(Quantity::$variant => $name,)* }
|
||||
}
|
||||
|
||||
pub fn dimension(self) -> Dimension {
|
||||
match self { $(Quantity::$variant => $dim,)* }
|
||||
}
|
||||
|
||||
/// The SI unit the quantity is stored in.
|
||||
pub fn si_symbol(self) -> &'static str {
|
||||
match self { $(Quantity::$variant => $si,)* }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fn d(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension {
|
||||
Dimension::new(length, mass, time, temperature)
|
||||
}
|
||||
|
||||
quantities! {
|
||||
Dimensionless = "dimensionless", d(0, 0, 0, 0), "1";
|
||||
Angle = "angle", d(0, 0, 0, 0), "rad";
|
||||
Length = "length", d(1, 0, 0, 0), "m";
|
||||
Area = "area", d(2, 0, 0, 0), "m²";
|
||||
Volume = "volume", d(3, 0, 0, 0), "m³";
|
||||
SecondMomentOfArea = "second_moment_of_area", d(4, 0, 0, 0), "m⁴";
|
||||
Mass = "mass", d(0, 1, 0, 0), "kg";
|
||||
Density = "density", d(-3, 1, 0, 0), "kg/m³";
|
||||
Time = "time", d(0, 0, 1, 0), "s";
|
||||
Frequency = "frequency", d(0, 0, -1, 0), "Hz";
|
||||
Velocity = "velocity", d(1, 0, -1, 0), "m/s";
|
||||
Acceleration = "acceleration", d(1, 0, -2, 0), "m/s²";
|
||||
Force = "force", d(1, 1, -2, 0), "N";
|
||||
Moment = "moment", d(2, 1, -2, 0), "N·m";
|
||||
Pressure = "pressure", d(-1, 1, -2, 0), "Pa";
|
||||
Stiffness = "stiffness", d(0, 1, -2, 0), "N/m";
|
||||
Damping = "damping", d(0, 1, -1, 0), "N·s/m";
|
||||
Energy = "energy", d(2, 1, -2, 0), "J";
|
||||
Power = "power", d(2, 1, -3, 0), "W";
|
||||
DynamicViscosity = "dynamic_viscosity", d(-1, 1, -1, 0), "Pa·s";
|
||||
KinematicViscosity = "kinematic_viscosity", d(2, 0, -1, 0), "m²/s";
|
||||
Temperature = "temperature", d(0, 0, 0, 1), "K";
|
||||
TemperatureDifference = "temperature_difference", d(0, 0, 0, 1), "K";
|
||||
ThermalExpansion = "thermal_expansion", d(0, 0, 0, -1), "K⁻¹";
|
||||
ThermalConductivity = "thermal_conductivity", d(1, 1, -3, -1), "W/(m·K)";
|
||||
SpecificHeat = "specific_heat", d(2, 0, -2, -1), "J/(kg·K)";
|
||||
HeatTransferCoefficient = "heat_transfer_coefficient", d(0, 1, -3, -1), "W/(m²·K)";
|
||||
HeatFlux = "heat_flux", d(0, 1, -3, 0), "W/m²";
|
||||
HeatGeneration = "heat_generation", d(-1, 1, -3, 0), "W/m³";
|
||||
}
|
||||
|
||||
impl Quantity {
|
||||
pub fn from_name(name: &str) -> Option<Quantity> {
|
||||
Quantity::ALL.iter().copied().find(|q| q.name() == name)
|
||||
}
|
||||
|
||||
/// Whether the unit's zero offset applies (°C and °F as absolute temperatures).
|
||||
fn absolute(self) -> bool {
|
||||
self == Quantity::Temperature
|
||||
}
|
||||
|
||||
/// Converts `value` in `unit` to this quantity's SI value, checking the dimension and the
|
||||
/// result.
|
||||
pub fn to_si(self, value: f64, unit: &Unit) -> Result<f64, UnitError> {
|
||||
if !value.is_finite() {
|
||||
return Err(UnitError::NotFinite(value.to_string()));
|
||||
}
|
||||
self.check(unit)?;
|
||||
let si = unit.to_si(value, self.absolute());
|
||||
if !si.is_finite() {
|
||||
return Err(UnitError::OutOfRange(format!("{value} {}", unit.symbol())));
|
||||
}
|
||||
if self.absolute() && si < 0.0 {
|
||||
return Err(UnitError::BelowAbsoluteZero(format!("{value} {}", unit.symbol())));
|
||||
}
|
||||
Ok(si)
|
||||
}
|
||||
|
||||
/// Converts an SI value of this quantity to `unit`.
|
||||
pub fn express(self, si: f64, unit: &Unit) -> Result<f64, UnitError> {
|
||||
self.check(unit)?;
|
||||
let v = unit.express(si, self.absolute());
|
||||
if v.is_finite() { Ok(v) } else { Err(UnitError::NotFinite(v.to_string())) }
|
||||
}
|
||||
|
||||
fn check(self, unit: &Unit) -> Result<(), UnitError> {
|
||||
if unit.dimension() == self.dimension() {
|
||||
// Angles and ratios share the empty dimension: only an angle takes rad, deg or °.
|
||||
if unit.is_angle() == (self == Quantity::Angle) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(UnitError::WrongKind { unit: unit.symbol().to_string(), quantity: self.name() })
|
||||
}
|
||||
} else {
|
||||
Err(UnitError::WrongDimension {
|
||||
unit: unit.symbol().to_string(),
|
||||
found: unit.dimension(),
|
||||
quantity: self.name(),
|
||||
expected: self.dimension(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `"210 GPa"`, `"25 mm/s"`, `"-5 °C"` (or a bare `"210"`, meaning `display`'s unit)
|
||||
/// to the SI value.
|
||||
pub fn parse(self, text: &str, display: &DisplaySystem) -> Result<f64, UnitError> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(UnitError::Empty);
|
||||
}
|
||||
if text.len() > MAX_TEXT {
|
||||
return Err(UnitError::TooLong);
|
||||
}
|
||||
let (number, unit) = split_number(text).ok_or_else(|| UnitError::Syntax(text.to_string()))?;
|
||||
if unit.trim().is_empty() { self.to_si(number, &display.unit(self)?) } else { self.to_si(number, &Unit::parse(unit)?) }
|
||||
}
|
||||
|
||||
/// Formats an SI value in `display`'s unit for this quantity, with the unit: `210000 MPa`.
|
||||
pub fn format(self, si: f64, display: &DisplaySystem) -> Result<String, UnitError> {
|
||||
let unit = display.unit(self)?;
|
||||
let v = self.express(si, &unit)?;
|
||||
Ok(if unit.symbol() == "1" { format_number(v) } else { format!("{} {}", format_number(v), unit.symbol()) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The leading number of `text` and the rest. `inf` and `nan` are not numbers here.
|
||||
fn split_number(text: &str) -> Option<(f64, &str)> {
|
||||
let end = text.find(|c: char| !(c.is_ascii_digit() || matches!(c, '+' | '-' | '.' | 'e' | 'E'))).unwrap_or(text.len());
|
||||
// Shrink until the prefix parses: "2e" in "2em" is not an exponent.
|
||||
(1..=end).rev().find_map(|i| {
|
||||
let head = text.get(..i)?;
|
||||
let v: f64 = head.parse().ok()?;
|
||||
Some((v, text.get(i..)?))
|
||||
})
|
||||
}
|
||||
|
||||
/// A value with six significant digits and no trailing zeros: `210000`, `7.85e-9`, `0.3`.
|
||||
pub fn format_number(v: f64) -> String {
|
||||
if v == 0.0 || !v.is_finite() {
|
||||
return if v == 0.0 { "0".to_string() } else { v.to_string() };
|
||||
}
|
||||
let exp = v.abs().log10().floor();
|
||||
if (-4.0..7.0).contains(&exp) {
|
||||
let decimals = (5.0 - exp).max(0.0) as usize;
|
||||
let s = format!("{v:.decimals$}");
|
||||
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
|
||||
} else {
|
||||
let s = format!("{v:.5e}");
|
||||
match s.split_once('e') {
|
||||
Some((mantissa, e)) => {
|
||||
let m = if mantissa.contains('.') { mantissa.trim_end_matches('0').trim_end_matches('.') } else { mantissa };
|
||||
format!("{m}e{e}")
|
||||
}
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as commands, scripts and the MCP give it: a bare number (in the display system), a
|
||||
/// string with its unit (`"210 GPa"`), or `{"value": 210, "unit": "GPa"}`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ValueInput {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
WithUnit(WithUnit),
|
||||
}
|
||||
|
||||
/// `{"value": 210, "unit": "GPa"}`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WithUnit {
|
||||
pub value: f64,
|
||||
pub unit: String,
|
||||
}
|
||||
|
||||
impl ValueInput {
|
||||
/// The SI value of this input as quantity `q`; a bare number is in `display`'s unit.
|
||||
pub fn to_si(&self, q: Quantity, display: &DisplaySystem) -> Result<f64, UnitError> {
|
||||
match self {
|
||||
ValueInput::Number(v) => q.to_si(*v, &display.unit(q)?),
|
||||
ValueInput::Text(t) => q.parse(t, display),
|
||||
ValueInput::WithUnit(w) => q.to_si(w.value, &Unit::parse(&w.unit)?),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_si_symbol_parses_to_its_dimension_with_factor_one() {
|
||||
for &q in Quantity::ALL {
|
||||
let u = Unit::parse(q.si_symbol()).unwrap_or_else(|e| panic!("{q:?}: {e}"));
|
||||
assert_eq!(u.dimension(), q.dimension(), "{q:?}");
|
||||
assert!((u.factor() - 1.0).abs() < 1e-15, "{q:?}");
|
||||
assert_eq!(Quantity::from_name(q.name()), Some(q));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_split_from_units() {
|
||||
assert_eq!(split_number("210 GPa"), Some((210.0, " GPa")));
|
||||
assert_eq!(split_number("2.1e11Pa"), Some((2.1e11, "Pa")));
|
||||
assert_eq!(split_number("-5 °C"), Some((-5.0, " °C")));
|
||||
assert_eq!(split_number("3e"), Some((3.0, "e")));
|
||||
assert_eq!(split_number("inf Pa"), None);
|
||||
assert_eq!(split_number("NaN"), None);
|
||||
assert_eq!(split_number("GPa"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_format_with_six_significant_digits() {
|
||||
assert_eq!(format_number(210000.0), "210000");
|
||||
assert_eq!(format_number(7.85e-9), "7.85e-9");
|
||||
assert_eq!(format_number(0.3), "0.3");
|
||||
assert_eq!(format_number(2.1e11), "2.1e11");
|
||||
assert_eq!(format_number(-20.0), "-20");
|
||||
assert_eq!(format_number(1.0 / 3.0), "0.333333");
|
||||
assert_eq!(format_number(0.0), "0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Named units and unit expressions (`MPa`, `t/mm³`, `W/(m·K)`), each reduced to a dimension and
|
||||
//! a factor to SI.
|
||||
//!
|
||||
//! This table is the only place in FieldCraft where a conversion factor is written down.
|
||||
|
||||
use super::{Dimension, UnitError};
|
||||
|
||||
/// Exact definitions of the non-metric units (NIST SP 811).
|
||||
const INCH: f64 = 0.0254;
|
||||
const FOOT: f64 = 0.3048;
|
||||
const POUND_MASS: f64 = 0.453_592_37;
|
||||
const STANDARD_GRAVITY: f64 = 9.806_65;
|
||||
const POUND_FORCE: f64 = POUND_MASS * STANDARD_GRAVITY;
|
||||
/// Celsius zero in kelvin.
|
||||
const CELSIUS_ZERO: f64 = 273.15;
|
||||
/// One degree Fahrenheit (or Rankine) in kelvin.
|
||||
const FAHRENHEIT_STEP: f64 = 5.0 / 9.0;
|
||||
/// Fahrenheit zero in kelvin: 459.67 °R.
|
||||
const FAHRENHEIT_ZERO: f64 = 459.67 * FAHRENHEIT_STEP;
|
||||
|
||||
const L: Dimension = Dimension::LENGTH;
|
||||
const M: Dimension = Dimension::MASS;
|
||||
const T: Dimension = Dimension::TIME;
|
||||
const TH: Dimension = Dimension::TEMPERATURE;
|
||||
const ONE: Dimension = Dimension::NONE;
|
||||
const FORCE: Dimension = Dimension::new(1, 1, -2, 0);
|
||||
const PRESSURE: Dimension = Dimension::new(-1, 1, -2, 0);
|
||||
const ENERGY: Dimension = Dimension::new(2, 1, -2, 0);
|
||||
const POWER: Dimension = Dimension::new(2, 1, -3, 0);
|
||||
const FREQUENCY: Dimension = Dimension::new(0, 0, -1, 0);
|
||||
|
||||
/// A named unit: its spellings (the first is the canonical symbol), dimension, the size of one
|
||||
/// unit in SI, and the SI value of its zero (non-zero only for °C and °F).
|
||||
struct Atom {
|
||||
names: &'static [&'static str],
|
||||
dimension: Dimension,
|
||||
factor: f64,
|
||||
offset: f64,
|
||||
}
|
||||
|
||||
const fn atom(names: &'static [&'static str], dimension: Dimension, factor: f64) -> Atom {
|
||||
Atom { names, dimension, factor, offset: 0.0 }
|
||||
}
|
||||
|
||||
const ATOMS: &[Atom] = &[
|
||||
// Length.
|
||||
atom(&["m"], L, 1.0),
|
||||
atom(&["km"], L, 1e3),
|
||||
atom(&["cm"], L, 1e-2),
|
||||
atom(&["mm"], L, 1e-3),
|
||||
atom(&["µm", "μm", "um"], L, 1e-6),
|
||||
atom(&["nm"], L, 1e-9),
|
||||
atom(&["in", "inch"], L, INCH),
|
||||
atom(&["ft"], L, FOOT),
|
||||
// Mass.
|
||||
atom(&["kg"], M, 1.0),
|
||||
atom(&["g"], M, 1e-3),
|
||||
atom(&["mg"], M, 1e-6),
|
||||
atom(&["t", "tonne"], M, 1e3),
|
||||
atom(&["lb", "lbm"], M, POUND_MASS),
|
||||
// Time.
|
||||
atom(&["s"], T, 1.0),
|
||||
atom(&["ms"], T, 1e-3),
|
||||
atom(&["µs", "μs", "us"], T, 1e-6),
|
||||
atom(&["min"], T, 60.0),
|
||||
atom(&["h"], T, 3600.0),
|
||||
atom(&["Hz"], FREQUENCY, 1.0),
|
||||
atom(&["kHz"], FREQUENCY, 1e3),
|
||||
// Temperature: as an absolute temperature °C and °F carry their offset; inside a compound unit
|
||||
// (W/(m·°C)) or as a temperature difference they are only a step size.
|
||||
atom(&["K"], TH, 1.0),
|
||||
Atom { names: &["°C", "degC", "℃"], dimension: TH, factor: 1.0, offset: CELSIUS_ZERO },
|
||||
Atom { names: &["°F", "degF", "℉"], dimension: TH, factor: FAHRENHEIT_STEP, offset: FAHRENHEIT_ZERO },
|
||||
// Force.
|
||||
atom(&["N"], FORCE, 1.0),
|
||||
atom(&["mN"], FORCE, 1e-3),
|
||||
atom(&["kN"], FORCE, 1e3),
|
||||
atom(&["MN"], FORCE, 1e6),
|
||||
atom(&["lbf"], FORCE, POUND_FORCE),
|
||||
atom(&["kip"], FORCE, 1e3 * POUND_FORCE),
|
||||
// Pressure and stress.
|
||||
atom(&["Pa"], PRESSURE, 1.0),
|
||||
atom(&["kPa"], PRESSURE, 1e3),
|
||||
atom(&["MPa"], PRESSURE, 1e6),
|
||||
atom(&["GPa"], PRESSURE, 1e9),
|
||||
atom(&["bar"], PRESSURE, 1e5),
|
||||
atom(&["psi"], PRESSURE, POUND_FORCE / (INCH * INCH)),
|
||||
atom(&["ksi"], PRESSURE, 1e3 * POUND_FORCE / (INCH * INCH)),
|
||||
// Energy and power.
|
||||
atom(&["J"], ENERGY, 1.0),
|
||||
atom(&["mJ"], ENERGY, 1e-3),
|
||||
atom(&["kJ"], ENERGY, 1e3),
|
||||
atom(&["W"], POWER, 1.0),
|
||||
atom(&["mW"], POWER, 1e-3),
|
||||
atom(&["kW"], POWER, 1e3),
|
||||
// Dimensionless: angles and ratios.
|
||||
atom(&["rad"], ONE, 1.0),
|
||||
atom(&["deg", "°"], ONE, std::f64::consts::PI / 180.0),
|
||||
atom(&["%"], ONE, 1e-2),
|
||||
];
|
||||
|
||||
/// Longest accepted unit or quantity text; longer input is refused unparsed.
|
||||
pub(super) const MAX_TEXT: usize = 64;
|
||||
/// Largest accepted exponent in a unit expression (`m^6` is the largest anything needs).
|
||||
const MAX_POWER: i8 = 6;
|
||||
/// Deepest accepted parenthesis nesting.
|
||||
const MAX_DEPTH: usize = 4;
|
||||
|
||||
/// A unit, possibly compound, reduced to SI: `value_si = value * factor + offset`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Unit {
|
||||
symbol: String,
|
||||
dimension: Dimension,
|
||||
factor: f64,
|
||||
/// The SI value of the unit's zero: non-zero only for a lone °C or °F.
|
||||
offset: f64,
|
||||
/// Whether the unit names an angle (`rad`, `deg`, `°`): only angles accept it.
|
||||
angle: bool,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
/// Parses a unit expression: named units joined by `*`, `·`, a space or `/`, with integer
|
||||
/// powers (`^2`, `²`, `^-1`, `⁻¹`, or trailing digits as in `mm2`) and parentheses; a leading
|
||||
/// `1` is a numerator (`1/K`). `1` alone is the unit of a dimensionless quantity. `.` is not a
|
||||
/// multiplication sign (it reads as a decimal point).
|
||||
pub fn parse(text: &str) -> Result<Unit, UnitError> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(UnitError::Empty);
|
||||
}
|
||||
if text.len() > MAX_TEXT {
|
||||
return Err(UnitError::TooLong);
|
||||
}
|
||||
if text == "1" {
|
||||
return Ok(Unit { symbol: text.to_string(), dimension: Dimension::NONE, factor: 1.0, offset: 0.0, angle: false });
|
||||
}
|
||||
let mut p = Parser { chars: text.chars().collect(), pos: 0, text, angle: false };
|
||||
let (dimension, factor, atoms, offset) = p.expression(0)?;
|
||||
if p.pos != p.chars.len() {
|
||||
return Err(p.syntax());
|
||||
}
|
||||
// An offset only means something for a lone temperature unit.
|
||||
let offset = if atoms == 1 { offset } else { 0.0 };
|
||||
if !(factor.is_finite() && factor > 0.0) {
|
||||
return Err(UnitError::OutOfRange(text.to_string()));
|
||||
}
|
||||
Ok(Unit { symbol: text.to_string(), dimension, factor, offset, angle: p.angle })
|
||||
}
|
||||
|
||||
pub fn symbol(&self) -> &str {
|
||||
&self.symbol
|
||||
}
|
||||
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
|
||||
/// The size of one unit in SI (for °C and °F, the size of one degree).
|
||||
pub fn factor(&self) -> f64 {
|
||||
self.factor
|
||||
}
|
||||
|
||||
/// Whether the unit names an angle (`rad`, `deg`, `°`).
|
||||
pub fn is_angle(&self) -> bool {
|
||||
self.angle
|
||||
}
|
||||
|
||||
/// Converts a value in this unit to SI. `absolute` applies the zero offset (absolute
|
||||
/// temperatures); temperature differences and every other quantity pass `false`.
|
||||
pub(super) fn to_si(&self, value: f64, absolute: bool) -> f64 {
|
||||
value * self.factor + if absolute { self.offset } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Converts an SI value to this unit; the inverse of [`Unit::to_si`].
|
||||
pub(super) fn express(&self, si: f64, absolute: bool) -> f64 {
|
||||
(si - if absolute { self.offset } else { 0.0 }) / self.factor
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
chars: Vec<char>,
|
||||
pos: usize,
|
||||
text: &'a str,
|
||||
angle: bool,
|
||||
}
|
||||
|
||||
/// Dimension, factor, number of named units, offset of the last named unit.
|
||||
type Parsed = (Dimension, f64, usize, f64);
|
||||
|
||||
impl Parser<'_> {
|
||||
fn syntax(&self) -> UnitError {
|
||||
UnitError::Syntax(self.text.to_string())
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn skip_spaces(&mut self) -> bool {
|
||||
let start = self.pos;
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
self.pos > start
|
||||
}
|
||||
|
||||
fn expression(&mut self, depth: usize) -> Result<Parsed, UnitError> {
|
||||
self.skip_spaces();
|
||||
// A leading `1` is a numerator: `1/K`, `1/s`.
|
||||
let leading_one = self.peek() == Some('1') && !self.chars.get(self.pos + 1).is_some_and(char::is_ascii_digit);
|
||||
let (mut dim, mut factor, mut atoms, mut offset) = if leading_one {
|
||||
self.pos += 1;
|
||||
self.skip_spaces();
|
||||
if self.peek() != Some('/') {
|
||||
return Err(self.syntax());
|
||||
}
|
||||
(Dimension::NONE, 1.0, 0, 0.0)
|
||||
} else {
|
||||
self.term(depth)?
|
||||
};
|
||||
loop {
|
||||
let spaced = self.skip_spaces();
|
||||
let divide = match self.peek() {
|
||||
Some('*' | '·') => false,
|
||||
Some('/') => true,
|
||||
// A space between two units multiplies: `N mm`, `W/(m K)`.
|
||||
Some(c) if spaced && (c == '(' || c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ')) => {
|
||||
self.pos -= 1;
|
||||
false
|
||||
}
|
||||
_ => break,
|
||||
};
|
||||
self.pos += 1;
|
||||
self.skip_spaces();
|
||||
let (d, f, a, o) = self.term(depth)?;
|
||||
let overflow = || UnitError::OutOfRange(self.text.to_string());
|
||||
if divide {
|
||||
dim = dim.checked_div(d).ok_or_else(overflow)?;
|
||||
factor /= f;
|
||||
} else {
|
||||
dim = dim.checked_mul(d).ok_or_else(overflow)?;
|
||||
factor *= f;
|
||||
}
|
||||
atoms += a;
|
||||
offset = o;
|
||||
}
|
||||
Ok((dim, factor, atoms, offset))
|
||||
}
|
||||
|
||||
fn term(&mut self, depth: usize) -> Result<Parsed, UnitError> {
|
||||
let (dim, factor, atoms, offset) = if self.peek() == Some('(') {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(self.syntax());
|
||||
}
|
||||
self.pos += 1;
|
||||
let inner = self.expression(depth + 1)?;
|
||||
self.skip_spaces();
|
||||
if self.peek() != Some(')') {
|
||||
return Err(self.syntax());
|
||||
}
|
||||
self.pos += 1;
|
||||
inner
|
||||
} else {
|
||||
self.atom()?
|
||||
};
|
||||
let power = self.power()?;
|
||||
if power == 1 {
|
||||
return Ok((dim, factor, atoms, offset));
|
||||
}
|
||||
let dim = dim.checked_pow(power).ok_or_else(|| UnitError::OutOfRange(self.text.to_string()))?;
|
||||
// A powered unit is never a lone absolute temperature.
|
||||
Ok((dim, factor.powi(i32::from(power)), atoms + 1, 0.0))
|
||||
}
|
||||
|
||||
fn atom(&mut self) -> Result<Parsed, UnitError> {
|
||||
let start = self.pos;
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ') {
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.pos == start {
|
||||
return Err(self.syntax());
|
||||
}
|
||||
let name: String = self.chars.get(start..self.pos).unwrap_or_default().iter().collect();
|
||||
let atom = ATOMS.iter().find(|a| a.names.contains(&name.as_str())).ok_or_else(|| UnitError::UnknownUnit(name.clone()))?;
|
||||
self.angle |= matches!(atom.names.first(), Some(&("rad" | "deg")));
|
||||
Ok((atom.dimension, atom.factor, 1, atom.offset))
|
||||
}
|
||||
|
||||
/// An optional integer exponent; 1 when absent.
|
||||
fn power(&mut self) -> Result<i8, UnitError> {
|
||||
let mut digits = String::new();
|
||||
if self.peek() == Some('^') {
|
||||
self.pos += 1;
|
||||
if self.peek() == Some('-') {
|
||||
digits.push('-');
|
||||
self.pos += 1;
|
||||
}
|
||||
while let Some(c) = self.peek().filter(char::is_ascii_digit) {
|
||||
digits.push(c);
|
||||
self.pos += 1;
|
||||
}
|
||||
} else {
|
||||
while let Some(c) = self.peek() {
|
||||
let mapped = match c {
|
||||
'⁻' => '-',
|
||||
'¹' => '1',
|
||||
'²' => '2',
|
||||
'³' => '3',
|
||||
'⁴' => '4',
|
||||
'⁵' => '5',
|
||||
'⁶' => '6',
|
||||
d if d.is_ascii_digit() => d,
|
||||
_ => break,
|
||||
};
|
||||
digits.push(mapped);
|
||||
self.pos += 1;
|
||||
}
|
||||
if digits.is_empty() {
|
||||
return Ok(1);
|
||||
}
|
||||
}
|
||||
match digits.parse::<i8>() {
|
||||
Ok(p) if p != 0 && p.unsigned_abs() <= MAX_POWER.unsigned_abs() => Ok(p),
|
||||
_ => Err(self.syntax()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-12 * a.abs().max(b.abs())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_units_reduce_to_si() {
|
||||
let cases = [
|
||||
("t/mm³", Dimension::new(-3, 1, 0, 0), 1e12),
|
||||
("t/mm^3", Dimension::new(-3, 1, 0, 0), 1e12),
|
||||
("kg/m3", Dimension::new(-3, 1, 0, 0), 1.0),
|
||||
("N/mm²", PRESSURE, 1e6),
|
||||
("W/(m·K)", Dimension::new(1, 1, -3, -1), 1.0),
|
||||
("mW/(mm*K)", Dimension::new(1, 1, -3, -1), 1.0),
|
||||
("W/m/K", Dimension::new(1, 1, -3, -1), 1.0),
|
||||
("K^-1", Dimension::new(0, 0, 0, -1), 1.0),
|
||||
("lbf·s²/in", M, POUND_FORCE / INCH),
|
||||
("mm/s", Dimension::new(1, 0, -1, 0), 1e-3),
|
||||
("MPa·s", Dimension::new(-1, 1, -1, 0), 1e6),
|
||||
];
|
||||
for (text, dim, factor) in cases {
|
||||
let u = Unit::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"));
|
||||
assert_eq!(u.dimension(), dim, "{text}");
|
||||
assert!(close(u.factor(), factor), "{text}: {} vs {factor}", u.factor());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerators_and_spaces() {
|
||||
let k = Unit::parse("1/K").unwrap();
|
||||
assert_eq!(k.dimension(), Dimension::new(0, 0, 0, -1));
|
||||
assert_eq!(Unit::parse("1/s").unwrap().dimension(), FREQUENCY);
|
||||
assert_eq!(Unit::parse("1 / (m·K)").unwrap().dimension(), Dimension::new(-1, 0, 0, -1));
|
||||
for (spaced, plain) in
|
||||
[("N · mm", "N·mm"), ("W/(m K)", "W/(m·K)"), ("kg m^-3", "kg/m^3"), ("W / ( m * K )", "W/(m·K)"), ("lbf s² / in", "lbf·s²/in")]
|
||||
{
|
||||
let (a, b) = (Unit::parse(spaced).unwrap(), Unit::parse(plain).unwrap());
|
||||
assert_eq!(a.dimension(), b.dimension(), "{spaced}");
|
||||
assert!(close(a.factor(), b.factor()), "{spaced}");
|
||||
}
|
||||
assert!(Unit::parse("deg").unwrap().is_angle());
|
||||
assert!(Unit::parse("°").unwrap().is_angle());
|
||||
assert!(!Unit::parse("%").unwrap().is_angle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_only_for_lone_temperatures() {
|
||||
assert!(close(Unit::parse("°C").unwrap().to_si(20.0, true), 293.15));
|
||||
assert!(close(Unit::parse("°F").unwrap().to_si(32.0, true), 273.15));
|
||||
assert!(close(Unit::parse("°F").unwrap().to_si(212.0, true), 373.15));
|
||||
// In a compound unit the degree is a step.
|
||||
let k = Unit::parse("W/(m·°C)").unwrap();
|
||||
assert!(close(k.to_si(1.0, true), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_units_are_refused() {
|
||||
for text in [
|
||||
"",
|
||||
" ",
|
||||
"m^",
|
||||
"m^0",
|
||||
"m^99",
|
||||
"m^-128",
|
||||
"((((((m))))))",
|
||||
"(m",
|
||||
"m)",
|
||||
"m//s",
|
||||
"mm2x",
|
||||
"foo",
|
||||
"m^7",
|
||||
"BTU",
|
||||
"m.s",
|
||||
"1 K",
|
||||
"11/K",
|
||||
"2/K",
|
||||
"1/",
|
||||
] {
|
||||
assert!(Unit::parse(text).is_err(), "{text:?}");
|
||||
}
|
||||
assert!(matches!(Unit::parse(&"m".repeat(100)), Err(UnitError::TooLong)));
|
||||
// Exponent overflow across many factors.
|
||||
assert!(Unit::parse("(m^6)^6·(m^6)^6·(m^6)^6·(m^6)^6").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The unit table against hand-computed values.
|
||||
|
||||
use fieldcraft_model::units::{DisplaySystem, Preset, Quantity, Unit, UnitError, ValueInput};
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1e-300)
|
||||
}
|
||||
|
||||
fn si(q: Quantity, text: &str) -> f64 {
|
||||
q.parse(text, &DisplaySystem::new(Preset::Si)).unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
|
||||
fn shown(q: Quantity, si_value: f64, preset: Preset) -> f64 {
|
||||
q.express(si_value, &DisplaySystem::new(preset).unit(q).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_values() {
|
||||
use Quantity as Q;
|
||||
// E = 210 GPa = 210 000 MPa = 2.1e11 Pa.
|
||||
let e = si(Q::Pressure, "210 GPa");
|
||||
assert!(close(e, 2.1e11));
|
||||
assert!(close(si(Q::Pressure, "210000 MPa"), e));
|
||||
assert!(close(shown(Q::Pressure, e, Preset::MmTonneS), 210_000.0));
|
||||
// 1 N/mm² = 1 MPa.
|
||||
assert!(close(si(Q::Pressure, "1 N/mm²"), si(Q::Pressure, "1 MPa")));
|
||||
// Steel density 7850 kg/m³ = 7.85e-9 t/mm³.
|
||||
let rho = si(Q::Density, "7850 kg/m³");
|
||||
assert!(close(shown(Q::Density, rho, Preset::MmTonneS), 7.85e-9));
|
||||
assert!(close(si(Q::Density, "7.85e-9 t/mm^3"), 7850.0));
|
||||
// 20 °C = 293.15 K, but a difference of 20 °C is 20 K.
|
||||
assert!(close(si(Q::Temperature, "20 °C"), 293.15));
|
||||
assert!(close(si(Q::TemperatureDifference, "20 °C"), 20.0));
|
||||
assert!(close(si(Q::Temperature, "68 °F"), 293.15));
|
||||
assert!(close(si(Q::TemperatureDifference, "9 °F"), 5.0));
|
||||
assert!(close(shown(Q::Temperature, 293.15, Preset::MmTonneS), 20.0));
|
||||
assert!(close(shown(Q::Temperature, 293.15, Preset::InLbfS), 68.0));
|
||||
// Expansion 12e-6 /°C = 12e-6 /K; 6.667e-6 /°F.
|
||||
assert!(close(si(Q::ThermalExpansion, "12e-6 °C⁻¹"), 12e-6));
|
||||
assert!(close(shown(Q::ThermalExpansion, 12e-6, Preset::InLbfS), 12e-6 * 5.0 / 9.0));
|
||||
// 25 mm/s = 0.025 m/s; g = 9.81 m/s² = 9810 mm/s² = 386.22 in/s².
|
||||
assert!(close(si(Q::Velocity, "25 mm/s"), 0.025));
|
||||
assert!(close(shown(Q::Acceleration, 9.81, Preset::MmTonneS), 9810.0));
|
||||
assert!(close(shown(Q::Acceleration, 9.81, Preset::InLbfS), 9.81 / 0.0254));
|
||||
// Steel in in-lbf-s: E = 2.1e11 Pa = 30.458 Msi; ρ = 7850 kg/m³ = 7.345e-4 lbf·s²/in⁴.
|
||||
let psi = 4.448_221_615_260_5 / (0.0254 * 0.0254);
|
||||
assert!(close(shown(Q::Pressure, e, Preset::InLbfS), 2.1e11 / psi));
|
||||
assert!(close(shown(Q::Density, rho, Preset::InLbfS), 7850.0 * 0.0254 / 4.448_221_615_260_5 * 0.0254 * 0.0254 * 0.0254));
|
||||
// Steel conductivity 50 W/(m·K) is 50 mW/(mm·K); specific heat 460 J/(kg·K) is 4.6e8 mJ/(t·K).
|
||||
assert!(close(shown(Q::ThermalConductivity, 50.0, Preset::MmTonneS), 50.0));
|
||||
assert!(close(shown(Q::SpecificHeat, 460.0, Preset::MmTonneS), 4.6e8));
|
||||
// 10 W/(m²·K) = 0.01 mW/(mm²·K); 1 kW/m² = 1 mW/mm².
|
||||
assert!(close(shown(Q::HeatTransferCoefficient, 10.0, Preset::MmTonneS), 0.01));
|
||||
assert!(close(shown(Q::HeatFlux, 1000.0, Preset::MmTonneS), 1.0));
|
||||
// Water viscosity 1e-3 Pa·s = 1e-9 MPa·s; 1 N·mm = 1e-3 N·m = 1 mJ.
|
||||
assert!(close(shown(Q::DynamicViscosity, 1e-3, Preset::MmTonneS), 1e-9));
|
||||
assert!(close(si(Q::Moment, "1 N·mm"), si(Q::Energy, "1 mJ")));
|
||||
// 90 deg = π/2 rad; 5 % = 0.05.
|
||||
assert!(close(si(Q::Angle, "90 deg"), std::f64::consts::FRAC_PI_2));
|
||||
assert!(close(si(Q::Dimensionless, "5 %"), 0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_unit_has_its_quantitys_dimension() {
|
||||
for preset in Preset::ALL {
|
||||
let display = DisplaySystem::new(preset);
|
||||
for &q in Quantity::ALL {
|
||||
let unit = display.unit(q).unwrap_or_else(|e| panic!("{preset:?} {q:?}: {e}"));
|
||||
assert_eq!(unit.dimension(), q.dimension(), "{preset:?} {q:?} `{}`", unit.symbol());
|
||||
}
|
||||
assert_eq!(Preset::from_label(preset.label()), Some(preset));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mm_tonne_second_is_consistent() {
|
||||
// A consistent system: force = mass · acceleration and stress = force / area hold with no
|
||||
// factor, so the deck writers can write its numbers directly.
|
||||
let d = DisplaySystem::new(Preset::MmTonneS);
|
||||
let f = |q| d.unit(q).unwrap().factor();
|
||||
use Quantity as Q;
|
||||
assert!(close(f(Q::Force), f(Q::Mass) * f(Q::Acceleration)));
|
||||
assert!(close(f(Q::Pressure), f(Q::Force) / f(Q::Area)));
|
||||
assert!(close(f(Q::Energy), f(Q::Force) * f(Q::Length)));
|
||||
assert!(close(f(Q::Density), f(Q::Mass) / f(Q::Volume)));
|
||||
let i = DisplaySystem::new(Preset::InLbfS);
|
||||
let g = |q| i.unit(q).unwrap().factor();
|
||||
assert!(close(g(Q::Force), g(Q::Mass) * g(Q::Acceleration)));
|
||||
assert!(close(g(Q::Pressure), g(Q::Force) / g(Q::Area)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_every_preset() {
|
||||
let values = [(Quantity::Pressure, 2.1e11), (Quantity::Density, 7850.0), (Quantity::Temperature, 293.15), (Quantity::Length, 0.0254)];
|
||||
for preset in Preset::ALL {
|
||||
let d = DisplaySystem::new(preset);
|
||||
for (q, v) in values {
|
||||
let text = q.format(v, &d).unwrap();
|
||||
let back = q.parse(&text, &d).unwrap();
|
||||
assert!((back - v).abs() <= 1e-5 * v.abs(), "{preset:?} {q:?}: {text} -> {back}");
|
||||
}
|
||||
}
|
||||
let mm = DisplaySystem::default();
|
||||
assert_eq!(Quantity::Pressure.format(2.1e11, &mm).unwrap(), "210000 MPa");
|
||||
assert_eq!(Quantity::Density.format(7850.0, &mm).unwrap(), "7.85e-9 t/mm³");
|
||||
assert_eq!(Quantity::Temperature.format(293.15, &mm).unwrap(), "20 °C");
|
||||
assert_eq!(Quantity::Dimensionless.format(0.3, &mm).unwrap(), "0.3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_from_commands() {
|
||||
let mm = DisplaySystem::default();
|
||||
let parse = |json: &str| serde_json::from_str::<ValueInput>(json).unwrap().to_si(Quantity::Pressure, &mm);
|
||||
assert!(close(parse("210000").unwrap(), 2.1e11)); // bare number: display system (MPa)
|
||||
assert!(close(parse(r#""210 GPa""#).unwrap(), 2.1e11));
|
||||
assert!(close(parse(r#"{"value": 210, "unit": "GPa"}"#).unwrap(), 2.1e11));
|
||||
assert!(close(Quantity::Pressure.parse("210000", &mm).unwrap(), 2.1e11));
|
||||
assert!(serde_json::from_str::<ValueInput>(r#"{"value": 1, "unit": "Pa", "extra": 0}"#).is_err());
|
||||
let over = DisplaySystem::default().with_override(Quantity::Energy, "J").unwrap();
|
||||
assert_eq!(Quantity::Energy.format(2.0, &over).unwrap(), "2 J");
|
||||
assert!(DisplaySystem::default().with_override(Quantity::Energy, "MPa").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_are_refused() {
|
||||
let mm = DisplaySystem::default();
|
||||
use Quantity as Q;
|
||||
let wrong = Q::Pressure.parse("210 mm", &mm);
|
||||
assert!(matches!(wrong, Err(UnitError::WrongDimension { .. })), "{wrong:?}");
|
||||
assert_eq!(wrong.unwrap_err().to_string(), "`mm` is L, but a pressure is L^-1 M T^-2");
|
||||
for text in ["", "GPa", "nan GPa", "inf Pa", "1e400 Pa", "1e300 GPa", "210 GPa extra", "210 parsecs", "--1 Pa"] {
|
||||
assert!(Q::Pressure.parse(text, &mm).is_err(), "{text:?}");
|
||||
}
|
||||
assert!(Q::Density.parse("1e300 t/mm³", &mm).is_err());
|
||||
assert!(matches!(Q::Temperature.parse("-300 °C", &mm), Err(UnitError::BelowAbsoluteZero(_))));
|
||||
assert!(Q::TemperatureDifference.parse("-300 °C", &mm).is_ok());
|
||||
assert!(Q::Pressure.to_si(f64::NAN, &Unit::parse("Pa").unwrap()).is_err());
|
||||
assert!(Q::Pressure.parse(&"9".repeat(500), &mm).is_err());
|
||||
let input = ValueInput::WithUnit(fieldcraft_model::units::WithUnit { value: f64::INFINITY, unit: "Pa".into() });
|
||||
assert!(input.to_si(Q::Pressure, &mm).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angles_and_ratios_are_kept_apart() {
|
||||
let mm = DisplaySystem::default();
|
||||
use Quantity as Q;
|
||||
assert!(matches!(Q::Angle.parse("5 %", &mm), Err(UnitError::WrongKind { .. })));
|
||||
assert!(matches!(Q::Dimensionless.parse("1 deg", &mm), Err(UnitError::WrongKind { .. })));
|
||||
assert!(matches!(Q::Angle.parse("90 mm", &mm), Err(UnitError::WrongDimension { .. })));
|
||||
assert!(Q::Pressure.parse("1 MPa·deg", &mm).is_err());
|
||||
assert!(close(Q::Angle.parse("90 °", &mm).unwrap(), std::f64::consts::FRAC_PI_2));
|
||||
assert!(close(Q::Angle.parse("90", &mm).unwrap(), std::f64::consts::FRAC_PI_2)); // display: deg
|
||||
assert!(close(Q::Dimensionless.parse("0.3", &mm).unwrap(), 0.3));
|
||||
assert!(close(Q::ThermalExpansion.parse("12e-6 1/K", &mm).unwrap(), 12e-6));
|
||||
assert!(close(Q::Frequency.parse("0.01 1/s", &mm).unwrap(), 0.01));
|
||||
assert!(close(Q::ThermalConductivity.parse("50 W/(m K)", &mm).unwrap(), 50.0));
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use crate::cholesky::{Factor, FactorError, UpperColumns};
|
||||
use crate::constraints::{Budget, Reduction, coupling_rows, symmetry_rows};
|
||||
use crate::eigen::{Operator, Wanted, lanczos};
|
||||
use crate::ldlt::{Kind, Symmetric};
|
||||
use crate::ordering::nested_dissection;
|
||||
use crate::ordering::fill_reducing_order;
|
||||
use crate::sparse::{Csr, NodeGraph, add_block, add_diagonal, add_scalar};
|
||||
use crate::system::{
|
||||
Mesh, Structural, StructuralKernel, add_thermal_loads, inelastic_warnings, loads, prescribed, structural_body_forces, temperature_loads,
|
||||
@@ -311,10 +311,10 @@ pub(crate) fn pattern(mesh: &Mesh) -> (NodeGraph, Vec<usize>) {
|
||||
// The order covers the mesh nodes; reference point equations follow them.
|
||||
let n = mesh.labels.len();
|
||||
let order = if graph.adjacency.len() == n {
|
||||
nested_dissection(&mesh.xyz, &graph.adjacency)
|
||||
fill_reducing_order(&mesh.xyz, &graph.adjacency)
|
||||
} else {
|
||||
let mesh_part: Vec<Vec<usize>> = graph.adjacency.iter().take(n).map(|row| row.iter().copied().filter(|b| *b < n).collect()).collect();
|
||||
nested_dissection(&mesh.xyz, &mesh_part)
|
||||
fill_reducing_order(&mesh.xyz, &mesh_part)
|
||||
};
|
||||
(graph, order)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,11 @@ fn ereach(a: &UpperColumns, k: usize, parent: &[usize], stack: &mut [usize], mar
|
||||
top
|
||||
}
|
||||
|
||||
/// The entries of the Cholesky factor of `a`'s pattern, in its own order, without values.
|
||||
pub(crate) fn factor_entries(a: &UpperColumns) -> usize {
|
||||
symbolic(a).1.iter().sum()
|
||||
}
|
||||
|
||||
/// The elimination tree and the entry count of each column of L, without computing values.
|
||||
fn symbolic(a: &UpperColumns) -> (Vec<usize>, Vec<usize>) {
|
||||
let parent = etree(a);
|
||||
|
||||
@@ -28,7 +28,7 @@ use fieldcraft_model::{ElementKind, Load, Model, Procedure, Resolved, SectionKin
|
||||
use crate::cholesky::{Factor, FactorError, UpperColumns};
|
||||
use crate::constraints::{Budget, Reduction, Row};
|
||||
use crate::contact::thickness;
|
||||
use crate::ordering::nested_dissection;
|
||||
use crate::ordering::fill_reducing_order;
|
||||
use crate::sparse::{Csr, NodeGraph, add_block};
|
||||
use crate::system::hold;
|
||||
use crate::tie::{TieFace, TieNodes, rows_of};
|
||||
@@ -398,7 +398,7 @@ fn run(model: &Model, resolved: &Resolved, step: &Step, limits: &Limits) -> Resu
|
||||
let mut warnings = Vec::new();
|
||||
let rows = tie_rows(model, resolved, &mesh, &mut warnings)?;
|
||||
let fixed = prescribed(model, resolved, &mesh, step)?;
|
||||
let order = nested_dissection(&mesh.xyz, &graph.adjacency);
|
||||
let order = fill_reducing_order(&mesh.xyz, &graph.adjacency);
|
||||
let budget = Budget { max_bytes: limits.max_factor_entries.saturating_mul(8), pattern_entries: graph.entries(&mesh.layout), matrices: 1 };
|
||||
let eqs = Reduction::new(&mesh.layout, std::iter::empty(), &order, &rows, |_| Ok(fixed), budget)?;
|
||||
if eqs.neq() == 0 {
|
||||
|
||||
@@ -198,12 +198,23 @@ pub struct CriticalPoint {
|
||||
pub negative_after: usize,
|
||||
}
|
||||
|
||||
/// The displacements at the end of a converged increment.
|
||||
/// The displacements and reactions at the end of a converged increment.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Frame {
|
||||
pub load_factor: f64,
|
||||
/// Per node, in [`StaticResult::nodes`] order.
|
||||
pub displacement: Vec<[f64; 3]>,
|
||||
/// The reaction forces at the nodes that carry one, as (index in [`StaticResult::nodes`],
|
||||
/// force), by index; nodes left out carry none. Sparse: only supported or prescribed nodes
|
||||
/// appear, so a frame costs little beyond its displacements.
|
||||
pub reaction: Vec<(usize, [f64; 3])>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// The reaction force at the node of index `i` (zero when it carries none).
|
||||
pub fn reaction_at(&self, i: usize) -> [f64; 3] {
|
||||
self.reaction.binary_search_by_key(&i, |(k, _)| *k).ok().and_then(|k| self.reaction.get(k)).map_or([0.0; 3], |(_, f)| *f)
|
||||
}
|
||||
}
|
||||
|
||||
/// The increments of a nonlinear static step and the displacements after each converged one.
|
||||
@@ -226,7 +237,8 @@ impl History {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most displacement values the frames of one step keep (3 per node per frame).
|
||||
/// Most values the frames of one step keep (3 per node per frame for the displacements, 4 per
|
||||
/// reacted node for the reactions).
|
||||
pub const MAX_FRAME_VALUES: usize = 30_000_000;
|
||||
|
||||
/// Line search: accept a step whose residual work is at most this fraction of the first.
|
||||
@@ -1210,19 +1222,26 @@ fn solve_on(model: &Model, step: &str, limits: &Limits, notes: Vec<String>) -> R
|
||||
let mut history = History::default();
|
||||
let (mut frame_values, mut frames_full) = (0usize, false);
|
||||
let values = 3 * mesh.labels.len();
|
||||
let mut keep_frame = |history: &mut History, lambda: f64, u: &[f64], warnings: &mut Vec<String>| {
|
||||
let mut keep_frame = |history: &mut History, lambda: f64, u: &[f64], at: Option<&Evaluation>, warnings: &mut Vec<String>| {
|
||||
if frames_full {
|
||||
return;
|
||||
}
|
||||
if frame_values.saturating_add(values) > MAX_FRAME_VALUES {
|
||||
warnings.push(format!("the displacement frames stop at load factor {lambda}: the step keeps at most {MAX_FRAME_VALUES} values"));
|
||||
let reaction: Vec<(usize, [f64; 3])> = at
|
||||
.map(|e| {
|
||||
let out_of_balance: Vec<f64> = e.internal.iter().zip(&e.external).map(|(a, b)| a - b).collect();
|
||||
split(&red.reactions_of(&out_of_balance), &mesh).0.into_iter().enumerate().filter(|(_, f)| f.iter().any(|c| *c != 0.0)).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let these = values.saturating_add(reaction.len().saturating_mul(4));
|
||||
if frame_values.saturating_add(these) > MAX_FRAME_VALUES {
|
||||
warnings.push(format!("the frames stop at load factor {lambda}: the step keeps at most {MAX_FRAME_VALUES} values"));
|
||||
frames_full = true;
|
||||
return;
|
||||
}
|
||||
frame_values += values;
|
||||
history.frames.push(Frame { load_factor: lambda, displacement: split(u, &mesh).0 });
|
||||
frame_values += these;
|
||||
history.frames.push(Frame { load_factor: lambda, displacement: split(u, &mesh).0, reaction });
|
||||
};
|
||||
keep_frame(&mut history, 0.0, &vec![0.0; mesh.dofs()], &mut warnings);
|
||||
keep_frame(&mut history, 0.0, &vec![0.0; mesh.dofs()], None, &mut warnings);
|
||||
let (q, lambda, last) = if let Some(arc) = &settings.arc_length {
|
||||
if problem.field.is_some() {
|
||||
return Err(Error::NotSupported(format!("temperatures in arc-length steps (step `{}`)", step.name)));
|
||||
@@ -1298,7 +1317,7 @@ fn solve_on(model: &Model, step: &str, limits: &Limits, notes: Vec<String>) -> R
|
||||
});
|
||||
(t, lambda) = (target, target_lambda);
|
||||
q = c.q;
|
||||
keep_frame(&mut history, lambda, &red.expand_scaled(&q, lambda), &mut warnings);
|
||||
keep_frame(&mut history, lambda, &red.expand_scaled(&q, lambda), Some(&c.evaluation), &mut warnings);
|
||||
run.committed.clone_from(&c.evaluation.history);
|
||||
for (l, s) in run.contact.iter_mut().zip(&c.evaluation.contact) {
|
||||
l.commit(s);
|
||||
@@ -1633,7 +1652,7 @@ fn arc_length(
|
||||
step: &Step,
|
||||
history: &mut History,
|
||||
warnings: &mut Vec<String>,
|
||||
keep_frame: &mut dyn FnMut(&mut History, f64, &[f64], &mut Vec<String>),
|
||||
keep_frame: &mut dyn FnMut(&mut History, f64, &[f64], Option<&Evaluation>, &mut Vec<String>),
|
||||
) -> Result<(Vec<f64>, f64, Option<Evaluation>)> {
|
||||
let settings = run.settings;
|
||||
let red = run.problem.red;
|
||||
@@ -1689,7 +1708,7 @@ fn arc_length(
|
||||
(q, lambda) = (q1, lambda1);
|
||||
run.committed.clone_from(&evaluation.history);
|
||||
let u = red.expand_scaled(&q, lambda);
|
||||
keep_frame(history, lambda, &u, warnings);
|
||||
keep_frame(history, lambda, &u, Some(&evaluation), warnings);
|
||||
start = run.arc_start(&mut evaluation, &q, lambda, false).map_err(fatal)?;
|
||||
let negative = start.factor.inertia().negative;
|
||||
history.increments.push(Increment {
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
//! unstructured problems for parallel processing*, Computing Systems in Engineering 2:135–148,
|
||||
//! 1991, with vertex separators). Ties are broken by node index, so the ordering is
|
||||
//! deterministic.
|
||||
//!
|
||||
//! A plane cuts a thin curved wall (a shell meshed with solids, a bellows, a revolved part) many
|
||||
//! times, so its separators are large there. [`graph_dissection`] takes its separators from the
|
||||
//! graph instead: a middle level of the breadth-first level structure rooted at a
|
||||
//! pseudo-peripheral node (A. George, J. W. H. Liu, *An automatic nested dissection algorithm for
|
||||
//! irregular finite element problems*, SIAM J. Numer. Anal. 15(5):1053–1069, 1978; the
|
||||
//! pseudo-peripheral node of N. E. Gibbs, W. G. Poole, P. K. Stockmeyer, *An algorithm for
|
||||
//! reducing the bandwidth and profile of a sparse matrix*, SIAM J. Numer. Anal. 13(2):236–250,
|
||||
//! 1976), which cuts such a wall across its surface. [`fill_reducing_order`] computes both and
|
||||
//! keeps the one whose Cholesky factor has fewer entries; the symbolic count costs O(|L|) time
|
||||
//! and O(n + |A|) memory, small next to the numeric factorization.
|
||||
|
||||
/// Parts this small are ordered as they come.
|
||||
const LEAF: usize = 16;
|
||||
@@ -86,6 +97,189 @@ pub fn nested_dissection(xyz: &[[f64; 3]], adjacency: &[Vec<usize>]) -> Vec<usiz
|
||||
order
|
||||
}
|
||||
|
||||
/// The nested dissection order of the nodes that fills the factor less: by coordinate planes or
|
||||
/// by the graph's level structures. Ties keep the coordinate order.
|
||||
pub fn fill_reducing_order(xyz: &[[f64; 3]], adjacency: &[Vec<usize>]) -> Vec<usize> {
|
||||
let by_planes = nested_dissection(xyz, adjacency);
|
||||
if adjacency.len() <= LEAF {
|
||||
// Both order a leaf as it comes: there is no choice to make.
|
||||
return by_planes;
|
||||
}
|
||||
let by_graph = graph_dissection(adjacency);
|
||||
if fill(adjacency, &by_graph) < fill(adjacency, &by_planes) { by_graph } else { by_planes }
|
||||
}
|
||||
|
||||
/// Searches for a pseudo-peripheral node per part.
|
||||
const PERIPHERAL_SEARCHES: usize = 8;
|
||||
|
||||
/// The breadth-first level structure of the nodes with `owner == token` reached from `root`.
|
||||
/// Marks each reached node's level in `level` (which must be `usize::MAX` there on entry).
|
||||
fn level_structure(adjacency: &[Vec<usize>], root: usize, token: usize, owner: &[usize], level: &mut [usize]) -> Vec<Vec<usize>> {
|
||||
let mut out = vec![vec![root]];
|
||||
if let Some(l) = level.get_mut(root) {
|
||||
*l = 0;
|
||||
}
|
||||
loop {
|
||||
let depth = out.len();
|
||||
let mut next = Vec::new();
|
||||
for &v in out.last().into_iter().flatten() {
|
||||
for &w in adjacency.get(v).into_iter().flatten() {
|
||||
if owner.get(w) == Some(&token)
|
||||
&& let Some(l) = level.get_mut(w)
|
||||
&& *l == usize::MAX
|
||||
{
|
||||
*l = depth;
|
||||
next.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
if next.is_empty() {
|
||||
return out;
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the levels of `structure`'s nodes.
|
||||
fn clear(level: &mut [usize], structure: &[Vec<usize>]) {
|
||||
for &v in structure.iter().flatten() {
|
||||
if let Some(l) = level.get_mut(v) {
|
||||
*l = usize::MAX;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A permutation of the nodes by nested dissection on the graph alone (George & Liu 1978, see
|
||||
/// the module notes): each part's separator is the nodes of its middle level that touch the
|
||||
/// next level. Disconnected parts are ordered one after the other. Ties go by node index, so
|
||||
/// the ordering is deterministic.
|
||||
pub fn graph_dissection(adjacency: &[Vec<usize>]) -> Vec<usize> {
|
||||
let n = adjacency.len();
|
||||
let mut order = Vec::with_capacity(n);
|
||||
// The part each node is in (a token per part), and its level in the current search.
|
||||
let mut owner = vec![0usize; n];
|
||||
let mut level = vec![usize::MAX; n];
|
||||
let mut token = 0usize;
|
||||
enum Task {
|
||||
Split(Vec<usize>),
|
||||
Emit(Vec<usize>),
|
||||
}
|
||||
let mut stack = vec![Task::Split((0..n).collect())];
|
||||
while let Some(task) = stack.pop() {
|
||||
let part = match task {
|
||||
Task::Emit(sep) => {
|
||||
order.extend(sep);
|
||||
continue;
|
||||
}
|
||||
Task::Split(p) => p,
|
||||
};
|
||||
let Some(&first) = part.first() else { continue };
|
||||
if part.len() <= LEAF {
|
||||
order.extend(part);
|
||||
continue;
|
||||
}
|
||||
token += 1;
|
||||
for &v in &part {
|
||||
if let Some(o) = owner.get_mut(v) {
|
||||
*o = token;
|
||||
}
|
||||
}
|
||||
let degree = |v: usize, owner: &[usize]| adjacency.get(v).map_or(0, |row| row.iter().filter(|w| owner.get(**w) == Some(&token)).count());
|
||||
// A pseudo-peripheral root: from the first node, move to the least connected node of the
|
||||
// last level while that deepens the structure.
|
||||
let mut structure = level_structure(adjacency, first, token, &owner, &mut level);
|
||||
for _ in 0..PERIPHERAL_SEARCHES {
|
||||
let Some(candidate) = structure.last().and_then(|last| last.iter().copied().min_by_key(|&v| (degree(v, &owner), v))) else { break };
|
||||
clear(&mut level, &structure);
|
||||
let deeper = level_structure(adjacency, candidate, token, &owner, &mut level);
|
||||
if deeper.len() <= structure.len() {
|
||||
// Keep the structure found: restore its levels.
|
||||
clear(&mut level, &deeper);
|
||||
for (depth, l) in structure.iter().enumerate() {
|
||||
for &v in l {
|
||||
if let Some(x) = level.get_mut(v) {
|
||||
*x = depth;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
structure = deeper;
|
||||
}
|
||||
let reached: usize = structure.iter().map(Vec::len).sum();
|
||||
if reached < part.len() {
|
||||
// Disconnected: the reached component, then the rest.
|
||||
let rest: Vec<usize> = part.iter().copied().filter(|&v| level.get(v) == Some(&usize::MAX)).collect();
|
||||
clear(&mut level, &structure);
|
||||
let mut component: Vec<usize> = structure.into_iter().flatten().collect();
|
||||
component.sort_unstable();
|
||||
stack.push(Task::Split(rest));
|
||||
stack.push(Task::Split(component));
|
||||
continue;
|
||||
}
|
||||
if structure.len() < 3 {
|
||||
clear(&mut level, &structure);
|
||||
order.extend(part);
|
||||
continue;
|
||||
}
|
||||
// The middle level: the first at which the levels up to it hold half the part, kept
|
||||
// off the last level so both sides are non-empty.
|
||||
let mut count = 0;
|
||||
let mut middle = 1;
|
||||
for (i, l) in structure.iter().enumerate().skip(1) {
|
||||
count += l.len();
|
||||
if 2 * count >= part.len() {
|
||||
middle = i.min(structure.len() - 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let touches_next =
|
||||
|v: usize| adjacency.get(v).is_some_and(|row| row.iter().any(|&w| owner.get(w) == Some(&token) && level.get(w) == Some(&(middle + 1))));
|
||||
let mut separator = Vec::new();
|
||||
let mut before: Vec<usize> = structure.iter().take(middle).flatten().copied().collect();
|
||||
for &v in structure.get(middle).into_iter().flatten() {
|
||||
if touches_next(v) { separator.push(v) } else { before.push(v) }
|
||||
}
|
||||
let mut after: Vec<usize> = structure.iter().skip(middle + 1).flatten().copied().collect();
|
||||
clear(&mut level, &structure);
|
||||
before.sort_unstable();
|
||||
separator.sort_unstable();
|
||||
after.sort_unstable();
|
||||
// Order: before, then after, then the separator (the stack runs in reverse).
|
||||
stack.push(Task::Emit(separator));
|
||||
stack.push(Task::Split(after));
|
||||
stack.push(Task::Split(before));
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// The entries of the Cholesky factor of the node graph `adjacency` eliminated in `order` (one
|
||||
/// per node pair): the fill an ordering gives. `order` must be a permutation of the nodes.
|
||||
pub fn fill(adjacency: &[Vec<usize>], order: &[usize]) -> usize {
|
||||
let n = adjacency.len();
|
||||
let mut position = vec![usize::MAX; n];
|
||||
for (k, &v) in order.iter().enumerate() {
|
||||
if let Some(p) = position.get_mut(v) {
|
||||
*p = k;
|
||||
}
|
||||
}
|
||||
let mut ptr = Vec::with_capacity(n + 1);
|
||||
ptr.push(0);
|
||||
let mut idx = Vec::new();
|
||||
let mut rows = Vec::new();
|
||||
for (k, &v) in order.iter().enumerate() {
|
||||
rows.clear();
|
||||
rows.extend(adjacency.get(v).into_iter().flatten().filter_map(|w| position.get(*w).copied()).filter(|&r| r < k));
|
||||
rows.push(k);
|
||||
rows.sort_unstable();
|
||||
rows.dedup();
|
||||
idx.extend_from_slice(&rows);
|
||||
ptr.push(idx.len());
|
||||
}
|
||||
let val = vec![0.0; idx.len()];
|
||||
crate::cholesky::factor_entries(&crate::cholesky::UpperColumns { n: order.len(), ptr, idx, val })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -116,4 +310,170 @@ mod tests {
|
||||
assert!(last.iter().all(|v| v / m == last[0] / m) || last.iter().all(|v| v % m == last[0] % m), "{last:?}");
|
||||
assert_eq!(order, nested_dissection(&xyz, &adj), "deterministic");
|
||||
}
|
||||
|
||||
/// The node graph of a structured hex8 grid of `dims` cells (27-point stencil), with the
|
||||
/// node coordinates from `at(i, j, k)`; `wrap` closes the first index around.
|
||||
fn hex_grid(dims: [usize; 3], wrap: bool, at: impl Fn(usize, usize, usize) -> [f64; 3]) -> (Vec<[f64; 3]>, Vec<Vec<usize>>) {
|
||||
let ni = if wrap { dims[0] } else { dims[0] + 1 };
|
||||
let (nj, nk) = (dims[1] + 1, dims[2] + 1);
|
||||
let id = |i: usize, j: usize, k: usize| (i % ni) + ni * (j + nj * k);
|
||||
let mut xyz = vec![[0.0; 3]; ni * nj * nk];
|
||||
let mut adj = vec![Vec::new(); ni * nj * nk];
|
||||
for k in 0..nk {
|
||||
for j in 0..nj {
|
||||
for i in 0..ni {
|
||||
xyz[id(i, j, k)] = at(i, j, k);
|
||||
for t in 0..27 {
|
||||
let (di, dj, dk) = (t % 3, (t / 3) % 3, t / 9);
|
||||
let (a, b, c) = (i + ni + di - 1, j + dj, k + dk);
|
||||
let a = if wrap {
|
||||
a % ni
|
||||
} else if a < ni || a >= 2 * ni {
|
||||
continue;
|
||||
} else {
|
||||
a - ni
|
||||
};
|
||||
if (1..=nj).contains(&b) && (1..=nk).contains(&c) {
|
||||
adj[id(i, j, k)].push(id(a, b - 1, c - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in &mut adj {
|
||||
row.sort_unstable();
|
||||
row.dedup();
|
||||
}
|
||||
(xyz, adj)
|
||||
}
|
||||
|
||||
fn is_permutation(order: &[usize], n: usize) -> bool {
|
||||
let mut sorted = order.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted == (0..n).collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// A thin cylinder wall, two solid elements thick, 160 around and 40 high.
|
||||
fn cylinder_wall() -> (Vec<[f64; 3]>, Vec<Vec<usize>>) {
|
||||
hex_grid([160, 2, 40], true, |i, j, k| {
|
||||
let t = i as f64 / 160.0 * std::f64::consts::TAU;
|
||||
let r = 50.0 + j as f64 * 0.5;
|
||||
[r * t.cos(), r * t.sin(), k as f64 * 2.0]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_dissection_is_a_deterministic_permutation() {
|
||||
let (_, adj) = cylinder_wall();
|
||||
let order = graph_dissection(&adj);
|
||||
assert!(is_permutation(&order, adj.len()));
|
||||
assert_eq!(order, graph_dissection(&adj));
|
||||
// Two disconnected blocks and an isolated node.
|
||||
let (_, mut two) = hex_grid([6, 6, 6], false, |i, j, k| [i as f64, j as f64, k as f64]);
|
||||
let m = two.len();
|
||||
two.extend(two.clone().into_iter().map(|row| row.into_iter().map(|v| v + m).collect()));
|
||||
two.push(Vec::new());
|
||||
assert!(is_permutation(&graph_dissection(&two), 2 * m + 1));
|
||||
assert!(graph_dissection(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_graph_cuts_a_thin_curved_wall_with_less_fill() {
|
||||
let (xyz, adj) = cylinder_wall();
|
||||
let planes = fill(&adj, &nested_dissection(&xyz, &adj));
|
||||
let graph = fill(&adj, &graph_dissection(&adj));
|
||||
assert!(graph < planes, "graph {graph} planes {planes}");
|
||||
assert_eq!(fill(&adj, &fill_reducing_order(&xyz, &adj)), graph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_keeps_the_better_ordering() {
|
||||
let (xyz, adj) = hex_grid([16, 16, 16], false, |i, j, k| [i as f64, j as f64, k as f64]);
|
||||
let planes = fill(&adj, &nested_dissection(&xyz, &adj));
|
||||
let chosen = fill(&adj, &fill_reducing_order(&xyz, &adj));
|
||||
assert!(chosen <= planes, "chosen {chosen} planes {planes}");
|
||||
}
|
||||
|
||||
/// Factor entries and factor time (s) of a diagonally dominant 3-unknowns-per-node matrix on
|
||||
/// the graph `adj`, eliminated in `order`.
|
||||
fn factor_3dof(adj: &[Vec<usize>], order: &[usize]) -> (usize, f64) {
|
||||
let mut place = vec![0; adj.len()];
|
||||
for (p, &v) in order.iter().enumerate() {
|
||||
place[v] = p;
|
||||
}
|
||||
let neq = 3 * adj.len();
|
||||
let mut cols: Vec<Vec<(usize, f64)>> = vec![Vec::new(); neq];
|
||||
for (v, row) in adj.iter().enumerate() {
|
||||
for &w in row {
|
||||
for a in 0..3 {
|
||||
for b in 0..3 {
|
||||
let (r, c) = (3 * place[w] + b, 3 * place[v] + a);
|
||||
if r > c {
|
||||
continue;
|
||||
}
|
||||
let value = if v == w {
|
||||
if a == b { 30.0 } else { 0.5 }
|
||||
} else if a == b {
|
||||
-1.0
|
||||
} else {
|
||||
0.01 * ((v + w) % 3) as f64
|
||||
};
|
||||
cols[c].push((r, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ptr = vec![0];
|
||||
let (mut idx, mut val) = (Vec::new(), Vec::new());
|
||||
for mut col in cols {
|
||||
col.sort_by_key(|e| e.0);
|
||||
idx.extend(col.iter().map(|e| e.0));
|
||||
val.extend(col.iter().map(|e| e.1));
|
||||
ptr.push(idx.len());
|
||||
}
|
||||
let upper = crate::cholesky::UpperColumns { n: neq, ptr, idx, val };
|
||||
let t = std::time::Instant::now();
|
||||
let factor = crate::cholesky::Factor::new(&upper, usize::MAX).unwrap();
|
||||
(factor.entries(), t.elapsed().as_secs_f64())
|
||||
}
|
||||
|
||||
/// The measurement table of the ordering choice (fill and time per ordering).
|
||||
#[test]
|
||||
#[ignore = "measurement; run with --ignored --nocapture"]
|
||||
fn measure_orderings() {
|
||||
let cases = [
|
||||
("cylinder wall 160x2x40", cylinder_wall()),
|
||||
(
|
||||
"bellows 120x2x120",
|
||||
hex_grid([120, 2, 120], true, |i, j, k| {
|
||||
let t = i as f64 / 120.0 * std::f64::consts::TAU;
|
||||
let z = k as f64;
|
||||
// Convolutions of the wall in z, the wall thickness along the normal.
|
||||
let r = 40.0 + 12.0 * (z / 120.0 * 8.0 * std::f64::consts::TAU).sin() + j as f64 * 0.5;
|
||||
[r * t.cos(), r * t.sin(), z * 1.0]
|
||||
}),
|
||||
),
|
||||
("block 24^3", hex_grid([24, 24, 24], false, |i, j, k| [i as f64, j as f64, k as f64])),
|
||||
];
|
||||
for (name, (xyz, adj)) in cases {
|
||||
let t = std::time::Instant::now();
|
||||
let planes = nested_dissection(&xyz, &adj);
|
||||
let tp = t.elapsed();
|
||||
let t = std::time::Instant::now();
|
||||
let graph = graph_dissection(&adj);
|
||||
let tg = t.elapsed();
|
||||
let t = std::time::Instant::now();
|
||||
let (fp, fg) = (fill(&adj, &planes), fill(&adj, &graph));
|
||||
let tf = t.elapsed();
|
||||
let (ep, sp) = factor_3dof(&adj, &planes);
|
||||
let (eg, sg) = factor_3dof(&adj, &graph);
|
||||
println!(
|
||||
"| {name} | {} | {fp} / {fg} | {ep} / {eg} | {sp:.3} / {sg:.3} | {:.1} / {:.1} / {:.1} |",
|
||||
3 * adj.len(),
|
||||
tp.as_secs_f64() * 1e3,
|
||||
tg.as_secs_f64() * 1e3,
|
||||
tf.as_secs_f64() * 1e3
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,3 +545,57 @@ fn nearly_incompressible_hybrids_under_prescribed_displacement() {
|
||||
}
|
||||
assert!(indefinite > 0, "the LDLᵀ path was exercised");
|
||||
}
|
||||
|
||||
/// Every frame keeps its reactions: a linear elastic unit block (small displacements) in a
|
||||
/// nonlinear step, pulled along x by a prescribed travel δ, carries F = E λ δ on the pulled face
|
||||
/// at each load factor λ (force ∝ travel), and under a force P on that face the fixed face reacts
|
||||
/// with −λ P at every frame.
|
||||
#[test]
|
||||
fn frames_keep_their_reactions() {
|
||||
let (e, nu, delta, force) = (1000.0, 0.3, 0.01, 5.0);
|
||||
let procedure = || {
|
||||
Procedure::NonlinearStatic(NonlinearStatic {
|
||||
nonlinear_geometry: false,
|
||||
initial_increment: 0.2,
|
||||
max_increment: 0.2,
|
||||
..NonlinearStatic::default()
|
||||
})
|
||||
};
|
||||
let block = |g: &mut Grid| {
|
||||
g.define(iso(e, nu), None, full(), procedure());
|
||||
g.symmetry("x0", Symmetry::X, |x| near(x[0], 0.0));
|
||||
g.symmetry("y0", Symmetry::Y, |x| near(x[1], 0.0));
|
||||
g.symmetry("z0", Symmetry::Z, |x| near(x[2], 0.0));
|
||||
g.node_set("x1", |x| near(x[0], 1.0));
|
||||
};
|
||||
let sum = |r: &StaticResult, f: &fieldcraft_solver::nonlinear::Frame, nodes: &[u32]| -> [f64; 3] {
|
||||
nodes.iter().map(|n| f.reaction_at(at(r, *n))).fold([0.0; 3], |a, v| std::array::from_fn(|k| a[k] + v[k]))
|
||||
};
|
||||
// Displacement control.
|
||||
let mut g = grid([2, 2, 2], ElementKind::Hex8, |x| x);
|
||||
block(&mut g);
|
||||
g.bc("pull", BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(delta), None, None, None, None, None] });
|
||||
let r = solve(&g);
|
||||
let (pulled, fixed) = (g.nodes_where(|x| near(x[0], 1.0)), g.nodes_where(|x| near(x[0], 0.0)));
|
||||
let frames = &r.nonlinear.as_ref().unwrap().frames;
|
||||
assert!(frames.len() >= 6, "{} frames", frames.len());
|
||||
for f in frames {
|
||||
let (p, q) = (sum(&r, f, &pulled), sum(&r, f, &fixed));
|
||||
let want = e * f.load_factor * delta;
|
||||
assert!((p[0] - want).abs() < 1e-8 * e * delta && (p[0] + q[0]).abs() < 1e-8 * e * delta, "λ {}: {p:?} {q:?} vs {want}", f.load_factor);
|
||||
// The travel of the pulled face follows the load factor.
|
||||
assert!(pulled.iter().all(|n| (f.displacement[at(&r, *n)][0] - f.load_factor * delta).abs() < 1e-12));
|
||||
}
|
||||
let last = frames.last().unwrap();
|
||||
assert!((sum(&r, last, &pulled)[0] - pulled.iter().map(|n| r.reaction[at(&r, *n)][0]).sum::<f64>()).abs() < 1e-9);
|
||||
// Load control.
|
||||
let mut g = grid([2, 2, 2], ElementKind::Hex8, |x| x);
|
||||
block(&mut g);
|
||||
g.load("push", Load::Force { target: Target::Set("x1".into()), vector: [force, 0.0, 0.0], distribution: Distribution::Total });
|
||||
let r = solve(&g);
|
||||
let fixed = g.nodes_where(|x| near(x[0], 0.0));
|
||||
for f in &r.nonlinear.as_ref().unwrap().frames {
|
||||
let q = sum(&r, f, &fixed);
|
||||
assert!((q[0] + f.load_factor * force).abs() < 1e-8 * force, "λ {}: {q:?}", f.load_factor);
|
||||
}
|
||||
}
|
||||
+125
-13
@@ -486,11 +486,14 @@ fn print_summary(rows: &[Row]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A number with five significant digits.
|
||||
/// A number with five significant digits. The notation follows the rounded value, so a number a
|
||||
/// rounding error below a decade (0.000999999999) prints like the decade itself (0.001).
|
||||
pub fn number(x: f64) -> String {
|
||||
if x == 0.0 {
|
||||
return "0".into();
|
||||
if x == 0.0 || !x.is_finite() {
|
||||
return if x == 0.0 { "0".into() } else { x.to_string() };
|
||||
}
|
||||
// Round to five significant digits first; `{:e}` output always parses back.
|
||||
let x = format!("{x:.4e}").parse::<f64>().unwrap_or(x);
|
||||
let a = x.abs();
|
||||
if (1e-3..1e5).contains(&a) {
|
||||
let digits = 4 - a.log10().floor() as i32;
|
||||
@@ -551,19 +554,59 @@ fn page_error(r: &Row) -> String {
|
||||
if r.quantity.frozen { error_of(r) } else { format!("within {}", r.quantity.tolerance.describe()) }
|
||||
}
|
||||
|
||||
/// The error against the reference: relative where the reference is not zero. Errors inside the
|
||||
/// fixture band are rounding and are shown as a bound.
|
||||
/// The error against the reference: relative where the reference is not zero.
|
||||
///
|
||||
/// A frozen result may move by its fixture band (`fixture_relative` of its size) without being a
|
||||
/// change, so digits of the error at that scale are rounding noise and must not reach
|
||||
/// docs/oracle.md. The rule: the page's floor is 1000 bands (rounded up to a power of ten; the
|
||||
/// band's own size, not the result, sets it, so the floor cannot move with the result); an error
|
||||
/// under the floor is shown as that bound ("< 1e-4%" for the usual 10⁻⁹ band) and a larger one is
|
||||
/// rounded to a tenth of the floor. A quantity whose reference is zero keeps the "≈ 0" rule of
|
||||
/// [`is_noise`].
|
||||
fn error_of(r: &Row) -> String {
|
||||
let (Some(ours), Some(reference)) = (r.ours, r.quantity.reference) else { return "—".into() };
|
||||
let d = ours - reference;
|
||||
if reference != 0.0 && r.quantity.tolerance.relative > 0.0 {
|
||||
let rel = d / reference.abs();
|
||||
if rel.abs() < FIXTURE_RELATIVE { "< 1e-7%".into() } else { percent(rel) }
|
||||
} else if is_noise(r, d) || d == 0.0 {
|
||||
"≈ 0".into()
|
||||
} else {
|
||||
format!("{d:.1e}")
|
||||
if reference == 0.0 {
|
||||
return if is_noise(r, d) || d == 0.0 { "≈ 0".into() } else { format!("{d:.1e}") };
|
||||
}
|
||||
let band = r.quantity.fixture_relative;
|
||||
if r.quantity.tolerance.relative > 0.0 {
|
||||
match banded(d / reference.abs(), band) {
|
||||
Banded::Below(bound) => format!("< {}%", decade(bound * 100.0)),
|
||||
Banded::Error(rel) => percent(rel),
|
||||
}
|
||||
} else {
|
||||
match banded(d, band * reference.abs()) {
|
||||
Banded::Below(bound) => format!("< {}", decade(bound)),
|
||||
Banded::Error(d) => format!("{d:.1e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An error as [`error_of`] shows it.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Banded {
|
||||
/// Smaller than this bound: below the page's floor.
|
||||
Below(f64),
|
||||
/// Rounded to the page's resolution.
|
||||
Error(f64),
|
||||
}
|
||||
|
||||
/// `error` against the floor of `band` (both in the same unit); see [`error_of`].
|
||||
fn banded(error: f64, band: f64) -> Banded {
|
||||
if !(band > 0.0 && band.is_finite() && error.is_finite()) {
|
||||
return Banded::Error(error);
|
||||
}
|
||||
// The slack keeps 1000 · 1e-9 (which is 1.0000000000000002e-6) on its own decade.
|
||||
let exponent = ((1000.0 * band).log10() - 1e-9).ceil() as i32;
|
||||
let bound = 10f64.powi(exponent);
|
||||
let step = 10f64.powi(exponent - 1);
|
||||
if error.abs() < bound { Banded::Below(bound) } else { Banded::Error((error / step).round() * step) }
|
||||
}
|
||||
|
||||
/// A power of ten as the page writes a bound: "1e-7" when small, a plain number otherwise.
|
||||
fn decade(x: f64) -> String {
|
||||
if x < 1e-3 { format!("{x:.0e}") } else { number(x) }
|
||||
}
|
||||
|
||||
fn verdict_text(v: &Verdict) -> &'static str {
|
||||
@@ -616,7 +659,8 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
Every case is our own model, built with FieldCraft commands from the problem's published description, and compared with \
|
||||
the reference value its `expected.json` cites, within the stated tolerance and for the stated reason. Accepted results are \
|
||||
frozen in each case's `fixture.json`: a later change that moves one beyond its band (10⁻⁹ of its size) fails CI even while \
|
||||
the reference still passes. No reference value was produced with a commercial product.\n\n\
|
||||
the reference still passes. Errors are rounded to 100 bands and an error under 1000 bands \
|
||||
(rounded up to a power of ten) is shown as that bound: digits at the band's scale are rounding noise. No reference value was produced with a commercial product.\n\n\
|
||||
**{passing} of {quantities} quantities pass** ({known} known failure(s)) in {} active cases: {} theory, {} published \
|
||||
benchmark, {} experiment; {} pending cases wait for capabilities still being built.\n\n",
|
||||
active.len(),
|
||||
@@ -786,4 +830,72 @@ mod tests {
|
||||
assert_eq!(percent(0.02), "2%");
|
||||
assert_eq!(fraction(1e-9), "1e-9 rel.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_take_the_notation_of_their_rounded_value() {
|
||||
assert_eq!(number(0.000999999999), "0.001");
|
||||
assert_eq!(number(0.001), "0.001");
|
||||
assert_eq!(number(-0.000999999999), "-0.001");
|
||||
assert_eq!(number(99999.99999), "1.0000e5");
|
||||
assert_eq!(number(1.0e5), "1.0000e5");
|
||||
assert_eq!(number(3.48751e-4), "3.4875e-4");
|
||||
assert_eq!(number(1.234567e9), "1.2346e9");
|
||||
assert_eq!(number(-2.5e-12), "-2.5000e-12");
|
||||
}
|
||||
|
||||
fn row(reference: f64, tolerance: Tolerance, ours: f64) -> Row {
|
||||
Row {
|
||||
case: "c".into(),
|
||||
quantity: quantity(reference, tolerance, None),
|
||||
ours: Some(ours),
|
||||
verdict: Verdict::Pass,
|
||||
fixture: FixtureCheck::Matches,
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors at the scale of the fixture band are noise: they print as the page's bound.
|
||||
#[test]
|
||||
fn noise_errors_print_as_a_bound() {
|
||||
let rel = Tolerance { relative: 0.001, absolute: 0.0 };
|
||||
assert_eq!(error_of(&row(12572.0, rel, 12572.0 * (1.0 + 1.78e-8))), "< 1e-4%");
|
||||
assert_eq!(error_of(&row(12572.0, rel, 12572.0 * (1.0 + 1.74e-8))), "< 1e-4%");
|
||||
assert_eq!(error_of(&row(1.0, rel, 1.0)), "< 1e-4%");
|
||||
let abs = Tolerance { relative: 0.0, absolute: 1e-9 };
|
||||
assert_eq!(error_of(&row(-0.011149, abs, -0.011149 - 1.2e-16)), "< 1e-7");
|
||||
assert_eq!(error_of(&row(-0.011149, abs, -0.011149 - 1.4e-16)), "< 1e-7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_errors_still_print() {
|
||||
let rel = Tolerance { relative: 0.02, absolute: 0.0 };
|
||||
assert_eq!(error_of(&row(1.0, rel, 0.99183)), "-0.817%");
|
||||
assert_eq!(error_of(&row(0.024355, rel, 0.024357)), "0.00821%");
|
||||
let abs = Tolerance { relative: 0.0, absolute: 0.1 };
|
||||
assert_eq!(error_of(&row(0.21104, abs, 0.21895)), "7.9e-3");
|
||||
}
|
||||
|
||||
/// The page must not change when a result moves by a few ulps (summation order, platform maths).
|
||||
#[test]
|
||||
fn the_page_ignores_a_few_ulps() {
|
||||
let rel = Tolerance { relative: 0.01, absolute: 0.0 };
|
||||
let abs = Tolerance { relative: 0.0, absolute: 1e-9 };
|
||||
let cases = [
|
||||
(0.001, rel, 0.000999999999),
|
||||
(12572.0, rel, 12572.000223),
|
||||
(-0.011149, abs, -0.011149 - 1.2e-16),
|
||||
(0.0, abs, 3e-17),
|
||||
(1.0, rel, 0.99183),
|
||||
(3.4875e-4, rel, 3.4875e-4 * (1.0 - 6.65e-9)),
|
||||
(210000.0, rel, 209999.99999),
|
||||
];
|
||||
for (reference, tolerance, ours) in cases {
|
||||
let base = row(reference, tolerance, ours);
|
||||
let page = (number(reference), ours_text(&base), error_of(&base));
|
||||
for ulps in [-4i64, -1, 1, 4] {
|
||||
let moved = f64::from_bits((ours.to_bits() as i64 + ulps) as u64);
|
||||
let r = row(reference, tolerance, moved);
|
||||
assert_eq!((number(reference), ours_text(&r), error_of(&r)), page, "{ours} moved by {ulps} ulps");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,8 @@ pub struct Viewport {
|
||||
pub struct XyPlot {
|
||||
/// `results.xy`'s `y`; a nodal field's `node` is filled from [`XyPlot::node`].
|
||||
pub y: Option<Value>,
|
||||
/// `results.xy`'s `x` when it is a travel (`None`: the step's own axis).
|
||||
pub x: Option<Value>,
|
||||
pub node: Option<u32>,
|
||||
pub key: Option<(String, usize, Value)>,
|
||||
pub series: Option<Value>,
|
||||
@@ -686,6 +688,7 @@ impl ModelApp {
|
||||
xy.node = node.as_u64().and_then(|n| u32::try_from(n).ok());
|
||||
}
|
||||
xy.y = Some(template);
|
||||
xy.x = params.get("x").filter(|x| x.is_object()).cloned();
|
||||
}
|
||||
}
|
||||
// What the duplicate check found is selected, so the viewport shows it (#234).
|
||||
@@ -3091,6 +3094,27 @@ mod tests {
|
||||
assert_eq!(s["x"]["axis"], "mode");
|
||||
}
|
||||
|
||||
/// A nonlinear static step's XY plot offers the force–travel curve of each boundary-condition
|
||||
/// set (#204): picked, the panel runs results.xy with the set's travel as x.
|
||||
#[test]
|
||||
fn the_xy_plot_offers_force_travel_curves() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.open_sample("rubber-seal");
|
||||
app.run("solve.run", json!({})).unwrap();
|
||||
app.start("results.xy").unwrap();
|
||||
draw(&mut app, &ctx, 1280.0, 800.0);
|
||||
let result = app.session.latest_result("Squeeze").unwrap().0.clone();
|
||||
let options = crate::results_panel::xy_options(&app.session, &result);
|
||||
let (_, y, x) = options.iter().find(|(l, ..)| l == "Force–travel Z: top").expect("the top's force–travel curve").clone();
|
||||
let xy = app.xy.as_mut().unwrap();
|
||||
(xy.y, xy.x) = (Some(y), x);
|
||||
draw(&mut app, &ctx, 1280.0, 800.0);
|
||||
let s = app.xy.as_ref().and_then(|x| x.series.clone()).expect("the panel ran results.xy");
|
||||
assert_eq!(s["x"]["axis"], "displacement", "{s}");
|
||||
assert!(s["points"].as_u64().unwrap_or(0) > 5, "{s}");
|
||||
}
|
||||
|
||||
fn is_iso(cam: &Camera) -> bool {
|
||||
let (yaw, pitch) = StandardView::Iso.angles();
|
||||
(cam.yaw - yaw).abs() < 1e-9 && (cam.pitch - pitch).abs() < 1e-9
|
||||
|
||||
@@ -718,32 +718,52 @@ fn line_plot(ui: &mut egui::Ui, lines: &[Line], x_name: &str, x_unit: Option<&st
|
||||
(response, axes)
|
||||
}
|
||||
|
||||
/// The quantities the XY plot offers for a result, as (label, `results.xy`'s `y`); a nodal
|
||||
/// field's `node` is filled in from the probed node.
|
||||
pub fn xy_options(r: &fieldcraft_engine::StepResult) -> Vec<(String, Value)> {
|
||||
/// The quantities the XY plot offers for a result, as (label, `results.xy`'s `y`, its `x` when
|
||||
/// not the step's own axis); a nodal field's `node` is filled in from the probed node. A
|
||||
/// nonlinear static step also offers the reaction on each of its boundary-condition sets,
|
||||
/// against the load factor and against the set's mean travel (a force–travel curve); all of
|
||||
/// them (the picker scrolls).
|
||||
pub fn xy_options(s: &fieldcraft_engine::Session, r: &fieldcraft_engine::StepResult) -> Vec<(String, Value, Option<Value>)> {
|
||||
use fieldcraft_engine::cmd::xy;
|
||||
let mut out = Vec::new();
|
||||
if let Kind::Frequency(f) = r.kind() {
|
||||
out.push(if f.buckling.is_some() {
|
||||
("Buckling load factor".to_string(), json!("load_factor"))
|
||||
("Buckling load factor".to_string(), json!("load_factor"), None)
|
||||
} else {
|
||||
("Frequency".to_string(), json!("frequency"))
|
||||
("Frequency".to_string(), json!("frequency"), None)
|
||||
});
|
||||
}
|
||||
if let Kind::Explicit(e) = r.kind() {
|
||||
out.extend(xy::ENERGIES.iter().map(|n| (format!("Energy: {}", n.replace('_', " ")), json!({"energy": n}))));
|
||||
out.extend(xy::HISTORIES.iter().map(|n| (n.replace('_', " "), json!({"history": n}))));
|
||||
out.extend(xy::ENERGIES.iter().map(|n| (format!("Energy: {}", n.replace('_', " ")), json!({"energy": n}), None)));
|
||||
out.extend(xy::HISTORIES.iter().map(|n| (n.replace('_', " "), json!({"history": n}), None)));
|
||||
for node in e.history.nodes.iter().take(8) {
|
||||
out.extend(
|
||||
xy::NODE_QUANTITIES
|
||||
.iter()
|
||||
.map(|q| (format!("History node {node}: {}", q.replace('_', " ")), json!({"history_node": node, "quantity": q}))),
|
||||
.map(|q| (format!("History node {node}: {}", q.replace('_', " ")), json!({"history_node": node, "quantity": q}), None)),
|
||||
);
|
||||
}
|
||||
}
|
||||
let linear = matches!(r.kind(), Kind::Static(s) if s.nonlinear.is_none());
|
||||
let linear = matches!(r.kind(), Kind::Static(st) if st.nonlinear.is_none());
|
||||
if !linear {
|
||||
out.extend(xy::plottable(r).into_iter().map(|f| (format!("{} at a node", f.label()), json!({"field": f.id()}))));
|
||||
out.extend(xy::plottable(r).into_iter().map(|f| (format!("{} at a node", f.label()), json!({"field": f.id()}), None)));
|
||||
}
|
||||
if matches!(r.kind(), Kind::Static(st) if st.nonlinear.is_some()) {
|
||||
let mut sets: Vec<&str> = s
|
||||
.model
|
||||
.step(r.step())
|
||||
.map(|st| st.boundary_conditions.iter().filter_map(|bc| s.model.boundary_condition(bc)?.target().set()).collect())
|
||||
.unwrap_or_default();
|
||||
sets.sort_unstable();
|
||||
sets.dedup();
|
||||
for set in sets {
|
||||
for c in ["x", "y", "z"] {
|
||||
let upper = c.to_uppercase();
|
||||
let y = json!({"reaction": set, "component": c});
|
||||
out.push((format!("Force–travel {upper}: {set}"), y.clone(), Some(json!({"displacement": set, "component": c}))));
|
||||
out.push((format!("Reaction {upper} on {set}"), y, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -764,7 +784,7 @@ fn xy_section(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens, ru
|
||||
app.xy = None;
|
||||
return None;
|
||||
}
|
||||
let options = xy_options(&plot.result);
|
||||
let options = xy_options(&app.session, &plot.result);
|
||||
if options.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("A linear static step has one state: plot a nonlinear static, explicit, heat or frequency step").small().color(t.text_dim),
|
||||
@@ -776,17 +796,23 @@ fn xy_section(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens, ru
|
||||
if probed.is_some() {
|
||||
xy.node = probed;
|
||||
}
|
||||
let template = xy.y.clone().filter(|y| options.iter().any(|(_, o)| o == y)).unwrap_or_else(|| options[0].1.clone());
|
||||
let shown = options.iter().find(|(_, o)| *o == template).map_or("", |(l, _)| l.as_str());
|
||||
let mut picked = template.clone();
|
||||
let current = (xy.y.clone(), xy.x.clone());
|
||||
let template = options
|
||||
.iter()
|
||||
.find(|(_, y, x)| current.0.as_ref() == Some(y) && current.1 == *x)
|
||||
.or(options.first())
|
||||
.map(|(_, y, x)| (y.clone(), x.clone()))
|
||||
.unwrap_or_default();
|
||||
let shown = options.iter().find(|(_, y, x)| (y, x) == (&template.0, &template.1)).map_or("", |(l, ..)| l.as_str());
|
||||
let (mut picked, mut against) = template.clone();
|
||||
egui::ComboBox::from_id_salt("fc_results_xy").selected_text(shown).width(ui.available_width() - 8.0).truncate().show_ui(ui, |ui| {
|
||||
for (label, y) in &options {
|
||||
if ui.selectable_label(*y == template, label).clicked() {
|
||||
picked = y.clone();
|
||||
for (label, y, x) in &options {
|
||||
if ui.selectable_label((y, x) == (&template.0, &template.1), label).clicked() {
|
||||
(picked, against) = (y.clone(), x.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
xy.y = Some(picked.clone());
|
||||
(xy.y, xy.x) = (Some(picked.clone()), against.clone());
|
||||
let y = if picked.get("field").is_some() {
|
||||
let Some(node) = xy.node else {
|
||||
(xy.key, xy.series) = (None, None);
|
||||
@@ -799,11 +825,15 @@ fn xy_section(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens, ru
|
||||
} else {
|
||||
picked
|
||||
};
|
||||
let key = (step.clone(), Arc::as_ptr(&plot.result) as usize, y.clone());
|
||||
let mut params = json!({"step": step, "y": y});
|
||||
if let Some(x) = against {
|
||||
params["x"] = x;
|
||||
}
|
||||
let key = (step.clone(), Arc::as_ptr(&plot.result) as usize, params.clone());
|
||||
if xy.key.as_ref() != Some(&key) {
|
||||
xy.key = Some(key);
|
||||
xy.series = None;
|
||||
run.push(("results.xy", json!({"step": step, "y": y})));
|
||||
run.push(("results.xy", params));
|
||||
return None;
|
||||
}
|
||||
let s = xy.series.clone().filter(|s| s["step"] == step.as_str())?;
|
||||
@@ -825,9 +855,14 @@ fn xy_section(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens, ru
|
||||
let x_name = match axis {
|
||||
"load_factor" => "λ",
|
||||
"time" => "t",
|
||||
"displacement" => "u",
|
||||
_ => "mode",
|
||||
};
|
||||
let x_unit = (axis == "time").then(|| Quantity::Time.unit(plot.units)).flatten();
|
||||
let x_unit = match axis {
|
||||
"time" => Quantity::Time.unit(plot.units),
|
||||
"displacement" => Quantity::Length.unit(plot.units),
|
||||
_ => None,
|
||||
};
|
||||
let line = Line { color: t.accent, points: xs.iter().copied().zip(ys.iter().copied()).collect() };
|
||||
let (response, axes) = line_plot(ui, &[line], x_name, x_unit.as_deref(), current.and_then(|i| xs.get(i).copied()), t);
|
||||
let x_label = s["x"]["label"].as_str().unwrap_or_default().to_string();
|
||||
@@ -851,7 +886,7 @@ fn xy_section(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens, ru
|
||||
let at = |v: &[f64]| v.get(i).map_or("—".into(), |x| number(*x));
|
||||
ui.label(RichText::new(format!("{x_label} {}: {}", at(&xs), at(&ys))).small().color(t.text_dim));
|
||||
}
|
||||
ui.button("Export CSV…").on_hover_text("Write the series to a CSV file (results.xy)").clicked().then(|| json!({"step": step, "y": y}))
|
||||
ui.button("Export CSV…").on_hover_text("Write the series to a CSV file (results.xy)").clicked().then_some(params)
|
||||
}
|
||||
|
||||
impl ModelApp {
|
||||
|
||||
@@ -34,6 +34,7 @@ crates in strictly lower layers.
|
||||
| L6 | `fieldcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts; every file a command names checked against the session's roots (`Session::file_access`); headless pictures of the session (`view`: model, geometry, deformed shape and contours on the CPU rasterizer). | started |
|
||||
| L7 | `fieldcraft-gpu` | The viewport's GPU pipelines (lit faces, wide lines, MSAA, depth) without a window toolkit, the adapter policy (`FIELDCRAFT_GPU`: hardware GPU, software GPU, CPU rasterizer) and offscreen pictures (`fieldcraft-cli snapshot`). The window draws through the same pipelines. | started |
|
||||
| L7 | `fieldcraft-lbm-gpu` | The lattice Boltzmann solver's GPU backend: the CPU scheme's step (TRT collision with Guo forcing, the per-link wall rules, Zou–He boundaries, momentum exchange, and the temperature lattice with its wall conditions, buoyancy and heat exchange) as wgpu compute shaders, driven through `lbm`'s `Backend` trait from the flow the CPU prepared; single precision, one population buffer streamed in place (the AA pattern, `docs/lbm-gpu-performance.md`), deterministic per device, checked against the CPU within its tests' tolerances; opt-in per solve (`solve.fluid` with `backend: "gpu"`). Opens its device with `gpu`'s adapter policy. | started |
|
||||
| L7 | `fieldcraft-mesh-gpu` | Projection smoothing's GPU device: the coloured Winslow sweeps' interior nodes (Winslow's update or the neighbour mean, the guarded step on the scaled Jacobian and edge ratio around the node) as a wgpu compute shader, driven colour by colour through `mesh`'s sweep device while the host moves the boundary nodes on the geometry; single precision about the mesh's centre, deterministic per device, the same verdicts as the CPU's coloured sweeps with a smallest scaled Jacobian within 0.02 of theirs; opt-in per projection (`mesh.hex.project` with `backend: "gpu"`). Opens its device with `gpu`'s adapter policy. | started |
|
||||
| L7 | `fieldcraft-ui-egui`, `fieldcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server over stdio (docs/mcp.md): tools over engine commands, a headless session or the running app through its control channel, read and write roots. | ui, mcp started |
|
||||
| testkit | `fieldcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started |
|
||||
| apps | `fieldcraft`, `fieldcraft-cli`, `fieldcraft-web` | Desktop, command line, browser (docs/web.md). | started |
|
||||
|
||||
+148
-145
@@ -1,8 +1,8 @@
|
||||
# Verification and validation oracle
|
||||
|
||||
Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the policy is [verification.md](verification.md). Every case is our own model, built with FieldCraft commands from the problem's published description, and compared with the reference value its `expected.json` cites, within the stated tolerance and for the stated reason. Accepted results are frozen in each case's `fixture.json`: a later change that moves one beyond its band (10⁻⁹ of its size) fails CI even while the reference still passes. No reference value was produced with a commercial product.
|
||||
Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the policy is [verification.md](verification.md). Every case is our own model, built with FieldCraft commands from the problem's published description, and compared with the reference value its `expected.json` cites, within the stated tolerance and for the stated reason. Accepted results are frozen in each case's `fixture.json`: a later change that moves one beyond its band (10⁻⁹ of its size) fails CI even while the reference still passes. Errors are rounded to 100 bands and an error under 1000 bands (rounded up to a power of ten) is shown as that bound: digits at the band's scale are rounding noise. No reference value was produced with a commercial product.
|
||||
|
||||
**282 of 289 quantities pass** (7 known failure(s)) in 77 active cases: 62 theory, 15 published benchmark, 0 experiment; 8 pending cases wait for capabilities still being built.
|
||||
**283 of 291 quantities pass** (8 known failure(s)) in 78 active cases: 62 theory, 16 published benchmark, 0 experiment; 8 pending cases wait for capabilities still being built.
|
||||
|
||||
## Theory: closed-form solutions, patch tests and mesh invariants
|
||||
|
||||
@@ -10,7 +10,7 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| [arc-shallow-arch](../tests/oracle/arc-shallow-arch/) | upper limit load factor (snap-through) | 1 | 0.99183 | -0.817% | 2% | pass | matches |
|
||||
| [arc-shallow-arch](../tests/oracle/arc-shallow-arch/) | lower limit load factor | 0.21104 | 0.21895 | 7.9e-3 | ± 0.02 | pass | matches |
|
||||
| [arc-shallow-arch](../tests/oracle/arc-shallow-arch/) | negative eigenvalues of the tangent past the upper limit point | 1 | 1 | ≈ 0 | exact | pass | matches |
|
||||
| [arc-shallow-arch](../tests/oracle/arc-shallow-arch/) | negative eigenvalues of the tangent past the upper limit point | 1 | 1 | < 1e-6 | exact | pass | matches |
|
||||
| [bar-pressure-wave](../tests/oracle/bar-pressure-wave/) | displacement of the loaded end at t = 0.06 | -6.0000e-4 | -5.9980e-4 | 0.034% | 1% | pass | matches |
|
||||
| [bar-pressure-wave](../tests/oracle/bar-pressure-wave/) | particle velocity at x = 5, 20 elements behind the front, at t = 0.06 | -0.01 | -0.0098552 | 1.45% | 2% | pass | matches |
|
||||
| [bar-pressure-wave](../tests/oracle/bar-pressure-wave/) | energy balance error | 0 | 0.001527 | 1.5e-3 | ± 0.01 | pass | matches |
|
||||
@@ -24,14 +24,14 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
| [cantilever-frequencies](../tests/oracle/cantilever-frequencies/) | the other first bending frequency | 0.012771 | 0.012751 | -0.158% | 1% | pass | matches |
|
||||
| [cantilever-frequencies](../tests/oracle/cantilever-frequencies/) | second bending frequency | 0.080034 | 0.079167 | -1.08% | 2% | pass | matches |
|
||||
| [cantilever-hex20](../tests/oracle/cantilever-hex20/) | tip deflection u_z at (10, 0.5, 0.5) | -4.024 | -4.0235 | 0.0131% | 0.5% | pass | matches |
|
||||
| [cantilever-hex20](../tests/oracle/cantilever-hex20/) | total reaction z | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [cantilever-hex20](../tests/oracle/cantilever-hex20/) | total reaction z | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [cantilever-hex8-incompatible](../tests/oracle/cantilever-hex8-incompatible/) | tip deflection u_z at (10, 0.5, 0.5) | -4.024 | -4.0175 | 0.162% | 0.5% | pass | matches |
|
||||
| [cantilever-hex8-incompatible](../tests/oracle/cantilever-hex8-incompatible/) | total reaction z | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [cantilever-hex8-incompatible](../tests/oracle/cantilever-hex8-incompatible/) | total reaction z | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [cantilever-tet10](../tests/oracle/cantilever-tet10/) | tip deflection u_z at (10, 0.5, 0.5) | -4.024 | -4.0233 | 0.018% | 0.5% | pass | matches |
|
||||
| [cantilever-tet10](../tests/oracle/cantilever-tet10/) | total reaction z | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [cantilever-tet10](../tests/oracle/cantilever-tet10/) | total reaction z | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [cdt-cylinder](../tests/oracle/cdt-cylinder/) | volume of the tetrahedra | 6267.5 | 6266.5 | -0.0152% | 0.25% | pass | matches |
|
||||
| [cdt-cylinder](../tests/oracle/cdt-cylinder/) | elements that fail the certified Jacobian check | 0 | 0 | ≈ 0 | exact | pass | matches |
|
||||
| [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | volume of the tetrahedra | 47000 | 47000 | < 1e-7% | 1e-12 rel. | pass | matches |
|
||||
| [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | volume of the tetrahedra | 47000 | 47000 | < 1e-4% | 1e-12 rel. | pass | matches |
|
||||
| [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | elements that fail the certified Jacobian check | 0 | 0 | ≈ 0 | exact | pass | matches |
|
||||
| [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | elements the Jacobian check could not decide | 0 | 0 | ≈ 0 | exact | pass | matches |
|
||||
| [explicit-bars-collision](../tests/oracle/explicit-bars-collision/) | time of the last contact (the bars touch from t = 0) against 2L/c = 0.2 | 0.2 | 0.20242 | 1.21% | 2% | pass | matches |
|
||||
@@ -54,76 +54,76 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
| [explicit-sliding-block-wall](../tests/oracle/explicit-sliding-block-wall/) | velocity of the top centre node after the stop | 0 | ≈ 0 | ≈ 0 | ± 0.03 | pass | matches |
|
||||
| [explicit-sliding-block-wall](../tests/oracle/explicit-sliding-block-wall/) | friction dissipation over ½mv₀² = 2.25 | 1 | 1.0012 | 0.121% | 1% | pass | matches |
|
||||
| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | mesh-dependent | within 3% | 3% | pass | — |
|
||||
| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | total reaction x (N) | -2000 | -2000 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | total reaction x (N) | -2000 | -2000 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | mesh-dependent | within 5% | 5% | pass | — |
|
||||
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | total reaction x (N) | -2000 | -2000 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | total reaction x (N) | -2000 | -2000 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | sixth frequency (the last rigid-body mode) | 0 | ≈ 0 | ≈ 0 | ± 8.1264e-5 | pass | matches |
|
||||
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | first free–free bending frequency (mode 7) | 0.081264 | 0.080618 | -0.795% | 1.5% | pass | matches |
|
||||
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | total mass | 20 | 20 | < 1e-7% | 1e-12 rel. | pass | matches |
|
||||
| [j2-bauschinger](../tests/oracle/j2-bauschinger/) | axial stress back at zero strain after a pull to ε_a = 0.01 | -247.52 | -247.52 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [j2-bauschinger](../tests/oracle/j2-bauschinger/) | accumulated equivalent plastic strain | 0.016089 | 0.016089 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [j2-heated-bar](../tests/oracle/j2-heated-bar/) | axial stress, ΔT = 300 (α ΔT = 3.6·10⁻³, 2.9 times the yield strain) | -254.65 | -254.65 | < 1e-7% | 1e-8 rel. | pass | matches |
|
||||
| [j2-heated-bar](../tests/oracle/j2-heated-bar/) | equivalent plastic strain | 0.0023267 | 0.0023267 | < 1e-7% | 1e-7 rel. | pass | matches |
|
||||
| [j2-uniaxial-explicit](../tests/oracle/j2-uniaxial-explicit/) | axial Cauchy stress at a logarithmic strain of 0.2 | 643.56 | 643.57 | 0.000174% | 1% | pass | matches |
|
||||
| [j2-uniaxial-explicit](../tests/oracle/j2-uniaxial-explicit/) | equivalent plastic strain | 0.19678 | 0.19678 | -0.00000113% | 1% | pass | matches |
|
||||
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | total mass | 20 | 20 | < 1e-4% | 1e-12 rel. | pass | matches |
|
||||
| [j2-bauschinger](../tests/oracle/j2-bauschinger/) | axial stress back at zero strain after a pull to ε_a = 0.01 | -247.52 | -247.52 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [j2-bauschinger](../tests/oracle/j2-bauschinger/) | accumulated equivalent plastic strain | 0.016089 | 0.016089 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [j2-heated-bar](../tests/oracle/j2-heated-bar/) | axial stress, ΔT = 300 (α ΔT = 3.6·10⁻³, 2.9 times the yield strain) | -254.65 | -254.65 | < 1e-4% | 1e-8 rel. | pass | matches |
|
||||
| [j2-heated-bar](../tests/oracle/j2-heated-bar/) | equivalent plastic strain | 0.0023267 | 0.0023267 | < 1e-4% | 1e-7 rel. | pass | matches |
|
||||
| [j2-uniaxial-explicit](../tests/oracle/j2-uniaxial-explicit/) | axial Cauchy stress at a logarithmic strain of 0.2 | 643.56 | 643.57 | 0.00017% | 1% | pass | matches |
|
||||
| [j2-uniaxial-explicit](../tests/oracle/j2-uniaxial-explicit/) | equivalent plastic strain | 0.19678 | 0.19678 | < 1e-4% | 1% | pass | matches |
|
||||
| [kirsch-plate](../tests/oracle/kirsch-plate/) | σ_xx at the top of the hole (0, 1, 0) over the remote stress | 3 | 3.0121 | 0.402% | 2% | pass | matches |
|
||||
| [kirsch-plate](../tests/oracle/kirsch-plate/) | σ_yy at the side of the hole (1, 0, 0) over the remote stress | -1 | -1.011 | -1.1% | 3% | pass | matches |
|
||||
| [kirsch-plate-plane-stress](../tests/oracle/kirsch-plate-plane-stress/) | σ_xx at the top of the hole (0, 1) over the remote stress | 3 | 3.0029 | 0.096% | 2% | pass | matches |
|
||||
| [kirsch-plate-plane-stress](../tests/oracle/kirsch-plate-plane-stress/) | σ_yy at the side of the hole (1, 0) over the remote stress | -1 | -1.0019 | -0.187% | 3% | pass | matches |
|
||||
| [lame-cylinder](../tests/oracle/lame-cylinder/) | radial displacement at the bore (10, 0, 0) | 0.0090794 | 0.0090793 | -0.000267% | 0.1% | pass | matches |
|
||||
| [lame-cylinder](../tests/oracle/lame-cylinder/) | radial displacement outside (20, 0, 0) | 0.0057778 | 0.0057778 | -0.000301% | 0.1% | pass | matches |
|
||||
| [lame-cylinder](../tests/oracle/lame-cylinder/) | radial displacement at the bore (10, 0, 0) | 0.0090794 | 0.0090793 | -0.00027% | 0.1% | pass | matches |
|
||||
| [lame-cylinder](../tests/oracle/lame-cylinder/) | radial displacement outside (20, 0, 0) | 0.0057778 | 0.0057778 | -0.0003% | 0.1% | pass | matches |
|
||||
| [lame-cylinder](../tests/oracle/lame-cylinder/) | hoop stress σ_θ = σ_yy at the bore (10, 0, 0) | 166.67 | 166.36 | -0.183% | 1% | pass | matches |
|
||||
| [lame-cylinder-axisymmetric](../tests/oracle/lame-cylinder-axisymmetric/) | radial displacement at the bore (r = 10) | 0.0090794 | 0.0090793 | -0.000416% | 0.1% | pass | matches |
|
||||
| [lame-cylinder-axisymmetric](../tests/oracle/lame-cylinder-axisymmetric/) | radial displacement outside (r = 20) | 0.0057778 | 0.0057778 | -0.000327% | 0.1% | pass | matches |
|
||||
| [lame-cylinder-axisymmetric](../tests/oracle/lame-cylinder-axisymmetric/) | radial displacement at the bore (r = 10) | 0.0090794 | 0.0090793 | -0.00042% | 0.1% | pass | matches |
|
||||
| [lame-cylinder-axisymmetric](../tests/oracle/lame-cylinder-axisymmetric/) | radial displacement outside (r = 20) | 0.0057778 | 0.0057778 | -0.00033% | 0.1% | pass | matches |
|
||||
| [lame-cylinder-axisymmetric](../tests/oracle/lame-cylinder-axisymmetric/) | hoop stress σ_θ at the bore (stress component 3 of an axisymmetric element) | 166.67 | 165.98 | -0.412% | 1% | pass | matches |
|
||||
| [lame-cylinder-axisymmetric](../tests/oracle/lame-cylinder-axisymmetric/) | axial stress σ_z = 2νA at the bore (held ends) | 20 | 20.084 | 0.42% | 1% | pass | matches |
|
||||
| [lame-cylinder-plane-strain](../tests/oracle/lame-cylinder-plane-strain/) | radial displacement at the bore (10, 0) | 0.0090794 | 0.0090793 | -0.000267% | 0.1% | pass | matches |
|
||||
| [lame-cylinder-plane-strain](../tests/oracle/lame-cylinder-plane-strain/) | radial displacement at the bore (10, 0) | 0.0090794 | 0.0090793 | -0.00027% | 0.1% | pass | matches |
|
||||
| [lame-cylinder-plane-strain](../tests/oracle/lame-cylinder-plane-strain/) | hoop stress σ_θ = σ_yy at the bore (10, 0) | 166.67 | 166.36 | -0.183% | 1% | pass | matches |
|
||||
| [lame-cylinder-plane-strain](../tests/oracle/lame-cylinder-plane-strain/) | axial stress σ_zz = 2νA at the bore (plane strain) | 20 | 20.083 | 0.415% | 1% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of the lower block's corner element under the interface (element 10) over that of the upper block's corner element over it (element 19) | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of the lower block's centre element under the interface (element 14) over that of element 19 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of the lower block's bottom corner element (element 1) over that of element 19 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of the upper block's top far corner element (element 26) over that of element 19 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of the lower block's corner element under the interface (element 10) over that of the upper block's corner element over it (element 19) | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of the lower block's centre element under the interface (element 14) over that of element 19 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of the lower block's bottom corner element (element 1) over that of element 19 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of the upper block's top far corner element (element 26) over that of element 19 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_xx of element 14 over its σ_zz (uniaxial stress: 0) | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-6 | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: the contact pressure at the secondary node (0.5, 0.5, 1) over −σ_zz of element 19 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: σ_zz of the lower block's corner element under the interface (element 10) over that of the upper block's corner element over it (element 19) | 1 | 1 | 0.000151% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: the contact pressure at the secondary node (0.5, 0.5, 1) over −σ_zz of element 19 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: σ_zz of the lower block's corner element under the interface (element 10) over that of the upper block's corner element over it (element 19) | 1 | 1 | 0.00015% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: σ_zz of the lower block's centre element under the interface (element 14) over that of element 19 | 1 | 0.99999 | -0.00129% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: σ_zz of the lower block's bottom corner element (element 1) over that of element 19 | 1 | 0.99999 | -0.000942% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: σ_zz of the upper block's top far corner element (element 26) over that of element 19 | 1 | 0.99999 | -0.000872% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: σ_zz of the lower block's bottom corner element (element 1) over that of element 19 | 1 | 0.99999 | -0.00094% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: σ_zz of the upper block's top far corner element (element 26) over that of element 19 | 1 | 0.99999 | -0.00087% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: σ_xx of element 14 over its σ_zz (uniaxial stress: 0) | 0 | ≈ 0 | ≈ 0 | ± 2.0000e-4 | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: the contact pressure at the secondary node (0.5, 0.5, 1) over −σ_zz of element 19 | 1 | 0.99998 | -0.00172% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-7% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Finite: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-4% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch](../tests/oracle/nl-contact-patch/) | Small: σ_zz of element 19 (uniaxial stress E ε = −10 for the 1% squeeze) | -10 | -9.9996 | 0.00426% | 0.01% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 4 over that of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 6 over that of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 1 over that of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 10 over that of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 4 over that of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 6 over that of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 1 over that of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 10 over that of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_xx of element 6 over its σ_yy (uniaxial stress: 0) | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-6 | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: the contact pressure at the secondary node (1.5, 1) over −σ_yy of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: σ_yy of element 4 over that of element 7 | 1 | 1 | 0.0000126% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: the contact pressure at the secondary node (1.5, 1) over −σ_yy of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: σ_yy of element 4 over that of element 7 | 1 | 1 | < 1e-4% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: σ_yy of element 6 over that of element 7 | 1 | 0.99997 | -0.00325% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: σ_yy of element 1 over that of element 7 | 1 | 0.99999 | -0.00149% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: σ_yy of element 10 over that of element 7 | 1 | 0.99998 | -0.00185% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: σ_xx of element 6 over its σ_yy (uniaxial stress: 0) | 0 | -6.2023e-6 | -6.2e-6 | ± 2.0000e-4 | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: the contact pressure at the secondary node (1.5, 1) over −σ_yy of element 7 | 1 | 0.99997 | -0.00285% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-7% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Finite: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-4% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/) | Small: σ_yy of element 7 (uniaxial stress E ε = −10 for the 1% squeeze) | -10 | -9.9996 | 0.00426% | 0.01% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 4 over that of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 6 over that of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 1 over that of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 10 over that of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 4 over that of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 6 over that of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 1 over that of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 10 over that of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_xx of element 6 over its σ_yy (uniaxial stress: 0) | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-6 | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: the contact pressure at the secondary node (0.5, 1) over −σ_yy of element 7 | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: σ_yy of element 4 over that of element 7 | 1 | 1 | 0.0000708% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: the contact pressure at the secondary node (0.5, 1) over −σ_yy of element 7 | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: σ_yy of element 4 over that of element 7 | 1 | 1 | < 1e-4% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: σ_yy of element 6 over that of element 7 | 1 | 0.99999 | -0.00087% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: σ_yy of element 1 over that of element 7 | 1 | 1 | -0.000312% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: σ_yy of element 10 over that of element 7 | 1 | 1 | -0.000428% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: σ_yy of element 1 over that of element 7 | 1 | 1 | -0.00031% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: σ_yy of element 10 over that of element 7 | 1 | 1 | -0.00043% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: σ_xx of element 6 over its σ_yy (uniaxial stress: 0) | 0 | ≈ 0 | ≈ 0 | ± 2.0000e-4 | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: the contact pressure at the secondary node (0.5, 1) over −σ_yy of element 7 | 1 | 0.99999 | -0.00075% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-7% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Finite: the integrated contact pressure over the reaction at the pushed top | 1 | 1 | < 1e-4% | 0.02% | pass | matches |
|
||||
| [nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/) | Small: σ_yy of element 7 (plane strain uniaxial stress E ε/(1 − ν²) = −10.989 for the 1% squeeze) | -10.989 | -10.988 | 0.00516% | 0.01% | pass | matches |
|
||||
| [nl-cylinder-inflation](../tests/oracle/nl-cylinder-inflation/) | radial displacement of the bore (10, 0, 0) at the pressure that inflates it to a = 15 | 5 | 5.004 | 0.0793% | 0.5% | pass | matches |
|
||||
| [nl-cylinder-inflation](../tests/oracle/nl-cylinder-inflation/) | radial displacement of the outer wall (20, 0, 0) | 2.9129 | 2.9173 | 0.152% | 0.5% | pass | matches |
|
||||
@@ -135,9 +135,9 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
| [nl-elastica-cantilever](../tests/oracle/nl-elastica-cantilever/) | tip shortening δ_h/L at P L²/(E I) = 5 | 0.38763 | 0.38955 | 0.495% | 2% | pass | matches |
|
||||
| [nl-elastica-cantilever](../tests/oracle/nl-elastica-cantilever/) | tip deflection δ_v/L at P L²/(E I) = 10 | 0.81061 | 0.81421 | 0.444% | 1% | pass | matches |
|
||||
| [nl-elastica-cantilever](../tests/oracle/nl-elastica-cantilever/) | tip shortening δ_h/L at P L²/(E I) = 10 | 0.555 | 0.558 | 0.541% | 2% | pass | matches |
|
||||
| [nl-friction-sliding-blocks](../tests/oracle/nl-friction-sliding-blocks/) | the tangential over the normal force across the sliding interface | 0.2 | 0.2 | -0.000103% | 0.1% | pass | matches |
|
||||
| [nl-friction-sliding-blocks](../tests/oracle/nl-friction-sliding-blocks/) | the tangential contact force on the upper block over the sideways reaction at its top (they balance) | -1 | -1 | 0.00000224% | 1e-6 rel. | pass | matches |
|
||||
| [nl-friction-sliding-blocks](../tests/oracle/nl-friction-sliding-blocks/) | the secondary nodes that slip (all 9) | 9 | 9 | ≈ 0 | exact | pass | matches |
|
||||
| [nl-friction-sliding-blocks](../tests/oracle/nl-friction-sliding-blocks/) | the tangential over the normal force across the sliding interface | 0.2 | 0.2 | -0.0001% | 0.1% | pass | matches |
|
||||
| [nl-friction-sliding-blocks](../tests/oracle/nl-friction-sliding-blocks/) | the tangential contact force on the upper block over the sideways reaction at its top (they balance) | -1 | -1 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-friction-sliding-blocks](../tests/oracle/nl-friction-sliding-blocks/) | the secondary nodes that slip (all 9) | 9 | 9 | < 1e-5 | exact | pass | matches |
|
||||
| [nl-hertz-cylinder](../tests/oracle/nl-hertz-cylinder/) | the largest contact pressure p₀ (at x = 0) | 2884.6 | 2892 | 0.254% | 2% | pass | matches |
|
||||
| [nl-hertz-cylinder](../tests/oracle/nl-hertz-cylinder/) | the force across the contact (half model, 1 mm thick) | 1132.8 | 1133 | 0.0221% | 0.2% | pass | matches |
|
||||
| [nl-hertz-cylinder](../tests/oracle/nl-hertz-cylinder/) | the contact pressure at the secondary node at x = 0.29594 (node 7) | 2325.1 | 2257.8 | -2.89% | 5% | pass | matches |
|
||||
@@ -150,118 +150,118 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
| [nl-hertz-cylinder-plane-strain](../tests/oracle/nl-hertz-cylinder-plane-strain/) | the contact pressure at the secondary node at x = 0.36701 | 1959 | 2078.4 | 6.09% | 10% | pass | matches |
|
||||
| [nl-hertz-cylinder-plane-strain](../tests/oracle/nl-hertz-cylinder-plane-strain/) | the contact pressure at the secondary node at x = 0.63325 (beyond the half-width b = 0.5) | 0 | ≈ 0 | ≈ 0 | ± 1 | pass | matches |
|
||||
| [nl-hertz-cylinder-plane-strain](../tests/oracle/nl-hertz-cylinder-plane-strain/) | the vertical stress σ_yy under the centre of contact at depth 0.47127 | -2099.2 | -2059.8 | 1.87% | 4% | pass | matches |
|
||||
| [nl-hybrid-incompressible](../tests/oracle/nl-hybrid-incompressible/) | axial Cauchy stress, stretch λ = 2, ν = 0.4999 | 3.4988 | 3.4988 | 0.000000135% | 1e-6 rel. | pass | matches |
|
||||
| [nl-hybrid-incompressible](../tests/oracle/nl-hybrid-incompressible/) | lateral displacement at (1, 1, 1): l − 1 | -0.29281 | -0.29281 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-j2-uniaxial](../tests/oracle/nl-j2-uniaxial/) | axial Cauchy stress at a logarithmic strain of 0.2 | 642.74 | 642.74 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-j2-uniaxial](../tests/oracle/nl-j2-uniaxial/) | equivalent plastic strain | 0.19678 | 0.19678 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | axial Cauchy stress, uniaxial stretch λ = 2 | 2.7366 | 2.7366 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | lateral displacement at (1, 1, 1), uniaxial: l − 1 | -0.28647 | -0.28647 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | in-plane Cauchy stress, equibiaxial stretch λ = 1.5 | 2.8427 | 2.8427 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | Cauchy stress along the stretch, pure shear λ = 2 | 3.5261 | 3.5261 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | Cauchy stress across, in the held direction, pure shear λ = 2 | 1.5067 | 1.5067 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-hybrid-incompressible](../tests/oracle/nl-hybrid-incompressible/) | axial Cauchy stress, stretch λ = 2, ν = 0.4999 | 3.4988 | 3.4988 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-hybrid-incompressible](../tests/oracle/nl-hybrid-incompressible/) | lateral displacement at (1, 1, 1): l − 1 | -0.29281 | -0.29281 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-j2-uniaxial](../tests/oracle/nl-j2-uniaxial/) | axial Cauchy stress at a logarithmic strain of 0.2 | 642.74 | 642.74 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-j2-uniaxial](../tests/oracle/nl-j2-uniaxial/) | equivalent plastic strain | 0.19678 | 0.19678 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | axial Cauchy stress, uniaxial stretch λ = 2 | 2.7366 | 2.7366 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | lateral displacement at (1, 1, 1), uniaxial: l − 1 | -0.28647 | -0.28647 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | in-plane Cauchy stress, equibiaxial stretch λ = 1.5 | 2.8427 | 2.8427 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | Cauchy stress along the stretch, pure shear λ = 2 | 3.5261 | 3.5261 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | Cauchy stress across, in the held direction, pure shear λ = 2 | 1.5067 | 1.5067 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-mooney-rivlin-tube](../tests/oracle/nl-mooney-rivlin-tube/) | radial displacement of the bore (10, 0, 0) at the pressure that inflates it to a = 15 at λ_z = 1.5 | 5 | 5.0311 | 0.622% | 1% | pass | matches |
|
||||
| [nl-mooney-rivlin-tube](../tests/oracle/nl-mooney-rivlin-tube/) | radial displacement of the outer wall (20, 0, 0): b − B, a small difference of radii | 0.61553 | 0.64339 | 2.8e-2 | ± 0.04 | pass | matches |
|
||||
| [nl-mooney-rivlin-tube-axisymmetric](../tests/oracle/nl-mooney-rivlin-tube-axisymmetric/) | radial displacement of the bore (10, 0, 0) at the pressure that inflates it to a = 15 at λ_z = 1.5 | 5 | 5.0329 | 0.658% | 1% | pass | matches |
|
||||
| [nl-mooney-rivlin-tube-axisymmetric](../tests/oracle/nl-mooney-rivlin-tube-axisymmetric/) | radial displacement of the outer wall (20, 0, 0): b − B, a small difference of radii | 0.61553 | 0.64473 | 2.9e-2 | ± 0.04 | pass | matches |
|
||||
| [nl-neo-hookean-uniaxial](../tests/oracle/nl-neo-hookean-uniaxial/) | axial Cauchy stress at the corner (1, 1, 1), stretch λ = 2 | 8.0601 | 8.0601 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-neo-hookean-uniaxial](../tests/oracle/nl-neo-hookean-uniaxial/) | lateral displacement u_y at the corner (1, 1, 1), l − 1 | -0.18686 | -0.18686 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | u_x at inner node 22 | 0.13583 | 0.13583 | < 1e-7% | 1e-8 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | u_y at inner node 22 | 3.4875e-4 | 3.4875e-4 | -0.000000665% | 1e-8 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | u_z at inner node 22 | -0.0023985 | -0.0023985 | -0.000000102% | 1e-8 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_xx at inner node 22 | 42.296 | 42.296 | < 1e-7% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_yy at inner node 22 | 23.501 | 23.501 | < 1e-7% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_zz at inner node 22 | 31.453 | 31.453 | < 1e-7% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_xy at inner node 22 | -0.88971 | -0.88971 | < 1e-7% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_xz at inner node 22 | -1.8906 | -1.8906 | < 1e-7% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_yz at inner node 22 | 1.9462 | 1.9462 | < 1e-7% | 1e-7 rel. | pass | matches |
|
||||
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | u_x at inner node 22: the rotation's | -0.011149 | -0.011149 | -1.2e-16 | ± 1.0000e-9 | pass | matches |
|
||||
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | u_y at inner node 22: the rotation's | 0.078591 | 0.078591 | -5.6e-17 | ± 1.0000e-9 | pass | matches |
|
||||
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | u_z at inner node 22: the rotation's | -0.067441 | -0.067441 | -1.2e-16 | ± 1.0000e-9 | pass | matches |
|
||||
| [nl-neo-hookean-uniaxial](../tests/oracle/nl-neo-hookean-uniaxial/) | axial Cauchy stress at the corner (1, 1, 1), stretch λ = 2 | 8.0601 | 8.0601 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-neo-hookean-uniaxial](../tests/oracle/nl-neo-hookean-uniaxial/) | lateral displacement u_y at the corner (1, 1, 1), l − 1 | -0.18686 | -0.18686 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | u_x at inner node 22 | 0.13583 | 0.13583 | < 1e-4% | 1e-8 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | u_y at inner node 22 | 3.4875e-4 | 3.4875e-4 | < 1e-4% | 1e-8 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | u_z at inner node 22 | -0.0023985 | -0.0023985 | < 1e-4% | 1e-8 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_xx at inner node 22 | 42.296 | 42.296 | < 1e-4% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_yy at inner node 22 | 23.501 | 23.501 | < 1e-4% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_zz at inner node 22 | 31.453 | 31.453 | < 1e-4% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_xy at inner node 22 | -0.88971 | -0.88971 | < 1e-4% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_xz at inner node 22 | -1.8906 | -1.8906 | < 1e-4% | 1e-7 rel. | pass | matches |
|
||||
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | Cauchy σ_yz at inner node 22 | 1.9462 | 1.9462 | < 1e-4% | 1e-7 rel. | pass | matches |
|
||||
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | u_x at inner node 22: the rotation's | -0.011149 | -0.011149 | < 1e-7 | ± 1.0000e-9 | pass | matches |
|
||||
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | u_y at inner node 22: the rotation's | 0.078591 | 0.078591 | < 1e-7 | ± 1.0000e-9 | pass | matches |
|
||||
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | u_z at inner node 22: the rotation's | -0.067441 | -0.067441 | < 1e-7 | ± 1.0000e-9 | pass | matches |
|
||||
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | von Mises stress at inner node 22 | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-8 | pass | matches |
|
||||
| [nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/) | displacement of the corner (1, 1, 1) along x, free, α ΔT = 0.2 | 0.2 | 0.2 | < 1e-7% | 1e-8 rel. | pass | matches |
|
||||
| [nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/) | displacement of the corner (1, 1, 1) along x, free, α ΔT = 0.2 | 0.2 | 0.2 | < 1e-4% | 1e-8 rel. | pass | matches |
|
||||
| [nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/) | stress of the free body (σ_xx at the corner) | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-8 | pass | matches |
|
||||
| [nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/) | stress of the held block, σ_xx (hydrostatic) | -3.5108 | -3.5108 | < 1e-7% | 1e-8 rel. | pass | matches |
|
||||
| [nl-yeoh-uniaxial](../tests/oracle/nl-yeoh-uniaxial/) | axial Cauchy stress, stretch λ = 2 | 3.1743 | 3.1743 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-yeoh-uniaxial](../tests/oracle/nl-yeoh-uniaxial/) | lateral displacement at (1, 1, 1): l − 1 | -0.28545 | -0.28545 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | x reaction on the clamped face (5 mm mesh) | -2000 | -2000 | < 1e-7% | 1e-6 rel. | pass | matches |
|
||||
| [nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/) | stress of the held block, σ_xx (hydrostatic) | -3.5108 | -3.5108 | < 1e-4% | 1e-8 rel. | pass | matches |
|
||||
| [nl-yeoh-uniaxial](../tests/oracle/nl-yeoh-uniaxial/) | axial Cauchy stress, stretch λ = 2 | 3.1743 | 3.1743 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [nl-yeoh-uniaxial](../tests/oracle/nl-yeoh-uniaxial/) | lateral displacement at (1, 1, 1): l − 1 | -0.28545 | -0.28545 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | x reaction on the clamped face (5 mm mesh) | -2000 | -2000 | < 1e-4% | 1e-6 rel. | pass | matches |
|
||||
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | elements the certified Jacobian check does not prove valid (5 mm mesh) | 0 | 0 | ≈ 0 | exact | pass | matches |
|
||||
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | mesh volume against the CAD volume (5 mm mesh) | 32145 | 32177 | 0.1% | 0.3% | pass | matches |
|
||||
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | σ_yy on the outer face of the upright leg at y = 19, z = 6 (5 mm mesh) | 145.31 | 149.26 | 2.72% | 5% | pass | matches |
|
||||
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | σ_yy on the inner face of the upright leg at y = 19, z = 6 (5 mm mesh) | -145.31 | -147.28 | -1.35% | 5% | pass | matches |
|
||||
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | von Mises stress at the middle of the fillet, 7 mm mesh | 143.21 | 138.53 | -3.27% | 5% | pass | matches |
|
||||
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | von Mises stress at the middle of the fillet, 5 mm mesh | 143.21 | 144.53 | 0.921% | 2% | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20](../tests/oracle/patch-hex20/) | σ_xy at the inner node | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | u_y at the corner (1, 1, 1) | 0.001 | 1.0000e-3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/) | σ_xy at the inner node | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8](../tests/oracle/patch-hex8/) | σ_xy at the inner node | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-hex8-incompatible](../tests/oracle/patch-hex8-incompatible/) | σ_xy at the inner node | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | u_y at the corner (1, 1, 1) | 0.001 | 1.0000e-3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet10](../tests/oracle/patch-tet10/) | σ_xy at the inner node | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | u_y at the corner (1, 1, 1) | 0.001 | 1.0000e-3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-tet4](../tests/oracle/patch-tet4/) | σ_xy at the inner node | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_xx at the inner node (0.46, 0.55, 0.53) | 1 | 1 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_xy at the inner node | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
|
||||
| [plastic-hinge-cantilever](../tests/oracle/plastic-hinge-cantilever/) | tip force at a tip deflection of 15 (4.5 times the first yield's) | -312.5 | -322.61 | -3.23% | 5% | pass | matches |
|
||||
| [plastic-thick-cylinder](../tests/oracle/plastic-thick-cylinder/) | bore displacement at the pressure that takes the plastic zone to c = 15 (a = 10, b = 20, σ_y = 250) | 0.024355 | 0.024358 | 0.0089% | 0.2% | pass | matches |
|
||||
| [plastic-thick-cylinder](../tests/oracle/plastic-thick-cylinder/) | radial force on the bore (per radian times 2π, height 1) at a bore displacement of 0.3, 24 times the first yield's: the collapse pressure 2k ln 2 = 200.094 times 2πa | 12572 | 12572 | 0.00000178% | 0.1% | pass | matches |
|
||||
| [pure-bending-coupling](../tests/oracle/pure-bending-coupling/) | bending stress σ_xx at mid-span, top fibre (10, 0.5, 1) | 15 | 15 | -0.0000103% | 0.5% | pass | matches |
|
||||
| [plastic-thick-cylinder](../tests/oracle/plastic-thick-cylinder/) | radial force on the bore (per radian times 2π, height 1) at a bore displacement of 0.3, 24 times the first yield's: the collapse pressure 2k ln 2 = 200.094 times 2πa | 12572 | 12572 | < 1e-4% | 0.1% | pass | matches |
|
||||
| [pure-bending-coupling](../tests/oracle/pure-bending-coupling/) | bending stress σ_xx at mid-span, top fibre (10, 0.5, 1) | 15 | 15 | < 1e-4% | 0.5% | pass | matches |
|
||||
| [pure-bending-coupling](../tests/oracle/pure-bending-coupling/) | rotation of the reference point about y | 0.3 | 0.30355 | 1.18% | 1% | known failure: 1.2% high: the distributing coupling weights every node of the end face equally, corner and mid-side nodes of hex20 alike, which is not the consistent linear traction; the end face deforms locally and the fitted rotation includes it. Area-consistent weights would remove it | — |
|
||||
| [rotating-cylinder-axisymmetric](../tests/oracle/rotating-cylinder-axisymmetric/) | radial stress σ_r on the axis (r = 0) | 0.38571 | 0.38608 | 0.0958% | 1% | pass | matches |
|
||||
| [rotating-cylinder-axisymmetric](../tests/oracle/rotating-cylinder-axisymmetric/) | hoop stress σ_θ at the surface (r = 1) | 0.12857 | 0.12873 | 0.123% | 1% | pass | matches |
|
||||
| [rotating-disc-plane-stress](../tests/oracle/rotating-disc-plane-stress/) | hoop stress σ_θ = σ_yy at the bore (20, 0) | 65.312 | 65.188 | -0.19% | 1% | pass | matches |
|
||||
| [rotating-disc-plane-stress](../tests/oracle/rotating-disc-plane-stress/) | radial stress σ_r = σ_xx at r = √(ab), its maximum | 20.724 | 20.683 | -0.2% | 1% | pass | matches |
|
||||
| [rotating-disc-plane-stress](../tests/oracle/rotating-disc-plane-stress/) | hoop stress σ_θ = σ_yy at the rim (100, 0) | 16.328 | 16.352 | 0.149% | 1% | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | T at the tied secondary node (0.5, 0.5, 1) | 84 | 84 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | T in the lower block at (0, 0, 0.5) | 52 | 52 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | T in the upper block at (1, 1, 1.5) | 92 | 92 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | heat flux q_z at (0, 0, 0.5) | -640 | -640 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | heat flux q_z at (1, 1, 1.5) | -640 | -640 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the lower block's corner element under the tie (element 10) | 10 | 10 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the lower block's centre element under the tie (element 14) | 10 | 10 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the lower block's bottom corner element (element 1) | 10 | 10 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the upper block's corner element over the tie (element 19) | 10 | 10 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the upper block's top far corner element (element 26) | 10 | 10 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | T at the tied secondary node (0.5, 0.5, 1) | 84 | 84 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | T in the lower block at (0, 0, 0.5) | 52 | 52 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | T in the upper block at (1, 1, 1.5) | 92 | 92 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | heat flux q_z at (0, 0, 0.5) | -640 | -640 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-heat-series](../tests/oracle/tie-heat-series/) | heat flux q_z at (1, 1, 1.5) | -640 | -640 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the lower block's corner element under the tie (element 10) | 10 | 10 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the lower block's centre element under the tie (element 14) | 10 | 10 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the lower block's bottom corner element (element 1) | 10 | 10 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the upper block's corner element over the tie (element 19) | 10 | 10 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_zz of the upper block's top far corner element (element 26) | 10 | 10 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | σ_xx of element 14 (uniaxial stress: 0) | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | u_z at the tied secondary node (0.5, 0.5, 1) | 0.01 | 0.01 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [tube-torsion-coupling](../tests/oracle/tube-torsion-coupling/) | rotation of the reference point about z | 0.056071 | 0.056071 | 0.000619% | 0.5% | pass | matches |
|
||||
| [tie-patch](../tests/oracle/tie-patch/) | u_z at the tied secondary node (0.5, 0.5, 1) | 0.01 | 0.01 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [tube-torsion-coupling](../tests/oracle/tube-torsion-coupling/) | rotation of the reference point about z | 0.056071 | 0.056071 | 0.00062% | 0.5% | pass | matches |
|
||||
| [tube-torsion-coupling](../tests/oracle/tube-torsion-coupling/) | surface shear stress τ_θz = σ_yz at (10, 0, 20) | 1078.3 | 1081.4 | 0.288% | 1% | pass | matches |
|
||||
|
||||
## Published benchmarks
|
||||
@@ -270,13 +270,13 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| [mh-curved-cantilever](../tests/oracle/mh-curved-cantilever/) | tip displacement along the in-plane load | 0.08734 | 0.076833 | -12% | 2% | known failure: 0.880 of the theory, grade C: straight-sided hex8 on six elements over 90° (MacNeal–Harder's HEXA(8): 0.880, Table 9 p. 15) | — |
|
||||
| [mh-curved-cantilever](../tests/oracle/mh-curved-cantilever/) | tip displacement along the out-of-plane load | 0.5022 | 0.40095 | -20.2% | 2% | known failure: 0.798 of the theory, grade D: out-of-plane bending with torsion on one element across (MacNeal–Harder's HEXA(8): 0.849) | — |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | σ_xx at inner node 7 (0.788, 0.693, 0.644) | 2000 | 2000 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | σ_yy at inner node 7 (0.788, 0.693, 0.644) | 2000 | 2000 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | σ_zz at inner node 7 (0.788, 0.693, 0.644) | 2000 | 2000 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | τ_xy at inner node 7 | 400 | 400 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | τ_xz at inner node 7 | 400 | 400 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | τ_yz at inner node 7 | 400 | 400 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | u_x at inner node 7 | 0.0014565 | 0.0014565 | < 1e-7% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | σ_xx at inner node 7 (0.788, 0.693, 0.644) | 2000 | 2000 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | σ_yy at inner node 7 (0.788, 0.693, 0.644) | 2000 | 2000 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | σ_zz at inner node 7 (0.788, 0.693, 0.644) | 2000 | 2000 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | τ_xy at inner node 7 | 400 | 400 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | τ_xz at inner node 7 | 400 | 400 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | τ_yz at inner node 7 | 400 | 400 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [mh-solid-patch](../tests/oracle/mh-solid-patch/) | u_x at inner node 7 | 0.0014565 | 0.0014565 | < 1e-4% | 1e-9 rel. | pass | matches |
|
||||
| [mh-straight-cantilever](../tests/oracle/mh-straight-cantilever/) | tip displacement along the load, extension | 3.0000e-5 | 2.9629e-5 | -1.24% | 2% | pass | matches |
|
||||
| [mh-straight-cantilever](../tests/oracle/mh-straight-cantilever/) | tip displacement along the load, in-plane shear (width 0.2) | 0.1081 | 0.10574 | -2.18% | 2% | known failure: 0.978 of the theory, grade B: one hex8 with incompatible modes through the depth of the prescribed mesh (MacNeal–Harder's HEXA(8): 0.981, Table 8(a) p. 15); refined meshes converge (cantilever-hex8-incompatible) | — |
|
||||
| [mh-straight-cantilever](../tests/oracle/mh-straight-cantilever/) | tip displacement along the load, out-of-plane shear (depth 0.1) | 0.4321 | 0.42037 | -2.71% | 2% | known failure: 0.973 of the theory, grade B, as for in-plane shear (MacNeal–Harder's HEXA(8): 0.981) | — |
|
||||
@@ -284,11 +284,11 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
| [mh-thick-cylinder](../tests/oracle/mh-thick-cylinder/) | radial displacement at the inner radius, ν = 0.49 | 0.0050399 | 0.0049705 | -1.38% | 2% | pass | matches |
|
||||
| [mh-thick-cylinder](../tests/oracle/mh-thick-cylinder/) | radial displacement at the inner radius, ν = 0.499 | 0.0050602 | 0.0049892 | -1.4% | 2% | pass | matches |
|
||||
| [mh-thick-cylinder](../tests/oracle/mh-thick-cylinder/) | radial displacement at the inner radius, ν = 0.4999 | 0.0050623 | 0.0049911 | -1.41% | 2% | pass | matches |
|
||||
| [nafems-fv32](../tests/oracle/nafems-fv32/) | frequency of mode 1 (Hz) | 44.623 | 44.623 | -0.000219% | 1% | pass | matches |
|
||||
| [nafems-fv32](../tests/oracle/nafems-fv32/) | frequency of mode 1 (Hz) | 44.623 | 44.623 | -0.00022% | 1% | pass | matches |
|
||||
| [nafems-fv32](../tests/oracle/nafems-fv32/) | frequency of mode 2 (Hz) | 130.03 | 130.03 | 0.00356% | 1% | pass | matches |
|
||||
| [nafems-fv32](../tests/oracle/nafems-fv32/) | frequency of mode 3 (Hz) | 162.7 | 162.7 | -0.00108% | 1% | pass | matches |
|
||||
| [nafems-fv32](../tests/oracle/nafems-fv32/) | frequency of mode 4 (Hz) | 246.05 | 246.05 | 0.00159% | 1% | pass | matches |
|
||||
| [nafems-fv32](../tests/oracle/nafems-fv32/) | frequency of mode 5 (Hz) | 379.9 | 379.9 | 0.000182% | 1% | pass | matches |
|
||||
| [nafems-fv32](../tests/oracle/nafems-fv32/) | frequency of mode 5 (Hz) | 379.9 | 379.9 | 0.00018% | 1% | pass | matches |
|
||||
| [nafems-fv32](../tests/oracle/nafems-fv32/) | frequency of mode 6 (Hz) | 391.44 | 391.44 | -0.0012% | 1% | pass | matches |
|
||||
| [nafems-le1](../tests/oracle/nafems-le1/) | σ_yy at D (2, 0) | 92.7 | 92.869 | 0.182% | 1% | pass | matches |
|
||||
| [nafems-le1-plane-stress](../tests/oracle/nafems-le1-plane-stress/) | σ_yy at D (2, 0) | 92.7 | 92.648 | -0.0559% | 1% | pass | matches |
|
||||
@@ -297,6 +297,8 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
| [nafems-le2](../tests/oracle/nafems-le2/) | tangential stress at E, outer surface, load case 2 (pressure and edge traction) | 60 | 59.904 | -0.161% | 1% | pass | matches |
|
||||
| [nafems-le3](../tests/oracle/nafems-le3/) | radial displacement at A (10, 0, 0), m | 0.185 | 0.18418 | -0.446% | 2% | pass | matches |
|
||||
| [nafems-le5](../tests/oracle/nafems-le5/) | σ_xx at A (2.5, −1, −1), mid-surface of the lower flange at its free edge | -108 | -109.45 | -1.35% | 2% | pass | matches |
|
||||
| [nafems-le6](../tests/oracle/nafems-le6/) | maximum principal stress at E (933.01, 250), lower surface | 0.802 | 0.81969 | 2.21% | 2% | known failure: the target is a thin-plate value; at the specified 10 mm the transverse shear deformation the MITC4 shell keeps raises it to 0.8197 (+2.2%; converged about 0.821), as ESRD's converged thin-solid model does (quantity s1_E_solid), and thinner plates move towards 0.802 only slowly (about 0.809 at 1 mm, see README); FieldCraft has no thin-plate (Kirchhoff) element | — |
|
||||
| [nafems-le6](../tests/oracle/nafems-le6/) | maximum principal stress at E (933.01, 250), lower surface, against the converged thin-solid model | 0.82 | 0.81969 | -0.0372% | 1% | pass | matches |
|
||||
| [nafems-le7](../tests/oracle/nafems-le7/) | axial stress σ_yy at D (1.0125, 1.4034), outer surface | 25.86 | 25.283 | -2.23% | 1% | known failure: the target is the axisymmetric thin-shell solution and FieldCraft has no axisymmetric shell element; our axisymmetric solid model converges to 25.27 (−2.3%), the value ESRD reports for its converged axisymmetric solid (quantity syy_D_solid) | — |
|
||||
| [nafems-le7](../tests/oracle/nafems-le7/) | axial stress σ_yy at D (1.0125, 1.4034), outer surface, against the converged solid model | 25.27 | 25.283 | 0.0496% | 1% | pass | matches |
|
||||
| [pinched-cylinder](../tests/oracle/pinched-cylinder/) | radial displacement under the load, mid-surface | -1.8248e-5 | -1.8038e-5 | 1.15% | 2% | pass | matches |
|
||||
@@ -344,6 +346,7 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
|
||||
- **[nafems-le2](../tests/oracle/nafems-le2/)** (benchmark): NAFEMS LE2 cylindrical shell bending patch test (MITC4 shells). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE2 (target: tangential stress 60 MPa on the outer surface at E, both load cases); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.2 'LE2: Cylindrical shell bending patch test', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 8. Tolerances — stt_E_moment: the target is the exact thin-shell value (6M/t²) and the stress is uniform over the patch; the 8 × 8 flat facets tilt the element axes by ±1.875° from the hoop direction, which moves 0.1% of it into σ_zz, and 16 × 16 is within 0.03% (see README); stt_E_pressure: the target is the exact membrane value (pR/t) and the stress is uniform over the patch; as in load case 1 the facets' tilt and the pressure on flat facets keep 8 × 8 within 0.2%, 16 × 16 within 0.05% (see README)
|
||||
- **[nafems-le3](../tests/oracle/nafems-le3/)** (benchmark): NAFEMS LE3 hemispherical shell with point loads (MITC4 shells). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE3 (target: radial displacement 0.185 m at A); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.3 'LE3: Hemispherical shell with point loads', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 12. Tolerances — ur_A: the target is published to three figures; the displacement under a point load on a shear-deformable shell keeps growing slowly as the mesh is refined (32 × 32 is 0.44% low, 64 × 64 0.16%; see README)
|
||||
- **[nafems-le5](../tests/oracle/nafems-le5/)** (benchmark): NAFEMS LE5 Z-section cantilever (hex20). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE5 (target: axial stress −108 MPa at A, mid-surface); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.5 'LE5: Z-section cantilever', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 15. Tolerances — sxx_A: the target is published to three figures and comes from a thin-walled idealisation; a 3D solid model of the section converges about 1.5% beyond it (ours: −109.46 here, −109.60 on the next mesh; ESRD's p-version solid: −109.5, see README)
|
||||
- **[nafems-le6](../tests/oracle/nafems-le6/)** (benchmark): NAFEMS LE6 skew plate under normal pressure (MITC4 shells). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE6 (target: maximum principal stress 0.802 MPa on the lower surface at E, the centre of the plate), as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.6 'LE6: Skew plate under normal pressure', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 19, which also gives its converged thin-solid result, 0.820 MPa (p. 20), and notes that its thin-solid formulation can differ from the plate model behind the target (p. 22). Tolerances — s1_E: the target is published to three figures for the thin-plate model of the problem; s1_E_solid: a model that keeps transverse shear, like ESRD's thin solid, which reports 0.820 on two meshes (0.822 with triangles); ours is 0.8197 and the next refinement moves it by 0.15% (see README)
|
||||
- **[nafems-le7](../tests/oracle/nafems-le7/)** (benchmark): NAFEMS LE7 axisymmetric cylinder/sphere under pressure (axisymmetric quad8). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE7 (target: axial stress 25.86 MPa on the outer surface at D, R = 1.0125, Z = 1.4034), as reproduced in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 23, which also gives its converged axisymmetric solid result, 25.27 MPa (p. 24); the Abaqus 2016 Benchmarks Guide, section 4.2.7 'LE7: Axisymmetric cylinder/sphere under pressure', gives the target rounded to 25.9 MPa. Tolerances — syy_D: the target is published to four figures for the thin-shell model of the vessel, and a model of the same theory converges well within 1%; syy_D_solid: the same model idealisation (axisymmetric solid) as the reference, which ESRD reports converged to under 1% on two meshes; ours moves by 0.05% on the next refinement (see README)
|
||||
- **[nl-contact-patch](../tests/oracle/nl-contact-patch/)** (theory): Contact patch test: two blocks with non-matching meshes in uniaxial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; Crisfield, Re-visiting the contact patch test, IJNME 48:435–449, 2000; passed by mortar methods: Puso & Laursen, CMAME 193:601–629, 2004. Uniaxial stress between frictionless symmetry planes is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its mortar integrals exact on these rectangular faces: a uniform stress is the discrete solution up to the convergence tolerance (10⁻¹⁰); small_lower_centre: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: frictionless symmetry planes and a frictionless interface leave the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface; finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_centre: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: frictionless symmetry planes and a frictionless interface leave the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface; small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness, a few 10⁻⁵ of the squeeze)
|
||||
- **[nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/)** (theory): Contact patch test, axisymmetric: two rings with non-matching edges in axial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; passed by 2D mortar methods: Yang, Laursen & Meng, IJNME 62:1183–1225, 2005. Uniaxial stress between frictionless supports is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its edge mortar integrals exact (degree-3 Gauss on the overlaps): the uniform stress is the discrete solution up to the convergence tolerance; small_lower_far: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface (both are totals over the circumference); finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_far: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface (both are totals over the circumference); small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Units inventory (#579)
|
||||
|
||||
Every place a dimensional number enters or leaves FieldCraft today, the unit it is assumed to be
|
||||
in, and where the SI conversion goes. Today nothing converts: every stored `f64` means "whatever
|
||||
consistent system the user chose", labelled by `model.units` (`UnitSystem`,
|
||||
`crates/model/src/units/mod.rs`). The SI table that later steps convert through is
|
||||
`crates/model/src/units/` (`Quantity`, `Unit`, `DisplaySystem`). **Risky** marks an implicit
|
||||
assumption, a hard-coded factor or a missing dimension.
|
||||
|
||||
## Model storage (`crates/model/src`): all bare `f64`, no unit tag
|
||||
Conversion goes at the command/file boundary; storage becomes SI.
|
||||
|
||||
| Where | Fields | Quantity |
|
||||
|---|---|---|
|
||||
| `lib.rs` | `units: UnitSystem` (serde default `unspecified`); `MAX_COORD = 1e9` "model units" | label; length |
|
||||
| `mesh.rs`, `datums.rs`, `constraints.rs`, `relaxations.rs`, `mesh_plans.rs` | node/datum/reference point xyz, plan centre/radius/size/tolerance | length |
|
||||
| `sizes.rs`, `seeds.rs`, `virtual_faces.rs`, `tie.rs` | size regions, seed sizes, tolerances, position_tolerance | length (bias, growth: dimensionless) |
|
||||
| `materials.rs` | young, shear, hyperelastic c01/c20/c30, yield_stress, hardening_modulus, hardening table stress, JC a/b | pressure |
|
||||
| | poisson, strains, JC n/c/m, taylor_quinney, damage d1–d5 | dimensionless |
|
||||
| | JC reference_strain_rate | frequency (1/s) |
|
||||
| | JC room/melt temperature, thermal reference_temperature | temperature (absolute) |
|
||||
| | conductivity, specific_heat, expansion | conductivity, specific heat, expansion (1/ΔT) |
|
||||
| | density; fluid viscosity (doc: "1e-9 MPa·s for water") | density; dynamic viscosity |
|
||||
| `sections.rs` | profile dims, shell thickness/offset, 2D thickness, area | length, area |
|
||||
| `loads.rs` | force, moment, traction, pressure, gravity, body force, centrifugal ω/point, surface flux, film, sink, heat source power, temperature | force, moment, pressure, acceleration, force/volume, angular velocity, heat flux, HTC, temperature, heat generation |
|
||||
| `bcs.rs` | displacement `[Option<f64>; 6]` (3 lengths + 3 rotations rad), temperature | length, angle, temperature |
|
||||
| `steps.rs` | frequency min/max_hz, buckle shift, end_time/increments/intervals, initial velocity, rigid wall point, hourglass/bulk viscosity coefs, heat theta/initial_temperature, imperfection scale, displacement control | frequency, time, velocity, length, temperature (coefs dimensionless) |
|
||||
| `contact.rs` | μ static/kinetic, penalty_scale; **decay (time per length)** | dimensionless; s/m |
|
||||
| `discrete.rs` | point mass, inertia, spring stiffness, dashpot coefficient | mass, mass·length², stiffness, damping |
|
||||
| `fluids.rs` | wall/inlet velocity, pressure, thermal temperature/flux, initial temperature, gravity, β, domain min/max/spacing/tolerance | velocity, pressure, temperature, heat flux, acceleration, expansion, length |
|
||||
|
||||
**Risky:** `units` missing in a file means `unspecified`, so a migration cannot know the system of
|
||||
old files without one; the step cache key includes `units`.
|
||||
|
||||
## Commands and forms (`crates/engine/src/cmd`, `schema.rs`, `cmd/forms.rs`)
|
||||
Definition commands (`model.{material,section,load,boundary_condition,…}.{create,edit}`) deserialize
|
||||
JSON straight into the model structs (`cmd/define.rs`), so every number is a bare `f64`. Each form
|
||||
field carries a `Dim` (`schema.rs:299-418`); `Dim::unit(system)` only builds a label. Conversion
|
||||
goes where the form value is read: `Field` → `Quantity` and `ValueInput::to_si`.
|
||||
|
||||
**Risky:**
|
||||
- `Dim::Millimetre` is a fixed "mm" for meshing and geometry commands (tet/hex size, tolerances,
|
||||
datums, hex project, defeature, mesh plan, geometry check), while the same quantities in mesh
|
||||
editing commands (node add/move/relax, seeds, size regions, block, mirror, planar, stitch) are
|
||||
`Dim::Length` and follow the system. One physical quantity, two assumptions.
|
||||
- Missing dimensions: load heat_source power (`forms.rs:561`, really W/m³), frequency shift
|
||||
(`forms.rs:2024`, really 1/s²), contact decay (`forms.rs:1015`, s/m), JC reference strain
|
||||
rate (`forms.rs:137`, 1/s).
|
||||
- `Dim::Temperature` has no unit; `Dim::Expansion` is fixed "1/K" while temperatures are unlabelled.
|
||||
- JSON Schema (`schema.rs:1260-1365`, list_commands, `fieldcraft-cli commands --json`, MCP) emits
|
||||
only `x-dimension` names and none for vectors and number_or_auto (forces, positions, gravity,
|
||||
velocities lose their dimension).
|
||||
- Help/defaults assume mm-t-s: `forms.rs:520` (gravity −9810), `define.rs:98` (210000, 7.85e-9),
|
||||
`define.rs:291`; samples `engine/src/sample.rs:362` `GRAVITY = 9810.0` and aluminium
|
||||
70000 / 2.7e-9.
|
||||
- `model.units` (`cmd/model.rs:39-54`) only sets the label; it warns about mm geometry only while a
|
||||
design is open.
|
||||
|
||||
## UI display (`crates/ui-egui`)
|
||||
Forms (`form.rs`) label through `Field::unit_label/help_text`; descriptions (`describe.rs`),
|
||||
replies (`reply.rs`), results panel and probes (`results_panel.rs`, `lib.rs` `probe_rows`) through
|
||||
`Quantity::unit(system)`. Display conversion goes here (`Quantity::format` with the display
|
||||
system). **Risky:** literal "mm", "mm²", "mm³" in `reply.rs:398, 708, 720, 723, 770` and
|
||||
`feature_graph.rs:52-257`.
|
||||
|
||||
## Results (`crates/engine/src/results.rs`, `flow.rs`, `cmd/xy.rs`, `cmd/results.rs`)
|
||||
`Field::quantity()` maps fields to the engine's `Quantity` (Length, Stress, Force, Moment, Mass,
|
||||
Time, Velocity, Temperature, Energy, HeatFlow, HeatFlux); `Quantity::unit` builds the label, with
|
||||
per-system strings for energy/power/flux. Reactions and probe replies are numbers only; contour
|
||||
replies carry `"unit"`. **Risky:** temperature is always `"°C or K"` (also in in-lbf-s);
|
||||
`ContactGap` (a length) maps to `None`. Conversion: results stay SI; legends and replies format.
|
||||
|
||||
## Files
|
||||
| Format | Today | Conversion |
|
||||
|---|---|---|
|
||||
| `.fieldcraft` (`crates/io`, `FORMAT = "fieldcraft/1"`) | `model.json` numbers as entered plus `"units"`; `mesh.bin` raw coordinates | scale to SI once on open by the declared system. **Risky:** FieldCraft has no pending-format mechanism (only the `FORMAT` const and its refusal); SI storage needs a format version step. Files with `unspecified` need a rule (assume mm-t-s-N-MPa, since geometry is mm). |
|
||||
| Abaqus / Nastran / LS-DYNA (`crates/decks`, write only) | unit label in a comment only | write in a chosen consistent system (`DisplaySystem` factors). **Risky:** no Nastran `PARAM,WTMASS`, no LS-DYNA `*CONTROL_UNITS`; there are no deck readers yet. |
|
||||
| SolidCraft geometry (`crates/geometry`) | "lengths are millimetres" (`lib.rs:16, 96`) | ×1e-3 once at import |
|
||||
| 3MF (`geometry/src/tagged_mesh.rs:151-161`) | **the one real conversion outside the table**: micron/mm/cm/inch/foot/metre → mm; writes `unit="millimeter"` | move factors to the table, convert to m |
|
||||
| STEP | no import exists | — |
|
||||
| STL export (`io/src/stl.rs`) | raw coordinates | write in the display length (STL has no unit field) |
|
||||
| VTU/PVD (`io/src/vtk.rs`) | no unit metadata | add units in field data |
|
||||
| CSV (`cmd/results.rs:1023-1075`) | bare headers; reply has `"units"` | units in headers (`fx [N]`) |
|
||||
|
||||
## CLI and MCP
|
||||
`crates/mcp/src/server.rs:26-27` instructions: "unit-consistent and never converts … use
|
||||
mm-t-s-N-MPa"; `tools.rs:105` example `model.units`; screenshot distance "in model units". The
|
||||
CLI adds no units itself (commands schema as above). Replies switch to values with units.
|
||||
|
||||
## Solvers
|
||||
- LBM (`crates/lbm/src/setup.rs`): lattice units Δx = Δt = ρ₀ = 1; ν, velocities, accelerations,
|
||||
pressure, force, heat scale through h, dt, ρ, c. Self-consistent in any system, no fixed
|
||||
factors; unchanged by SI storage. Temperatures pass unscaled (only differences matter).
|
||||
- Implicit, explicit, fem: no unit literals; thermal strain uses α(T − T_ref), Johnson–Cook uses
|
||||
room/melt in one scale: correct in kelvin. No 273.15 or Stefan–Boltzmann anywhere (radiation
|
||||
would need absolute kelvin, which SI storage gives).
|
||||
|
||||
## Literal factors
|
||||
`cargo xtask unit-factors` lists float literals equal to common factors outside the table: 548
|
||||
today (mesh 187, engine 175, geometry 34, render 33, fem 25, …). Almost all are tolerances,
|
||||
limits or bytes→MB; the real conversions are the 3MF table and the mm-t-s sample constants.
|
||||
|
||||
## Implicit "geometry in mm"
|
||||
Mesh coordinates come from mm geometry whatever `model.units` says: an m-kg-s or in-lbf-s model
|
||||
reads mm numbers as metres or inches; the only guard is the `model.units` warning. SI storage
|
||||
fixes this at the import boundary. `MAX_COORD` is "1e9 model units" in code, "±10⁹ mm" in
|
||||
`docs/formats/tagged-mesh.md:45`.
|
||||
@@ -3,7 +3,7 @@
|
||||
"values": {
|
||||
"lambda": {
|
||||
"band": 3.6152397073587395e-10,
|
||||
"value": 0.3601281681211783
|
||||
"value": 0.36012816702934053
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# NAFEMS LE6 skew plate under normal pressure (MITC4 shells)
|
||||
|
||||
Our model of the published problem: a rhombic plate with 1 m sides and a 30° skew angle, 10 mm
|
||||
thick, E = 210 GPa, ν = 0.3 (N, mm, MPa), under a uniform pressure of 0.7 kPa downward. The corners
|
||||
are A (0, 0), B (1000, 0), C (1866.03, 500) and D (866.03, 500): acute angles of 30° at A and C,
|
||||
obtuse angles of 150° at B and D. All four edges are simply supported (u_z = 0, rotations free);
|
||||
u_x = u_y = 0 at A and u_y = 0 at B hold the plate in its plane. The target is the maximum
|
||||
principal stress on the lower surface at E, the centre of the plate (933.01, 250).
|
||||
|
||||
## The mesh
|
||||
|
||||
The recipe writes the 4225 nodes and 4096 quad4 elements one by one with `mesh.node.add` and
|
||||
`mesh.element.add` ([docs/verification.md](../../../docs/verification.md)); a skewed grid is
|
||||
simpler to write than to block-mesh. Grid line k of 64 lies at (1 − cos(π k/64))/2 of the side in
|
||||
each direction, so the cells shrink towards every edge, and most at the obtuse corners B and D,
|
||||
where the plate's bending moments are singular; a uniform grid converges much more slowly there
|
||||
(below). E is node 2113, the middle of the grid. The elements run clockwise seen from +z, so each
|
||||
element's normal points down and the shell's top surface, which the nodal stresses report, is
|
||||
the plate's lower surface. The pressure is a load per volume over the thickness (0.7 kPa / 10 mm),
|
||||
the same uniform load on a flat plate.
|
||||
|
||||
The recipe was generated with this script (`python3 le6.py 64 > recipe.json`):
|
||||
|
||||
```python
|
||||
import json, math, sys
|
||||
|
||||
def le6(n):
|
||||
"""NAFEMS LE6 skew plate (mm, N, MPa): A (0, 0), B (1000, 0), C = B + s, D = A + s with
|
||||
s = 1000 (cos 30°, sin 30°); n × n cells graded towards every edge (cosine spacing), so the
|
||||
obtuse corners B and D, where the plate's stresses are singular, get the smallest cells."""
|
||||
s = (1000.0 * math.cos(math.pi / 6), 1000.0 * math.sin(math.pi / 6))
|
||||
g = lambda k: (1 - math.cos(math.pi * k / n)) / 2
|
||||
label = lambda i, j: 1 + i + (n + 1) * j
|
||||
commands = []
|
||||
for j in range(n + 1):
|
||||
for i in range(n + 1):
|
||||
commands.append({"command": "mesh.node.add", "params": {"label": label(i, j), "xyz": [1000.0 * g(i) + s[0] * g(j), s[1] * g(j), 0.0]}})
|
||||
for j in range(n):
|
||||
for i in range(n):
|
||||
# clockwise seen from +z: the element normal e3 points down, so the shell's top (+e3)
|
||||
# surface is the plate's lower surface, the one the nodal stresses report
|
||||
nodes = [label(i, j), label(i, j + 1), label(i + 1, j + 1), label(i + 1, j)]
|
||||
commands.append({"command": "mesh.element.add", "params": {"label": 1 + i + n * j, "kind": "quad4", "nodes": nodes}})
|
||||
edges = sorted({label(i, 0) for i in range(n + 1)} | {label(i, n) for i in range(n + 1)}
|
||||
| {label(0, j) for j in range(n + 1)} | {label(n, j) for j in range(n + 1)})
|
||||
return commands + [
|
||||
{"command": "model.set.create", "params": {"name": "plate", "elements": list(range(1, n * n + 1))}},
|
||||
{"command": "model.set.create", "params": {"name": "edges", "nodes": edges}},
|
||||
{"command": "model.set.create", "params": {"name": "A", "nodes": [label(0, 0)]}},
|
||||
{"command": "model.set.create", "params": {"name": "B", "nodes": [label(n, 0)]}},
|
||||
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
|
||||
{"command": "model.section.create", "params": {"name": "plate", "set": "plate", "material": "Steel", "kind": {"type": "shell", "thickness": 10.0}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "edges", "type": "displacement", "target": {"set": "edges"}, "values": [None, None, 0, None, None, None]}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "A", "type": "displacement", "target": {"set": "A"}, "values": [0, 0, None, None, None, None]}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "B", "type": "displacement", "target": {"set": "B"}, "values": [None, 0, None, None, None, None]}},
|
||||
# 0.7 kPa = 7e-4 MPa downward, spread over the 10 mm thickness as a load per volume
|
||||
{"command": "model.load.create", "params": {"name": "pressure", "type": "body_force", "set": "plate", "vector": [0, 0, -7e-5]}},
|
||||
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["edges", "A", "B"], "loads": ["pressure"]}},
|
||||
{"command": "solve.static", "params": {}, "id": "solve"},
|
||||
]
|
||||
|
||||
n = int(sys.argv[1]) if len(sys.argv) > 1 else 64
|
||||
print("[\n " + ",\n ".join(json.dumps(c) for c in le6(n)) + "\n]")
|
||||
```
|
||||
|
||||
## Refinement
|
||||
|
||||
Every row is the listing above with the row's divisions (and, for the uniform rows, the grading
|
||||
`g` replaced by k/n), solved and probed at E as the case does; the case mesh is the graded 64 × 64:
|
||||
|
||||
| cells | uniform grid | graded grid |
|
||||
|---|---|---|
|
||||
| 8 × 8 | 0.7465 | 0.7256 |
|
||||
| 16 × 16 | 0.7751 | 0.8021 |
|
||||
| 32 × 32 | 0.7952 | 0.8160 |
|
||||
| 64 × 64 (this case: graded) | 0.8088 | 0.8197 |
|
||||
| 128 × 128 | 0.8160 | 0.8210 |
|
||||
|
||||
The graded grid converges to about 0.821; the next refinement moves the case value by 0.15%.
|
||||
|
||||
## Towards the thin plate, and why the NAFEMS quantity is a known failure
|
||||
|
||||
The published target, 0.802, is a thin-plate value. Making the same model thinner, with the
|
||||
pressure scaled with the thickness squared so that the thin-plate stress stays the same, moves σ₁
|
||||
at E towards it, but slowly (graded grids, as listed above):
|
||||
|
||||
| thickness | span / thickness | 64 × 64 | 128 × 128 | 192 × 192 |
|
||||
|---|---|---|---|---|
|
||||
| 10 mm (the problem) | 100 | 0.8197 | 0.8210 | 0.8212 |
|
||||
| 3 mm | 333 | 0.8096 | 0.8121 | |
|
||||
| 1 mm | 1000 | 0.8025 | 0.8068 | 0.8077 |
|
||||
|
||||
The thinner the plate, the finer the grid it needs, so the 64 × 64 value at 1 mm (0.8025) is not
|
||||
converged; the 1 mm plate is heading for about 0.809. The gap to 0.802 falls from 2.4% at 10 mm
|
||||
to about 0.9% at 1 mm, roughly halving for each tenfold thinning. A slow approach is what a simply
|
||||
supported plate with free rotations ("soft" support) shows: the transverse shear the MITC4 shell
|
||||
keeps leaves boundary layers along the edges and at the obtuse corners that a thin-plate model has
|
||||
not. That is why these runs are recorded here and not checked in CI. A third quantity at 1 mm
|
||||
would pass only on an unconverged grid.
|
||||
|
||||
At the problem's thickness the shell converges to about 0.821, 2.4% above the thin-plate target,
|
||||
and ESRD's converged thin-solid model lands at the same place (0.820 on two meshes; 0.822 with
|
||||
triangles, p. 20). ESRD's guide also remarks that its thin-solid formulation can differ from the
|
||||
plate model behind the analytical solution (p. 22). The case therefore keeps the NAFEMS target with
|
||||
the tolerance its three figures support, 2%, marked `known_failure` until FieldCraft has a
|
||||
thin-plate (Kirchhoff) element. It checks the shell against the shear-deformable reference at 1%:
|
||||
0.8197 against 0.820.
|
||||
|
||||
`fixture.json` holds the passing quantity's value: the mesh is written by the recipe, so it is
|
||||
bit-identical on every platform.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
cargo xtask oracle --case nafems-le6
|
||||
cargo xtask oracle --bless nafems-le6 # only after the case passes
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"title": "NAFEMS LE6 skew plate under normal pressure (MITC4 shells)",
|
||||
"kind": "benchmark",
|
||||
"slow": true,
|
||||
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE6 (target: maximum principal stress 0.802 MPa on the lower surface at E, the centre of the plate), as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.6 'LE6: Skew plate under normal pressure', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 19, which also gives its converged thin-solid result, 0.820 MPa (p. 20), and notes that its thin-solid formulation can differ from the plate model behind the target (p. 22)",
|
||||
"quantities": [
|
||||
{
|
||||
"id": "s1_E",
|
||||
"quantity": "maximum principal stress at E (933.01, 250), lower surface",
|
||||
"measure": {"command": "solve.probe", "params": {"step": "Load", "at": [[933.0127018922193, 250, 0]]}, "pointer": "/nodes/0/principal/0"},
|
||||
"value": 0.802,
|
||||
"tolerance": {"relative": 0.02},
|
||||
"reason": "the target is published to three figures for the thin-plate model of the problem",
|
||||
"known_failure": "the target is a thin-plate value; at the specified 10 mm the transverse shear deformation the MITC4 shell keeps raises it to 0.8197 (+2.2%; converged about 0.821), as ESRD's converged thin-solid model does (quantity s1_E_solid), and thinner plates move towards 0.802 only slowly (about 0.809 at 1 mm, see README); FieldCraft has no thin-plate (Kirchhoff) element"
|
||||
},
|
||||
{
|
||||
"id": "s1_E_solid",
|
||||
"quantity": "maximum principal stress at E (933.01, 250), lower surface, against the converged thin-solid model",
|
||||
"measure": {"command": "solve.probe", "params": {"step": "Load", "at": [[933.0127018922193, 250, 0]]}, "pointer": "/nodes/0/principal/0"},
|
||||
"value": 0.820,
|
||||
"tolerance": {"relative": 0.01},
|
||||
"reason": "a model that keeps transverse shear, like ESRD's thin solid, which reports 0.820 on two meshes (0.822 with triangles); ours is 0.8197 and the next refinement moves it by 0.15% (see README)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
|
||||
"values": {
|
||||
"s1_E_solid": {
|
||||
"band": 8.2e-10,
|
||||
"value": 0.8196948596296774
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,8 +2,8 @@
|
||||
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
|
||||
"values": {
|
||||
"collapse_force": {
|
||||
"band": 3.2260685847425373e-7,
|
||||
"value": -322.6068584742537
|
||||
"band": 3.226068671283815e-7,
|
||||
"value": -322.6068671283815
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("engine", Class::Layer(6)),
|
||||
("gpu", Class::Layer(7)),
|
||||
("lbm-gpu", Class::Layer(7)),
|
||||
("mesh-gpu", Class::Layer(7)),
|
||||
("ui-egui", Class::Layer(7)),
|
||||
("mcp", Class::Layer(7)),
|
||||
("testkit", Class::Testkit),
|
||||
@@ -76,6 +77,8 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
||||
&["gpu", "mcp"],
|
||||
// The lattice Boltzmann GPU backend opens its device with the GPU crate's adapter policy.
|
||||
&["gpu", "lbm-gpu"],
|
||||
// So does the projection smoothing GPU device.
|
||||
&["gpu", "mesh-gpu"],
|
||||
];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
|
||||
@@ -13,6 +13,7 @@ mod layers;
|
||||
mod licences;
|
||||
mod skill;
|
||||
mod test;
|
||||
mod unit_factors;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -53,6 +54,8 @@ commands:
|
||||
and the flags CI plans its jobs with: everything (manifest, lock file,
|
||||
toolchain, xtask or workflows changed), solver (the heavy tests or the oracle
|
||||
are affected), wasm (a crate the wasm check builds is affected)
|
||||
unit-factors report literal unit-conversion factors (1e-3, 1e6, 273.15, 25.4, …) written
|
||||
outside the unit table, crates/model/src/units (report only, #579)
|
||||
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
||||
ci [--affected REV]
|
||||
fmt --check, assets, layers, clippy -D warnings, then the tests
|
||||
@@ -102,6 +105,7 @@ fn main() -> ExitCode {
|
||||
c.args(["run", "--release", "-p", affected::ORACLE_PACKAGE, "--bin", "oracle", "--"]).args(&rest);
|
||||
run(c, "oracle")
|
||||
}
|
||||
Some("unit-factors") => unit_factors::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! `cargo xtask unit-factors`: report literal unit-conversion factors (1e-3, 1e6, 273.15, 25.4, …)
|
||||
//! written outside the unit table (`crates/model/src/units/`).
|
||||
//!
|
||||
//! Issue #579 routes every conversion through the table. Until the later steps have removed the
|
||||
//! existing factors this is a report and never fails; once the count is zero it becomes a CI check.
|
||||
//! Many hits are tolerances or algorithm constants rather than conversions: the report lists them
|
||||
//! all so each can be classified.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The factors flagged, with what each usually converts.
|
||||
const FACTORS: &[(f64, &str)] = &[
|
||||
(1e-3, "milli (mm↔m, ms↔s, g↔kg)"),
|
||||
(1e3, "kilo (t↔kg, m↔mm)"),
|
||||
(1e6, "mega (MPa↔Pa, mm²↔m²)"),
|
||||
(1e9, "giga (GPa↔Pa, mm³↔m³)"),
|
||||
(1e-6, "micro (Pa↔MPa)"),
|
||||
(1e-9, "nano (Pa↔GPa, m³↔mm³)"),
|
||||
(1e12, "t/mm³↔kg/m³"),
|
||||
(1e-12, "kg/m³↔t/mm³"),
|
||||
(273.15, "°C↔K"),
|
||||
(459.67, "°F↔°R"),
|
||||
(25.4, "in↔mm"),
|
||||
(0.0254, "in↔m"),
|
||||
(9.80665, "standard gravity"),
|
||||
(9.81, "gravity"),
|
||||
(9810.0, "gravity in mm/s²"),
|
||||
];
|
||||
|
||||
/// The one place factors belong.
|
||||
const TABLE: &str = "crates/model/src/units/";
|
||||
const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist", "xtask", "tests", "node_modules"];
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let mut files = Vec::new();
|
||||
for dir in ["crates", "apps"] {
|
||||
walk(&root.join(dir), &mut files);
|
||||
}
|
||||
files.sort();
|
||||
let mut hits = Vec::new();
|
||||
for path in &files {
|
||||
let rel = path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/");
|
||||
if rel.starts_with(TABLE) {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { continue };
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
// Unit tests sit at the end of a file behind `#[cfg(test)]`; their values are data.
|
||||
if line.trim_start().starts_with("#[cfg(test)]") {
|
||||
break;
|
||||
}
|
||||
let code = line.split("//").next().unwrap_or("");
|
||||
for value in literals(code) {
|
||||
if let Some((_, what)) = FACTORS.iter().find(|(f, _)| *f == value) {
|
||||
hits.push(format!("{rel}:{}: {value:e} ({what}): {}", n + 1, line.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for h in &hits {
|
||||
println!("{h}");
|
||||
}
|
||||
println!("unit-factors: {} literal factor(s) outside {TABLE} (report only; #579 drives this to zero)", hits.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if p.is_dir() {
|
||||
if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
|
||||
walk(&p, out);
|
||||
}
|
||||
} else if p.extension().is_some_and(|x| x == "rs") {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating-point literals on a line of code, parsed (`1_000.0`, `1e-3`,
|
||||
/// `273.15_f64`). Digits inside identifiers (`vec3`) are skipped.
|
||||
fn literals(code: &str) -> Vec<f64> {
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let prev_ident = i > 0 && chars.get(i - 1).is_some_and(|p| p.is_alphanumeric() || *p == '_' || *p == '.');
|
||||
if !c.is_ascii_digit() || prev_ident {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while let Some(&d) = chars.get(i) {
|
||||
let exp_sign = matches!(d, '+' | '-') && matches!(chars.get(i.wrapping_sub(1)), Some('e' | 'E'));
|
||||
if d.is_ascii_digit() || d == '_' || d == '.' || d == 'e' || d == 'E' || exp_sign {
|
||||
// `0..n` is a range, `1.max(x)` a method call: stop at a dot not followed by a digit.
|
||||
if d == '.' && !chars.get(i + 1).is_some_and(char::is_ascii_digit) {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let text: String = chars.get(start..i).unwrap_or_default().iter().filter(|c| **c != '_').collect();
|
||||
let suffix_start = i;
|
||||
// Skip a type suffix (`f64`, `_f32`, `u8`).
|
||||
while chars.get(i).is_some_and(|c| c.is_alphanumeric() || *c == '_') {
|
||||
i += 1;
|
||||
}
|
||||
let suffix: String = chars.get(suffix_start..i).unwrap_or_default().iter().collect();
|
||||
// Conversions are floating-point arithmetic; integer literals are counts and caps.
|
||||
let float = text.contains(['.', 'e', 'E']) || suffix.trim_start_matches('_').starts_with('f');
|
||||
if float && let Ok(v) = text.parse::<f64>() {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::literals;
|
||||
|
||||
#[test]
|
||||
fn literals_on_a_line() {
|
||||
assert_eq!(literals("let x = 1e-3 * y + 273.15_f64;"), vec![1e-3, 273.15]);
|
||||
assert_eq!(literals("for i in 0..1000 { vec3(1_000.0) }"), vec![1000.0]);
|
||||
assert_eq!(literals("a.max(1.0e6) - 2.max(b)"), vec![1e6]);
|
||||
assert_eq!(literals("t.0 + x1 + 25.4f32 + 3f64"), vec![25.4, 3.0]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user