Skip to main content

sim_lib_femm_query/
implementation.rs

1#![forbid(unsafe_code)]
2//! Model query callables shared by FEMM function and sensitivity crates.
3
4use std::{any::Any, sync::Arc};
5
6use sim_kernel::{
7    ClassId, ClassRef, Cx, DefaultFactory, Expr, Factory, Object, ObjectEncode, ObjectEncoding,
8    Result as KernelResult, Symbol, Value,
9};
10use sim_lib_femm_core::{FemmError, FemmLimits, FemmResult, ParamSet};
11use sim_lib_femm_field::{Field, Projection};
12use sim_lib_femm_material::{BoundaryKind, Source};
13use sim_lib_femm_mesh::FemmModel;
14use sim_lib_femm_post::{Excitation, FemmSolution, QuantitySpec, quantity};
15use sim_lib_femm_solve::solve_steady;
16use sim_lib_numbers_func::{Func, FuncMetadata};
17
18/// One evaluation request against a model: which parameters, output, and limits.
19///
20/// Couples a parameter binding with the [`OutputQuery`] to compute and the
21/// solver [`FemmLimits`]; `want_grad` names parameters whose sensitivities the
22/// caller also wants. See the [crate README](index.html).
23#[derive(Clone, Debug)]
24pub struct FemmCall {
25    /// The parameter binding the model is evaluated at.
26    pub params: ParamSet,
27    /// The output to compute from the solved model.
28    pub query: OutputQuery,
29    /// Parameters to also report sensitivities for, if any.
30    pub want_grad: Option<Vec<Symbol>>,
31    /// Solver budget and tolerances for this evaluation.
32    pub limits: FemmLimits,
33}
34
35/// The kind of output an evaluation produces from a solved model.
36#[derive(Clone, Debug)]
37pub enum OutputQuery {
38    /// A scalar quantity reduced from the solution (energy, flux, capacitance).
39    Quantity(QuantitySpec),
40    /// A projected field (potential or a derived component) over the mesh.
41    Field(Projection),
42    /// The full solved model solution as an opaque value.
43    Solution,
44}
45
46/// The result of evaluating a model: the output value plus optional gradient.
47#[derive(Clone, Debug)]
48pub struct FemmEval {
49    /// The computed output value (scalar, field, or solution).
50    pub value: Value,
51    /// Per-parameter sensitivities, when a gradient was requested.
52    pub gradient: Option<Vec<(Symbol, f64)>>,
53    /// Diagnostics emitted while solving and reducing the output.
54    pub diagnostics: Vec<sim_kernel::Diagnostic>,
55}
56
57/// The opaque payload carried by a model-derived runtime function.
58///
59/// Recorded in a [`Func`]'s metadata so a differentiator can recover the model,
60/// its free variables, and the queried output to build an adjoint pass.
61#[derive(Clone)]
62pub struct FemmFuncPayload {
63    /// The model the function evaluates.
64    pub model: FemmModel,
65    /// The model inputs treated as the function's free variables.
66    pub vars: Vec<Symbol>,
67    /// The output the function returns.
68    pub query: OutputQuery,
69}
70
71impl Object for FemmFuncPayload {
72    fn display(&self, _cx: &mut Cx) -> KernelResult<String> {
73        Ok(format!(
74            "#<femm-payload model={} query={}>",
75            self.model.id.0,
76            describe_query(&self.query)
77        ))
78    }
79
80    fn as_any(&self) -> &dyn Any {
81        self
82    }
83}
84
85impl sim_kernel::ObjectCompat for FemmFuncPayload {
86    fn class(&self, cx: &mut Cx) -> KernelResult<ClassRef> {
87        if let Some(class) = cx
88            .registry()
89            .class_by_symbol(&Symbol::qualified("femm", "FuncPayload"))
90        {
91            return Ok(class.clone());
92        }
93        DefaultFactory.class_stub(ClassId(33), Symbol::qualified("femm", "FuncPayload"))
94    }
95    fn as_expr(&self, cx: &mut Cx) -> KernelResult<Expr> {
96        sim_citizen::constructor_expr(cx, self)
97    }
98    fn as_object_encoder(&self) -> Option<&dyn ObjectEncode> {
99        Some(self)
100    }
101}
102
103impl ObjectEncode for FemmFuncPayload {
104    fn object_encoding(&self, _cx: &mut Cx) -> KernelResult<ObjectEncoding> {
105        Ok(ObjectEncoding::Constructor {
106            class: func_payload_class_symbol(),
107            args: payload_constructor_args(self),
108        })
109    }
110}
111
112impl sim_citizen::Citizen for FemmFuncPayload {
113    fn citizen_symbol() -> Symbol {
114        func_payload_class_symbol()
115    }
116
117    fn citizen_version() -> u32 {
118        1
119    }
120
121    fn citizen_arity() -> usize {
122        3
123    }
124
125    fn citizen_fields() -> &'static [&'static str] {
126        &["model_id", "query", "vars"]
127    }
128}
129
130fn func_payload_class_symbol() -> Symbol {
131    Symbol::qualified("femm", "FuncPayload")
132}
133
134fn payload_constructor_args(payload: &FemmFuncPayload) -> Vec<Expr> {
135    vec![
136        Expr::Symbol(Symbol::new("v1")),
137        int_expr(payload.model.id.0),
138        Expr::String(describe_query(&payload.query)),
139        Expr::List(
140            payload
141                .vars
142                .iter()
143                .map(|name| Expr::String(name.to_string()))
144                .collect(),
145        ),
146    ]
147}
148
149fn int_expr(value: impl ToString) -> Expr {
150    Expr::Number(sim_kernel::NumberLiteral {
151        domain: Symbol::qualified("citizen", "int"),
152        canonical: value.to_string(),
153    })
154}
155
156/// Something that can be evaluated as a FEMM function of its parameters.
157pub trait FemmCallable {
158    /// Evaluates the callable for one [`FemmCall`], returning its output.
159    fn eval(&self, cx: &mut Cx, call: FemmCall) -> FemmResult<FemmEval>;
160}
161
162/// A [`FemmCallable`] that solves a concrete model on each evaluation.
163///
164/// Resolves defaults for any unbound inputs, runs the steady solve, and reduces
165/// the solution to the requested [`OutputQuery`].
166#[derive(Clone)]
167pub struct ModelCallable {
168    /// The model solved on each call.
169    pub model: FemmModel,
170}
171
172impl ModelCallable {
173    fn solve_solution(
174        &self,
175        cx: &mut Cx,
176        params: &ParamSet,
177        limits: &FemmLimits,
178    ) -> FemmResult<Arc<FemmSolution>> {
179        let resolved = resolve_model_params(&self.model, params.clone())?;
180        solve_steady(cx, &self.model, &resolved, limits, None).map(|out| out.solution)
181    }
182}
183
184impl FemmCallable for ModelCallable {
185    fn eval(&self, cx: &mut Cx, call: FemmCall) -> FemmResult<FemmEval> {
186        let resolved = resolve_model_params(&self.model, call.params)?;
187        match call.query {
188            OutputQuery::Quantity(QuantitySpec::Custom { expr, .. }) => {
189                let value = sim_lib_femm_geometry::eval_expr_f64(cx, &expr, &resolved, &[])?;
190                Ok(FemmEval {
191                    value: cx
192                        .factory()
193                        .number_literal(Symbol::qualified("numbers", "f64"), value.to_string())
194                        .map_err(|err| FemmError::SensitivityUnavailable(err.to_string()))?,
195                    gradient: None,
196                    diagnostics: Vec::new(),
197                })
198            }
199            OutputQuery::Quantity(spec) => {
200                let solution = self.solve_solution(cx, &resolved, &call.limits)?;
201                let excitation = resolve_excitation(cx, &self.model, &resolved, &spec)?;
202                let scalar = quantity(&solution, &spec, &excitation)?;
203                Ok(FemmEval {
204                    value: cx
205                        .factory()
206                        .number_literal(Symbol::qualified("numbers", "f64"), scalar.to_string())
207                        .map_err(|err| FemmError::SensitivityUnavailable(err.to_string()))?,
208                    gradient: None,
209                    diagnostics: Vec::new(),
210                })
211            }
212            OutputQuery::Field(projection) => {
213                let solution = self.solve_solution(cx, &resolved, &call.limits)?;
214                let field = Field::new(solution, projection);
215                Ok(FemmEval {
216                    value: cx
217                        .factory()
218                        .opaque(Arc::new(field))
219                        .map_err(|err| FemmError::SensitivityUnavailable(err.to_string()))?,
220                    gradient: None,
221                    diagnostics: Vec::new(),
222                })
223            }
224            OutputQuery::Solution => {
225                let solution = self.solve_solution(cx, &resolved, &call.limits)?;
226                Ok(FemmEval {
227                    value: cx
228                        .factory()
229                        .opaque(solution)
230                        .map_err(|err| FemmError::SensitivityUnavailable(err.to_string()))?,
231                    gradient: None,
232                    diagnostics: Vec::new(),
233                })
234            }
235        }
236    }
237}
238
239/// Resolves a model parameter set by inserting defaults for missing model inputs.
240///
241/// A missing input without a default is an error. This is the single defaulting
242/// rule used by model calls and sensitivity plugin evaluation.
243pub fn resolve_model_params(model: &FemmModel, params: ParamSet) -> FemmResult<ParamSet> {
244    let mut entries = params.entries;
245    for input in &model.inputs {
246        if entries.iter().all(|(name, _)| name != &input.name) {
247            let Some(default) = &input.default else {
248                return Err(FemmError::UnknownFemmParameter(input.name.to_string()));
249            };
250            entries.push((input.name.clone(), default.clone()));
251        }
252    }
253    Ok(ParamSet::new(entries))
254}
255
256/// Resolves the [`Excitation`] a derived quantity is evaluated against.
257///
258/// Inductance and flux linkage read the driving current of the named circuit
259/// coil source; capacitance reads the applied potential of the named conductor
260/// (its Dirichlet boundary). Quantities that do not depend on an excitation
261/// resolve to [`Excitation::none`]. A source the model does not define leaves
262/// the excitation unset, so [`quantity`] reports the precise missing-drive
263/// error rather than a silent wrong value.
264pub fn resolve_excitation(
265    cx: &mut Cx,
266    model: &FemmModel,
267    params: &ParamSet,
268    spec: &QuantitySpec,
269) -> FemmResult<Excitation> {
270    match spec {
271        QuantitySpec::Inductance { circuit } | QuantitySpec::FluxLinkage { circuit } => {
272            Ok(coil_current(cx, model, params, circuit)?
273                .map(Excitation::with_current)
274                .unwrap_or_else(Excitation::none))
275        }
276        QuantitySpec::Capacitance { conductor } => {
277            Ok(conductor_potential(cx, model, params, conductor)?
278                .map(Excitation::with_potential)
279                .unwrap_or_else(Excitation::none))
280        }
281        _ => Ok(Excitation::none()),
282    }
283}
284
285fn coil_current(
286    cx: &mut Cx,
287    model: &FemmModel,
288    params: &ParamSet,
289    circuit: &Symbol,
290) -> FemmResult<Option<f64>> {
291    for source in &model.sources {
292        if let Source::CircuitCoil { name, current, .. } = source
293            && name == circuit
294        {
295            return sim_lib_femm_geometry::eval_expr_f64(cx, current, params, &[]).map(Some);
296        }
297    }
298    Ok(None)
299}
300
301fn conductor_potential(
302    cx: &mut Cx,
303    model: &FemmModel,
304    params: &ParamSet,
305    conductor: &Symbol,
306) -> FemmResult<Option<f64>> {
307    for boundary in &model.boundaries {
308        if &boundary.name == conductor && matches!(boundary.kind, BoundaryKind::Dirichlet) {
309            return sim_lib_femm_geometry::eval_expr_f64(cx, &boundary.value, params, &[])
310                .map(Some);
311        }
312    }
313    Ok(None)
314}
315
316/// Wraps a model as a sim-numbers [`Func`] of the named variables.
317///
318/// The returned function solves the model on call and reduces it to `query`;
319/// its metadata carries a [`FemmFuncPayload`] and an adjoint differentiator
320/// hint so sensitivity analysis can recover the model.
321///
322/// # Examples
323///
324/// ```
325/// use sim_kernel::Symbol;
326/// use sim_lib_femm_fixtures::parallel_plate_capacitor;
327/// use sim_lib_femm_post::QuantitySpec;
328/// use sim_lib_femm_query::{OutputQuery, femm_as_func};
329///
330/// let vars = vec![Symbol::new("gap-mm")];
331/// let func = femm_as_func(
332///     parallel_plate_capacitor(),
333///     vars.clone(),
334///     OutputQuery::Quantity(QuantitySpec::Energy { region: None }),
335/// );
336/// assert_eq!(func.vars, vars);
337/// ```
338pub fn femm_as_func(model: FemmModel, vars: Vec<Symbol>, query: OutputQuery) -> Func {
339    let callable = ModelCallable {
340        model: model.clone(),
341    };
342    let closure_vars = vars.clone();
343    let payload_vars = closure_vars.clone();
344    let closure_query = query.clone();
345    let mut func = Func::native(
346        vars,
347        Arc::new(move |cx, args| {
348            let params = ParamSet::new(
349                closure_vars
350                    .iter()
351                    .cloned()
352                    .zip(args.iter().cloned())
353                    .collect::<Vec<_>>(),
354            );
355            callable
356                .eval(
357                    cx,
358                    FemmCall {
359                        params,
360                        query: closure_query.clone(),
361                        want_grad: None,
362                        limits: FemmLimits::default(),
363                    },
364                )
365                .map(|out| out.value)
366                .map_err(sim_kernel::Error::from)
367        }),
368    );
369    func.metadata = FuncMetadata {
370        source: Some(Symbol::qualified("femm", "model")),
371        differentiator_hint: Some(Symbol::new("femm-adjoint")),
372        payload: DefaultFactory
373            .opaque(Arc::new(FemmFuncPayload {
374                model: model.clone(),
375                vars: payload_vars,
376                query: query.clone(),
377            }))
378            .ok(),
379    };
380    func
381}
382
383/// Wraps a model's potential field as a sim-numbers [`Func`] over position.
384///
385/// The returned function solves `model` with its default parameters and samples
386/// the solved potential field at `(x, y)`. Mesh or solve failures propagate
387/// through the callable boundary; this path never fabricates a replacement
388/// solution.
389pub fn femm_field_func(model: FemmModel) -> Func {
390    Func::native(
391        vec![Symbol::new("x"), Symbol::new("y")],
392        Arc::new(move |cx, args| {
393            let x =
394                sim_lib_femm_core::value_as_f64(cx, &args[0]).map_err(sim_kernel::Error::from)?;
395            let y =
396                sim_lib_femm_core::value_as_f64(cx, &args[1]).map_err(sim_kernel::Error::from)?;
397            let solution = solve_steady(
398                cx,
399                &model,
400                &ParamSet::default(),
401                &FemmLimits::default(),
402                None,
403            )
404            .map_err(sim_kernel::Error::from)?
405            .solution;
406            let field = Field::new(solution, Projection::Potential);
407            cx.factory().number_literal(
408                Symbol::qualified("numbers", "f64"),
409                field.at(x, y).map_err(sim_kernel::Error::from)?.to_string(),
410            )
411        }),
412    )
413}
414
415/// Human-readable label for an output query.
416pub fn describe_query(query: &OutputQuery) -> String {
417    match query {
418        OutputQuery::Quantity(QuantitySpec::Custom { name, .. }) => format!("quantity:{name}"),
419        OutputQuery::Quantity(_) => "quantity".to_owned(),
420        OutputQuery::Field(projection) => format!("field:{projection:?}"),
421        OutputQuery::Solution => "solution".to_owned(),
422    }
423}