Skip to main content

sim_lib_femm_codec/
citizen.rs

1//! Citizen descriptors for FEMM domain objects.
2//!
3//! Compact, codec-addressable records (field, geometry, material, mesh, space)
4//! that let FEMM objects round-trip across the runtime's codec surfaces.
5
6use sim_citizen_derive::Citizen;
7use sim_kernel::Symbol;
8use sim_lib_femm_field::{Field, Projection};
9use sim_lib_femm_function::{FemmFuncPayload, OutputQuery};
10use sim_lib_femm_mesh::{FemMesh2, FemmModel};
11use sim_lib_femm_post::{FemmSolution, QuantitySpec};
12
13use crate::support::{formulation_name, physics_name};
14
15/// Codec-addressable descriptor for a solved [`Field`] projection.
16///
17/// Carries the source solution id and the projected quantity name so a field
18/// round-trips across codec surfaces under the `femm/Field` citizen symbol.
19#[derive(Clone, Debug, PartialEq, Citizen)]
20#[citizen(symbol = "femm/Field", version = 1)]
21pub struct FemmFieldDescriptor {
22    /// Id of the solution the field was projected from.
23    pub solution_id: u64,
24    /// Name of the projected quantity (for example `"bmag"`).
25    #[citizen(with = "descriptor_text")]
26    pub projection: String,
27}
28
29/// Codec-addressable descriptor for a FEMM model geometry.
30///
31/// Summarizes region and boundary counts plus an artifact reference under the
32/// `femm/Geometry` citizen symbol.
33#[derive(Clone, Debug, PartialEq, Citizen)]
34#[citizen(symbol = "femm/Geometry", version = 1)]
35pub struct FemmGeometryDescriptor {
36    /// Number of labeled regions in the geometry.
37    pub region_count: usize,
38    /// Number of boundary segments in the geometry.
39    pub boundary_count: usize,
40    /// Reference to the backing geometry artifact.
41    #[citizen(with = "descriptor_text")]
42    pub artifact_ref: String,
43}
44
45/// Codec-addressable descriptor for a FEMM material.
46///
47/// Names the material and lists its property keys under the `femm/Material`
48/// citizen symbol.
49#[derive(Clone, Debug, PartialEq, Citizen)]
50#[citizen(symbol = "femm/Material", version = 1)]
51pub struct FemmMaterialDescriptor {
52    /// Material name (for example `"air"`).
53    #[citizen(with = "descriptor_text")]
54    pub name: String,
55    /// Property keys carried by the material (for example `"mu-r"`).
56    pub properties: Vec<String>,
57}
58
59/// Codec-addressable descriptor for a FEMM mesh.
60///
61/// Summarizes node and element counts plus an artifact reference under the
62/// `femm/Mesh` citizen symbol.
63#[derive(Clone, Debug, PartialEq, Citizen)]
64#[citizen(symbol = "femm/Mesh", version = 1)]
65pub struct FemmMeshDescriptor {
66    /// Number of mesh nodes.
67    pub nodes: usize,
68    /// Number of mesh elements.
69    pub elements: usize,
70    /// Reference to the backing mesh artifact.
71    #[citizen(with = "descriptor_text")]
72    pub artifact_ref: String,
73}
74
75/// Codec-addressable descriptor for a function space.
76///
77/// Summarizes element count and formulation under the `femm/Space` citizen
78/// symbol.
79#[derive(Clone, Debug, PartialEq, Citizen)]
80#[citizen(symbol = "femm/Space", version = 1)]
81pub struct FemmSpaceDescriptor {
82    /// Number of elements the space spans.
83    pub element_count: usize,
84    /// Formulation name (for example `"planar"`).
85    #[citizen(with = "descriptor_text")]
86    pub formulation: String,
87}
88
89/// Codec-addressable descriptor for a physics problem.
90///
91/// Names the physics kind and formulation under the `femm/Physics` citizen
92/// symbol.
93#[derive(Clone, Debug, PartialEq, Citizen)]
94#[citizen(symbol = "femm/Physics", version = 1)]
95pub struct FemmPhysicsDescriptor {
96    /// Physics kind name (for example `"electrostatic"`).
97    #[citizen(with = "descriptor_text")]
98    pub physics: String,
99    /// Formulation name (for example `"planar"`).
100    #[citizen(with = "descriptor_text")]
101    pub formulation: String,
102}
103
104/// Codec-addressable descriptor for a solve step.
105///
106/// Names the linear-solve method and matrix reference under the `femm/Solve`
107/// citizen symbol.
108#[derive(Clone, Debug, PartialEq, Citizen)]
109#[citizen(symbol = "femm/Solve", version = 1)]
110pub struct FemmSolveDescriptor {
111    /// Solve method name (for example `"sparse-lu"`).
112    #[citizen(with = "descriptor_text")]
113    pub method: String,
114    /// Reference to the backing system matrix.
115    #[citizen(with = "descriptor_text")]
116    pub matrix_ref: String,
117}
118
119/// Codec-addressable descriptor for a [`FemmSolution`].
120///
121/// Summarizes the solution's identity, physics, parameters, and mesh size under
122/// the `femm/Solution` citizen symbol.
123#[derive(Clone, Debug, PartialEq, Citizen)]
124#[citizen(symbol = "femm/Solution", version = 1)]
125pub struct FemmSolutionDescriptor {
126    /// Solution id.
127    pub id: u64,
128    /// Id of the model the solution was produced from.
129    pub model_id: u64,
130    /// Physics kind name (for example `"electrostatic"`).
131    #[citizen(with = "descriptor_text")]
132    pub physics: String,
133    /// Formulation name (for example `"planar"`).
134    #[citizen(with = "descriptor_text")]
135    pub formulation: String,
136    /// Solve parameter names bound for the solution.
137    pub params: Vec<String>,
138    /// Number of mesh nodes in the solution.
139    pub nodes: usize,
140    /// Number of mesh elements in the solution.
141    pub elements: usize,
142}
143
144/// Codec-addressable descriptor for a post-processing query.
145///
146/// Names the requested quantity and its target under the `femm/Post` citizen
147/// symbol.
148#[derive(Clone, Debug, PartialEq, Citizen)]
149#[citizen(symbol = "femm/Post", version = 1)]
150pub struct FemmPostDescriptor {
151    /// Requested quantity name (for example `"energy"`).
152    #[citizen(with = "descriptor_text")]
153    pub quantity: String,
154    /// Target the quantity is evaluated over (for example `"region:air"`).
155    #[citizen(with = "descriptor_text")]
156    pub target: String,
157}
158
159/// Codec-addressable descriptor for a parameterized output function.
160///
161/// Names the model, output query, and free variables under the `femm/Function`
162/// citizen symbol.
163#[derive(Clone, Debug, PartialEq, Citizen)]
164#[citizen(symbol = "femm/Function", version = 1)]
165pub struct FemmFunctionDescriptor {
166    /// Id of the model the function evaluates.
167    pub model_id: u64,
168    /// Output query name (for example `"quantity:energy"`).
169    #[citizen(with = "descriptor_text")]
170    pub query: String,
171    /// Free variable names the function is parameterized over.
172    pub vars: Vec<String>,
173}
174
175/// Codec-addressable descriptor for a function-evaluation payload.
176///
177/// Mirrors [`FemmFunctionDescriptor`] under the `femm/FuncPayload` citizen
178/// symbol, naming the payload's model, query, and variables.
179#[derive(Clone, Debug, PartialEq, Citizen)]
180#[citizen(symbol = "femm/FuncPayload", version = 1)]
181pub struct FemmFuncPayloadDescriptor {
182    /// Id of the model the payload evaluates.
183    pub model_id: u64,
184    /// Output query name (for example `"quantity:energy"`).
185    #[citizen(with = "descriptor_text")]
186    pub query: String,
187    /// Free variable names the payload is parameterized over.
188    pub vars: Vec<String>,
189}
190
191/// Codec-addressable descriptor for a [`FemmModel`].
192///
193/// Summarizes the model's identity, physics, formulation, and input parameters
194/// under the `femm/Model` citizen symbol.
195#[derive(Clone, Debug, PartialEq, Citizen)]
196#[citizen(symbol = "femm/Model", version = 1)]
197pub struct FemmModelDescriptor {
198    /// Model id.
199    pub id: u64,
200    /// Model name (for example `"parallel-plate-capacitor"`).
201    #[citizen(with = "descriptor_text")]
202    pub name: String,
203    /// Physics kind name (for example `"electrostatic"`).
204    #[citizen(with = "descriptor_text")]
205    pub physics: String,
206    /// Formulation name (for example `"planar"`).
207    #[citizen(with = "descriptor_text")]
208    pub formulation: String,
209    /// Input parameter names declared by the model.
210    pub params: Vec<String>,
211}
212
213/// Codec-addressable descriptor for a sensitivity computation.
214///
215/// Names the differentiation path and the parameters differentiated with
216/// respect to, under the `femm/Sensitivity` citizen symbol.
217#[derive(Clone, Debug, PartialEq, Citizen)]
218#[citizen(symbol = "femm/Sensitivity", version = 1)]
219pub struct FemmSensitivityDescriptor {
220    /// Sensitivity path name (for example `"direct-exact"`).
221    #[citizen(with = "descriptor_text")]
222    pub path: String,
223    /// Parameter names the sensitivity is taken with respect to.
224    pub wrt: Vec<String>,
225}
226
227/// Codec-addressable descriptor for a sensitivity tape.
228///
229/// Summarizes recorded factor and solution counts plus an artifact reference
230/// under the `femm/Tape` citizen symbol.
231#[derive(Clone, Debug, PartialEq, Citizen)]
232#[citizen(symbol = "femm/Tape", version = 1)]
233pub struct FemmTapeDescriptor {
234    /// Number of factors recorded on the tape.
235    pub factors: usize,
236    /// Number of solutions recorded on the tape.
237    pub solutions: usize,
238    /// Reference to the backing tape artifact.
239    #[citizen(with = "descriptor_text")]
240    pub artifact_ref: String,
241}
242
243/// Codec-addressable descriptor for an ODE integration coupling.
244///
245/// Names the integrated state variables and the field quantities they require,
246/// under the `femm/Ode` citizen symbol.
247#[derive(Clone, Debug, PartialEq, Citizen)]
248#[citizen(symbol = "femm/Ode", version = 1)]
249pub struct FemmOdeDescriptor {
250    /// Integrated state variable names.
251    pub state_vars: Vec<String>,
252    /// Field quantity names the integration consumes.
253    pub quantity_needs: Vec<String>,
254}
255
256impl FemmFieldDescriptor {
257    /// Builds the descriptor from a solved [`Field`].
258    pub fn from_field(field: &Field) -> Self {
259        Self {
260            solution_id: field.solution_id().0,
261            projection: projection_name(&field.projection()),
262        }
263    }
264}
265
266impl FemmMeshDescriptor {
267    /// Builds the descriptor from a [`FemMesh2`] and its artifact reference.
268    pub fn from_mesh(mesh: &FemMesh2, artifact_ref: impl Into<String>) -> Self {
269        Self {
270            nodes: mesh.xy.len(),
271            elements: mesh.tri.len(),
272            artifact_ref: artifact_ref.into(),
273        }
274    }
275}
276
277impl FemmSolutionDescriptor {
278    /// Builds the descriptor from a [`FemmSolution`].
279    pub fn from_solution(solution: &FemmSolution) -> Self {
280        Self {
281            id: solution.id.0,
282            model_id: solution.model_id.0,
283            physics: physics_name(&solution.physics).to_owned(),
284            formulation: formulation_name(&solution.formulation).to_owned(),
285            params: solution
286                .params
287                .entries
288                .iter()
289                .map(|(name, _)| name.to_string())
290                .collect(),
291            nodes: solution.mesh.xy.len(),
292            elements: solution.mesh.tri.len(),
293        }
294    }
295}
296
297impl FemmFunctionDescriptor {
298    /// Builds the descriptor from a [`FemmFuncPayload`].
299    pub fn from_payload(payload: &FemmFuncPayload) -> Self {
300        Self {
301            model_id: payload.model.id.0,
302            query: query_name(&payload.query),
303            vars: payload.vars.iter().map(ToString::to_string).collect(),
304        }
305    }
306}
307
308impl FemmFuncPayloadDescriptor {
309    /// Builds the descriptor from a [`FemmFuncPayload`].
310    pub fn from_payload(payload: &FemmFuncPayload) -> Self {
311        let descriptor = FemmFunctionDescriptor::from_payload(payload);
312        Self {
313            model_id: descriptor.model_id,
314            query: descriptor.query,
315            vars: descriptor.vars,
316        }
317    }
318}
319
320impl FemmModelDescriptor {
321    /// Builds the descriptor from a [`FemmModel`].
322    pub fn from_model(model: &FemmModel) -> Self {
323        Self {
324            id: model.id.0,
325            name: model.name.to_string(),
326            physics: physics_name(&model.physics).to_owned(),
327            formulation: formulation_name(&model.formulation).to_owned(),
328            params: model
329                .inputs
330                .iter()
331                .map(|param| param.name.to_string())
332                .collect(),
333        }
334    }
335}
336
337impl Default for FemmFieldDescriptor {
338    fn default() -> Self {
339        Self {
340            solution_id: 1,
341            projection: "potential".to_owned(),
342        }
343    }
344}
345
346impl Default for FemmGeometryDescriptor {
347    fn default() -> Self {
348        Self {
349            region_count: 1,
350            boundary_count: 0,
351            artifact_ref: "table:femm/geometry/citizen".to_owned(),
352        }
353    }
354}
355
356impl Default for FemmMaterialDescriptor {
357    fn default() -> Self {
358        Self {
359            name: "air".to_owned(),
360            properties: vec!["epsilon-r".to_owned(), "mu-r".to_owned()],
361        }
362    }
363}
364
365impl Default for FemmMeshDescriptor {
366    fn default() -> Self {
367        Self {
368            nodes: 3,
369            elements: 1,
370            artifact_ref: "table:femm/mesh/citizen".to_owned(),
371        }
372    }
373}
374
375impl Default for FemmSpaceDescriptor {
376    fn default() -> Self {
377        Self {
378            element_count: 1,
379            formulation: "planar".to_owned(),
380        }
381    }
382}
383
384impl Default for FemmPhysicsDescriptor {
385    fn default() -> Self {
386        Self {
387            physics: "electrostatic".to_owned(),
388            formulation: "planar".to_owned(),
389        }
390    }
391}
392
393impl Default for FemmSolveDescriptor {
394    fn default() -> Self {
395        Self {
396            method: "sparse-lu".to_owned(),
397            matrix_ref: "stable:femm/matrix/citizen".to_owned(),
398        }
399    }
400}
401
402impl Default for FemmSolutionDescriptor {
403    fn default() -> Self {
404        Self {
405            id: 1,
406            model_id: 1,
407            physics: "electrostatic".to_owned(),
408            formulation: "planar".to_owned(),
409            params: Vec::new(),
410            nodes: 3,
411            elements: 1,
412        }
413    }
414}
415
416impl Default for FemmPostDescriptor {
417    fn default() -> Self {
418        Self {
419            quantity: "energy".to_owned(),
420            target: "region:air".to_owned(),
421        }
422    }
423}
424
425impl Default for FemmFunctionDescriptor {
426    fn default() -> Self {
427        Self {
428            model_id: 1,
429            query: "quantity:energy".to_owned(),
430            vars: vec!["gap".to_owned()],
431        }
432    }
433}
434
435impl Default for FemmFuncPayloadDescriptor {
436    fn default() -> Self {
437        Self {
438            model_id: 1,
439            query: "quantity:energy".to_owned(),
440            vars: vec!["gap".to_owned()],
441        }
442    }
443}
444
445impl Default for FemmModelDescriptor {
446    fn default() -> Self {
447        Self {
448            id: 1,
449            name: "parallel-plate-capacitor".to_owned(),
450            physics: "electrostatic".to_owned(),
451            formulation: "planar".to_owned(),
452            params: vec!["gap-mm".to_owned()],
453        }
454    }
455}
456
457impl Default for FemmSensitivityDescriptor {
458    fn default() -> Self {
459        Self {
460            path: "direct-exact".to_owned(),
461            wrt: vec!["gap".to_owned()],
462        }
463    }
464}
465
466impl Default for FemmTapeDescriptor {
467    fn default() -> Self {
468        Self {
469            factors: 1,
470            solutions: 1,
471            artifact_ref: "stable:femm/tape/citizen".to_owned(),
472        }
473    }
474}
475
476impl Default for FemmOdeDescriptor {
477    fn default() -> Self {
478        Self {
479            state_vars: vec!["x".to_owned(), "v".to_owned()],
480            quantity_needs: vec!["energy".to_owned()],
481        }
482    }
483}
484
485macro_rules! class_symbol_fn {
486    ($name:ident, $class:literal) => {
487        #[doc = concat!("Citizen class [`Symbol`] `femm/", $class, "` for the matching descriptor.")]
488        pub fn $name() -> Symbol {
489            Symbol::qualified("femm", $class)
490        }
491    };
492}
493
494class_symbol_fn!(femm_field_class_symbol, "Field");
495class_symbol_fn!(femm_geometry_class_symbol, "Geometry");
496class_symbol_fn!(femm_material_class_symbol, "Material");
497class_symbol_fn!(femm_mesh_class_symbol, "Mesh");
498class_symbol_fn!(femm_space_class_symbol, "Space");
499class_symbol_fn!(femm_physics_class_symbol, "Physics");
500class_symbol_fn!(femm_solve_class_symbol, "Solve");
501class_symbol_fn!(femm_solution_class_symbol, "Solution");
502class_symbol_fn!(femm_post_class_symbol, "Post");
503class_symbol_fn!(femm_function_class_symbol, "Function");
504class_symbol_fn!(femm_func_payload_class_symbol, "FuncPayload");
505class_symbol_fn!(femm_model_class_symbol, "Model");
506class_symbol_fn!(femm_sensitivity_class_symbol, "Sensitivity");
507class_symbol_fn!(femm_tape_class_symbol, "Tape");
508class_symbol_fn!(femm_ode_class_symbol, "Ode");
509
510pub(crate) mod descriptor_text {
511    use sim_kernel::{Error, Expr, Result};
512
513    pub fn encode(text: &str) -> Expr {
514        Expr::String(text.to_owned())
515    }
516
517    pub fn decode(expr: &Expr) -> Result<String> {
518        let Expr::String(text) = expr else {
519            return Err(Error::Eval(
520                "FEMM descriptor text must be a string".to_owned(),
521            ));
522        };
523        validate_descriptor_text(text)?;
524        Ok(text.clone())
525    }
526
527    fn validate_descriptor_text(text: &str) -> Result<()> {
528        if text.trim().is_empty() {
529            return Err(Error::Eval(
530                "FEMM descriptor text cannot be empty".to_owned(),
531            ));
532        }
533        if !text.is_ascii() {
534            return Err(Error::Eval("FEMM descriptor text must be ASCII".to_owned()));
535        }
536        Ok(())
537    }
538}
539
540fn projection_name(projection: &Projection) -> String {
541    match projection {
542        Projection::Potential => "potential".to_owned(),
543        Projection::Bx => "bx".to_owned(),
544        Projection::By => "by".to_owned(),
545        Projection::Bmag => "bmag".to_owned(),
546        Projection::Ex => "ex".to_owned(),
547        Projection::Ey => "ey".to_owned(),
548        Projection::Emag => "emag".to_owned(),
549        Projection::HeatFluxMag => "heat-flux-mag".to_owned(),
550        Projection::Custom(symbol) => symbol.to_string(),
551    }
552}
553
554fn query_name(query: &OutputQuery) -> String {
555    match query {
556        OutputQuery::Quantity(spec) => format!("quantity:{}", quantity_name(spec)),
557        OutputQuery::Field(projection) => format!("field:{}", projection_name(projection)),
558        OutputQuery::Solution => "solution".to_owned(),
559    }
560}
561
562fn quantity_name(spec: &QuantitySpec) -> String {
563    match spec {
564        QuantitySpec::Energy { region } => optional_region("energy", region.as_ref()),
565        QuantitySpec::Coenergy { region } => optional_region("coenergy", region.as_ref()),
566        QuantitySpec::ForceY { region } => format!("force-y:{region}"),
567        QuantitySpec::Torque { region, .. } => format!("torque:{region}"),
568        QuantitySpec::FluxLinkage { circuit } => format!("flux-linkage:{circuit}"),
569        QuantitySpec::Inductance { circuit } => format!("inductance:{circuit}"),
570        QuantitySpec::Capacitance { conductor } => format!("capacitance:{conductor}"),
571        QuantitySpec::JouleLoss { region } => optional_region("joule-loss", region.as_ref()),
572        QuantitySpec::FieldAt { field, .. } => format!("field-at:{field}"),
573        QuantitySpec::Custom { name, .. } => format!("custom:{name}"),
574    }
575}
576
577fn optional_region(prefix: &str, region: Option<&Symbol>) -> String {
578    region
579        .map(|region| format!("{prefix}:{region}"))
580        .unwrap_or_else(|| prefix.to_owned())
581}