Skip to main content

sim_lib_femm_core/
implementation.rs

1#![forbid(unsafe_code)]
2//! Core FEMM substrate: ids, vocabulary, parameters, numeric helpers, and library wiring.
3//!
4//! Defines the stable ids, physics/formulation/unit vocabulary, parameter
5//! specs and sets, limits, scalar decoding helpers, and runtime registration
6//! shared by every other FEMM crate.
7
8use std::{
9    any::Any,
10    collections::BTreeSet,
11    hash::{Hash, Hasher},
12    sync::Arc,
13    time::Duration,
14};
15
16use sim_kernel::{
17    AbiVersion, Args, Callable, ClassRef, Cx, DefaultFactory, Dependency, Expr, Factory, Lib,
18    LibManifest, LibTarget, Linker, Object, RawArgs, Result as KernelResult, Symbol, Value,
19    Version,
20};
21
22use crate::{FemmError, FemmResult};
23
24/// Stable 64-bit identity derived by hashing a value's content.
25///
26/// FEMM uses these as content fingerprints for caches and change detection
27/// across parameter sets and matrices; they are deterministic for a given
28/// input but not portable across hasher implementations.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct StableId(pub u64);
31
32impl StableId {
33    /// Compute a [`StableId`] from any [`Hash`]able value.
34    pub fn from_hashable<T: Hash>(value: &T) -> Self {
35        let mut hasher = std::collections::hash_map::DefaultHasher::new();
36        value.hash(&mut hasher);
37        Self(hasher.finish())
38    }
39}
40
41/// The physics problem a FEMM model solves.
42///
43/// The supported finite-element formulations the downstream physics crates
44/// dispatch on; see [`femm_capabilities`] for the advertised set.
45#[derive(Clone, Debug, PartialEq, Eq, Hash)]
46pub enum PhysicsKind {
47    /// Time-invariant magnetic field problem.
48    Magnetostatic,
49    /// Time-harmonic (frequency-domain) magnetics problem.
50    MagneticsHarmonic,
51    /// Time-invariant electric field problem.
52    Electrostatic,
53    /// Steady-state heat conduction problem.
54    HeatSteady,
55    /// Steady-state electric current flow problem.
56    CurrentSteady,
57}
58
59/// Geometric formulation under which a 2D model is interpreted.
60#[derive(Clone, Debug, PartialEq, Eq, Hash)]
61pub enum Formulation {
62    /// Planar (extruded) geometry with unit depth.
63    Planar,
64    /// Axisymmetric geometry revolved about an axis.
65    Axisymmetric,
66}
67
68/// Length unit a model's coordinates are expressed in.
69#[derive(Clone, Debug, PartialEq, Eq, Hash)]
70pub enum LengthUnit {
71    /// SI meter.
72    Meter,
73    /// Millimeter.
74    Millimeter,
75    /// Inch.
76    Inch,
77    /// A caller-named unit identified by [`Symbol`].
78    Custom(Symbol),
79}
80
81/// The role a model parameter plays in a FEMM study.
82///
83/// Classifies entries of a [`ParamSet`] so downstream crates can route design,
84/// excitation, ODE state, and other parameters appropriately.
85#[derive(Clone, Debug, PartialEq, Eq, Hash)]
86pub enum ParamRole {
87    /// A design variable that may be swept or optimized.
88    Design,
89    /// A source or boundary excitation magnitude.
90    Excitation,
91    /// A state variable advanced by an ODE integrator.
92    OdeState,
93    /// A time coordinate.
94    Time,
95    /// A geometric dimension.
96    Geometry,
97    /// A material property value.
98    Material,
99}
100
101/// Declaration of a single model parameter: its name, default, unit, and role.
102///
103/// Describes the shape of an input a FEMM model accepts, independent of any
104/// concrete binding in a [`ParamSet`].
105#[derive(Clone, Debug)]
106pub struct ParamSpec {
107    /// Parameter name.
108    pub name: Symbol,
109    /// Default kernel [`Value`] used when the parameter is unbound, if any.
110    pub default: Option<Value>,
111    /// Unit symbol the value is expressed in, if any.
112    pub unit: Option<Symbol>,
113    /// The [`ParamRole`] this parameter plays in a study.
114    pub role: ParamRole,
115}
116
117/// An ordered set of name-to-[`Value`] parameter bindings for a model run.
118///
119/// The concrete inputs supplied to a FEMM evaluation; lookups are by [`Symbol`]
120/// and the whole set can be fingerprinted into a [`StableId`]. See the
121/// [crate README](https://github.com/sim-nest/sim-femm) for the FEM role of
122/// parameter sets.
123///
124/// # Examples
125///
126/// ```
127/// use sim_lib_femm_core::ParamSet;
128/// use sim_kernel::{DefaultFactory, Factory, Symbol};
129///
130/// let radius = Symbol::new("radius");
131/// let value = DefaultFactory.string("0.5".to_owned()).unwrap();
132/// let params = ParamSet::new(vec![(radius.clone(), value)]);
133/// assert!(params.get(&radius).is_some());
134/// assert!(params.symbols().contains(&radius));
135/// ```
136#[derive(Clone, Debug, Default)]
137pub struct ParamSet {
138    /// The name/value bindings, in insertion order.
139    pub entries: Vec<(Symbol, Value)>,
140}
141
142impl ParamSet {
143    /// Build a [`ParamSet`] from name/value bindings.
144    pub fn new(entries: Vec<(Symbol, Value)>) -> Self {
145        Self { entries }
146    }
147
148    /// Look up the [`Value`] bound to `name`, if present.
149    pub fn get(&self, name: &Symbol) -> Option<&Value> {
150        self.entries
151            .iter()
152            .find(|(symbol, _)| symbol == name)
153            .map(|(_, value)| value)
154    }
155
156    /// Return the bound parameter names as a sorted set.
157    pub fn symbols(&self) -> BTreeSet<Symbol> {
158        self.entries
159            .iter()
160            .map(|(symbol, _)| symbol.clone())
161            .collect()
162    }
163
164    /// Compute a content [`StableId`] over the displayed bindings.
165    ///
166    /// Renders each value through the kernel's display path under `cx`, so the
167    /// fingerprint reflects value content rather than object identity.
168    pub fn fingerprint(&self, cx: &mut Cx) -> StableId {
169        let mut text = String::new();
170        for (symbol, value) in &self.entries {
171            let display = value
172                .object()
173                .display(cx)
174                .unwrap_or_else(|_| "#<display-error>".to_owned());
175            text.push_str(&symbol.to_string());
176            text.push('=');
177            text.push_str(&display);
178            text.push(';');
179        }
180        StableId::from_hashable(&text)
181    }
182}
183
184/// Resource ceilings enforced across a FEMM study to bound work.
185///
186/// Caps mesh size, solver effort, output volume, and wall time so a single
187/// evaluation cannot exhaust host resources; [`Default`] supplies safe limits.
188#[derive(Clone, Debug, PartialEq, Eq, Hash)]
189pub struct FemmLimits {
190    /// Maximum mesh node count.
191    pub max_nodes: usize,
192    /// Maximum mesh element count.
193    pub max_elements: usize,
194    /// Maximum number of stored nonzeros in an assembled matrix.
195    pub max_nnz: usize,
196    /// Maximum iterations for a single linear/nonlinear solve.
197    pub max_solve_iters: usize,
198    /// Maximum number of output samples produced by post-processing.
199    pub max_output_samples: usize,
200    /// Maximum number of FEMM solves in one study.
201    pub max_femm_solves: usize,
202    /// Maximum wall-clock budget in milliseconds.
203    pub max_wall_ms: u64,
204}
205
206impl Default for FemmLimits {
207    fn default() -> Self {
208        Self {
209            max_nodes: 10_000,
210            max_elements: 20_000,
211            max_nnz: 200_000,
212            max_solve_iters: 4_000,
213            max_output_samples: 20_000,
214            max_femm_solves: 1_000,
215            max_wall_ms: Duration::from_secs(30).as_millis() as u64,
216        }
217    }
218}
219
220/// List the capability tokens this FEMM build advertises.
221///
222/// Combines the always-present [`PhysicsKind`] names with availability flags
223/// for optional sim-numbers backends: field domain, fixed-step ODE, and adjoint
224/// differentiator.
225pub fn femm_capabilities(
226    installed_field: bool,
227    installed_ptc: bool,
228    installed_adjoint: bool,
229) -> Vec<String> {
230    let mut values = vec![
231        "Magnetostatic".to_owned(),
232        "MagneticsHarmonic".to_owned(),
233        "Electrostatic".to_owned(),
234        "HeatSteady".to_owned(),
235        "CurrentSteady".to_owned(),
236    ];
237    values.push(
238        if installed_ptc {
239            "femm-ptc:installed"
240        } else {
241            "femm-ptc:unavailable"
242        }
243        .to_owned(),
244    );
245    values.push(
246        if installed_adjoint {
247            "femm-adjoint:installed"
248        } else {
249            "femm-adjoint:unavailable"
250        }
251        .to_owned(),
252    );
253    values.push(
254        if installed_field {
255            "numbers/field:installed"
256        } else {
257            "numbers/field:unavailable"
258        }
259        .to_owned(),
260    );
261    values
262}
263
264/// Parse a finite scalar, accepting a plain decimal or a `num/den` rational.
265///
266/// The shared text-to-`f64` rule used by FEMM expression and value decoders.
267/// Malformed text, zero rational denominators, and non-finite values are
268/// rejected.
269///
270/// # Examples
271///
272/// ```
273/// use sim_lib_femm_core::parse_finite_number;
274///
275/// assert_eq!(parse_finite_number("0.5"), Some(0.5));
276/// assert_eq!(parse_finite_number("3/4"), Some(0.75));
277/// assert_eq!(parse_finite_number("1/0"), None);
278/// assert_eq!(parse_finite_number("inf"), None);
279/// ```
280pub fn parse_finite_number(text: &str) -> Option<f64> {
281    let value = if let Some((num, den)) = text.split_once('/') {
282        let num = num.parse::<f64>().ok()?;
283        let den = den.parse::<f64>().ok()?;
284        if den == 0.0 {
285            return None;
286        }
287        num / den
288    } else {
289        text.parse::<f64>().ok()?
290    };
291    value.is_finite().then_some(value)
292}
293
294/// Parse a displayed scalar, accepting a plain decimal or a `num/den` rational.
295///
296/// This compatibility wrapper uses [`parse_finite_number`], so displayed
297/// scalars are finite-only.
298pub fn parse_displayed_number(text: &str) -> Option<f64> {
299    parse_finite_number(text)
300}
301
302/// Decode a kernel [`Value`] to `f64` via its display and [`parse_displayed_number`].
303///
304/// Fails with [`FemmError::InvalidGeometry`] when the value does not display as
305/// a scalar number.
306pub fn value_as_f64(cx: &mut Cx, value: &Value) -> FemmResult<f64> {
307    let display = value
308        .object()
309        .display(cx)
310        .map_err(|err| FemmError::InvalidGeometry(err.to_string()))?;
311    parse_displayed_number(&display)
312        .ok_or_else(|| FemmError::InvalidGeometry(format!("expected scalar number, got {display}")))
313}
314
315/// Render a deterministic `name(field=value, ...)` summary string.
316///
317/// Used to build stable, human-readable descriptors that also feed content
318/// fingerprints.
319pub fn stable_summary(name: &str, fields: &[(&str, String)]) -> String {
320    let mut out = format!("{name}(");
321    for (index, (field, value)) in fields.iter().enumerate() {
322        if index > 0 {
323            out.push_str(", ");
324        }
325        out.push_str(field);
326        out.push('=');
327        out.push_str(value);
328    }
329    out.push(')');
330    out
331}
332
333fn version_symbol() -> Symbol {
334    Symbol::qualified("femm", "version")
335}
336
337fn capabilities_symbol() -> Symbol {
338    Symbol::qualified("femm", "capabilities")
339}
340
341#[derive(Clone)]
342struct FemmCoreFunction {
343    symbol: Symbol,
344}
345
346impl Object for FemmCoreFunction {
347    fn display(&self, _cx: &mut Cx) -> KernelResult<String> {
348        Ok(format!("#<function {}>", self.symbol))
349    }
350
351    fn as_any(&self) -> &dyn Any {
352        self
353    }
354}
355
356impl sim_kernel::ObjectCompat for FemmCoreFunction {
357    fn class(&self, cx: &mut Cx) -> KernelResult<ClassRef> {
358        if let Some(class) = cx
359            .registry()
360            .class_by_symbol(&Symbol::qualified("core", "Function"))
361        {
362            return Ok(class.clone());
363        }
364        DefaultFactory.class_stub(
365            sim_kernel::CORE_FUNCTION_CLASS_ID,
366            Symbol::qualified("core", "Function"),
367        )
368    }
369    fn as_expr(&self, _cx: &mut Cx) -> KernelResult<Expr> {
370        Ok(Expr::Symbol(self.symbol.clone()))
371    }
372    fn as_callable(&self) -> Option<&dyn Callable> {
373        Some(self)
374    }
375}
376
377impl Callable for FemmCoreFunction {
378    fn call(&self, cx: &mut Cx, _args: Args) -> KernelResult<Value> {
379        if self.symbol == version_symbol() {
380            return cx.factory().string("0.1.0".to_owned());
381        }
382        let installed_field = cx
383            .registry()
384            .number_domain_by_symbol(&Symbol::qualified("numbers", "field"))
385            .is_some();
386        let installed_ptc = sim_lib_numbers_numeric::global_numeric_registry()
387            .read()
388            .map(|registry| registry.ode_fixed(&Symbol::new("femm-ptc")).is_some())
389            .unwrap_or(false);
390        let installed_adjoint = sim_lib_numbers_numeric::global_numeric_registry()
391            .read()
392            .map(|registry| {
393                registry
394                    .differentiator(&Symbol::new("femm-adjoint"))
395                    .is_some()
396            })
397            .unwrap_or(false);
398        let values = femm_capabilities(installed_field, installed_ptc, installed_adjoint)
399            .into_iter()
400            .map(|item| cx.factory().string(item))
401            .collect::<KernelResult<Vec<_>>>()?;
402        cx.factory().list(values)
403    }
404
405    fn call_exprs(&self, cx: &mut Cx, _args: RawArgs) -> KernelResult<Value> {
406        self.call(cx, Args::default())
407    }
408}
409
410/// The loadable [`Lib`] that registers the FEMM core functions with a runtime.
411///
412/// Realizes the kernel [`Lib`] contract: its manifest declares the `femm.core`
413/// library and exports the `femm.version` and `femm.capabilities` functions,
414/// and [`Lib::load`] links them into the host registry.
415pub struct FemmCoreLib;
416
417impl FemmCoreLib {
418    /// Construct the FEMM core library handle.
419    pub fn new() -> Self {
420        Self
421    }
422}
423
424impl Default for FemmCoreLib {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430impl Lib for FemmCoreLib {
431    fn manifest(&self) -> LibManifest {
432        LibManifest {
433            id: Symbol::qualified("femm", "core"),
434            version: Version(env!("CARGO_PKG_VERSION").to_owned()),
435            abi: AbiVersion { major: 0, minor: 1 },
436            target: LibTarget::HostRegistered,
437            requires: vec![Dependency {
438                id: Symbol::qualified("numbers", "numeric"),
439                minimum_version: None,
440            }],
441            capabilities: Vec::new(),
442            exports: vec![
443                sim_kernel::Export::Function {
444                    symbol: version_symbol(),
445                    function_id: None,
446                },
447                sim_kernel::Export::Function {
448                    symbol: capabilities_symbol(),
449                    function_id: None,
450                },
451            ],
452        }
453    }
454
455    fn load(&self, _cx: &mut sim_kernel::LoadCx, linker: &mut Linker<'_>) -> KernelResult<()> {
456        for symbol in [version_symbol(), capabilities_symbol()] {
457            linker.function_value(
458                symbol.clone(),
459                DefaultFactory.opaque(Arc::new(FemmCoreFunction { symbol }))?,
460            )?;
461        }
462        Ok(())
463    }
464}
465
466#[cfg(test)]
467mod tests;