Skip to main content

oxirs_physics/samm/
mod.rs

1//! SAMM Aspect Model Bridge for Physics Simulations
2//!
3//! Parses SAMM (Semantic Aspect Meta Model) TTL format and bridges the
4//! extracted semantic structure to the physics simulation types used in
5//! `oxirs-physics`.
6//!
7//! # What is SAMM?
8//!
9//! The Semantic Aspect Meta Model (SAMM, formerly BAMM) is an Eclipse Tractus-X
10//! specification for describing the semantic structure of manufacturing and IoT
11//! data. It uses Turtle RDF to define:
12//!
13//! - **Aspects** – top-level semantic groupings (e.g. "MotorAspect")
14//! - **Properties** – typed data fields (e.g. "rotationalSpeed", "temperature")
15//! - **Characteristics** – constraints on properties (e.g. `Measurement`, `Quantifiable`)
16//! - **Units** – QUDT-compatible unit references (e.g. `unit:kilometre-per-hour`)
17//! - **Constraints** – value ranges, patterns, enumeration sets
18//!
19//! # Architecture
20//!
21//! ```text
22//! SAMM TTL source (file / string)
23//!        │
24//!        │  SammAspectParser::parse_*
25//!        ▼
26//!  [RdfStore (in-memory)]
27//!        │
28//!        │  SPARQL queries (extract_* methods)
29//!        ▼
30//!  SammAspectModel  ─────────────►  SimulationParameters  (via bridge methods)
31//!   ├── properties[]
32//!   ├── characteristics[]
33//!   └── constraints[]
34//! ```
35//!
36//! # Example
37//!
38//! ```rust,no_run
39//! use oxirs_physics::samm::SammAspectParser;
40//!
41//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
42//! let parser = SammAspectParser::new()?;
43//! let model = parser.parse_samm_string(TTL_SOURCE).await?;
44//!
45//! for prop in &model.properties {
46//!     println!("  {} [{}] {:?}..{:?}",
47//!         prop.name, prop.unit.as_deref().unwrap_or("dimensionless"),
48//!         prop.range_min, prop.range_max);
49//! }
50//! # Ok(())
51//! # }
52//! # const TTL_SOURCE: &str = "";
53//! ```
54
55pub mod fem_bridge;
56pub mod physics_aspect;
57
58pub use fem_bridge::{
59    PhysicsModelBridge, SammAspect as FemSammAspect, SammDataType as FemSammDataType,
60    SammPhysicsRegistry, SammProperty as FemSammProperty,
61};
62pub use physics_aspect::{
63    AasElement, AasElementKind, PhysicalDomain, PhysicsAasSubmodel, PhysicsAspect,
64    SammPhysicsMapper, SimulationParameter, SimulationResultValue, SimulationStatus,
65};
66
67use crate::error::{PhysicsError, PhysicsResult};
68use crate::rdf::literal_parser::{parse_rdf_literal, parse_unit_str, PhysicalUnit, PhysicalValue};
69use crate::simulation::parameter_extraction::{
70    BoundaryCondition, PhysicalQuantity, SimulationParameters,
71};
72use oxirs_core::model::Term;
73use oxirs_core::parser::{Parser, RdfFormat};
74use oxirs_core::rdf_store::{QueryResults, RdfStore};
75use serde::{Deserialize, Serialize};
76use std::collections::HashMap;
77use std::path::Path;
78use std::sync::Arc;
79
80// ──────────────────────────────────────────────────────────────────────────────
81// SAMM namespace constants
82// ──────────────────────────────────────────────────────────────────────────────
83
84/// SAMM meta-model namespace (v2.0.0)
85pub const SAMM_NS: &str = "urn:samm:org.eclipse.esmf.samm:meta-model:2.0.0#";
86/// SAMM characteristic namespace
87pub const SAMM_C_NS: &str = "urn:samm:org.eclipse.esmf.samm:characteristic:2.0.0#";
88/// SAMM unit namespace
89pub const SAMM_UNIT_NS: &str = "urn:samm:org.eclipse.esmf.samm:unit:2.0.0#";
90/// QUDT unit namespace
91pub const QUDT_UNIT_NS: &str = "http://qudt.org/vocab/unit/";
92/// XSD namespace
93pub const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema#";
94
95// ──────────────────────────────────────────────────────────────────────────────
96// Data model types
97// ──────────────────────────────────────────────────────────────────────────────
98
99/// XSD / SAMM data types for SAMM properties.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub enum SammDataType {
102    /// `xsd:double` or `xsd:float`
103    Double,
104    /// `xsd:integer` / `xsd:long` / `xsd:int` / `xsd:short`
105    Integer,
106    /// `xsd:string`
107    Text,
108    /// `xsd:boolean`
109    Boolean,
110    /// `xsd:dateTime`
111    DateTime,
112    /// A named composite type (non-primitive SAMM Entity URI)
113    Entity(String),
114    /// Unrecognised data type; stores the raw IRI string
115    Unknown(String),
116}
117
118impl SammDataType {
119    /// Construct from a datatype IRI string.
120    pub fn from_iri(iri: &str) -> Self {
121        match iri {
122            s if s.ends_with("#double") || s.ends_with("#float") => Self::Double,
123            s if s.ends_with("#decimal") => Self::Double,
124            s if s.ends_with("#integer")
125                || s.ends_with("#long")
126                || s.ends_with("#int")
127                || s.ends_with("#short")
128                || s.ends_with("#byte")
129                || s.ends_with("#nonNegativeInteger") =>
130            {
131                Self::Integer
132            }
133            s if s.ends_with("#string") => Self::Text,
134            s if s.ends_with("#boolean") => Self::Boolean,
135            s if s.ends_with("#dateTime") || s.ends_with("#date") => Self::DateTime,
136            "" => Self::Unknown(String::new()),
137            other => {
138                // Check if it looks like a full type IRI (has '#' or '/')
139                if other.contains('#') || other.contains('/') {
140                    Self::Entity(other.to_string())
141                } else {
142                    Self::Unknown(other.to_string())
143                }
144            }
145        }
146    }
147
148    /// Return `true` if this data type is numeric.
149    pub fn is_numeric(&self) -> bool {
150        matches!(self, Self::Double | Self::Integer)
151    }
152}
153
154/// SAMM characteristic type (maps to the SAMM-C vocabulary).
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub enum SammCharacteristic {
157    /// A measured quantity that carries a unit (samm-c:Measurement)
158    Measurement,
159    /// A numeric quantity that may carry a unit (samm-c:Quantifiable)
160    Quantifiable,
161    /// An enumeration of allowed values (samm-c:Enumeration)
162    Enumeration,
163    /// A duration measured in a time unit (samm-c:Duration)
164    Duration,
165    /// A single-dimension collection (samm-c:Collection / samm-c:List)
166    Collection,
167    /// Code / identifier string (samm-c:Code)
168    Code,
169    /// Unknown or composite characteristic; stores the IRI
170    Other(String),
171}
172
173impl SammCharacteristic {
174    /// Construct from a characteristic type IRI.
175    pub fn from_iri(iri: &str) -> Self {
176        if iri.contains("Measurement") {
177            Self::Measurement
178        } else if iri.contains("Quantifiable") {
179            Self::Quantifiable
180        } else if iri.contains("Enumeration") {
181            Self::Enumeration
182        } else if iri.contains("Duration") {
183            Self::Duration
184        } else if iri.contains("Collection") || iri.contains("List") || iri.contains("Set") {
185            Self::Collection
186        } else if iri.contains("Code") {
187            Self::Code
188        } else {
189            Self::Other(iri.to_string())
190        }
191    }
192}
193
194/// A SAMM property with physics semantics.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SammPhysicsProperty {
197    /// Full URN of this property node in the SAMM model.
198    pub urn: String,
199    /// Human-readable label (from `rdfs:label`).
200    pub name: String,
201    /// Description (from `samm:description`).
202    pub description: Option<String>,
203    /// XSD or Entity data type.
204    pub data_type: SammDataType,
205    /// Characteristic category (Measurement, Quantifiable, …).
206    pub characteristic: Option<SammCharacteristic>,
207    /// Unit string (from `samm:unit` or `qudt:unit` annotation).
208    pub unit: Option<String>,
209    /// Parsed [`PhysicalUnit`] variant when the unit string is recognised.
210    pub physical_unit: Option<PhysicalUnit>,
211    /// Minimum allowed value (from samm-c:minValue or samm-c:lowerBoundDefinition).
212    pub range_min: Option<f64>,
213    /// Maximum allowed value (from samm-c:maxValue or samm-c:upperBoundDefinition).
214    pub range_max: Option<f64>,
215    /// Allowed enumeration values (for samm-c:Enumeration).
216    pub enum_values: Vec<String>,
217    /// Whether the property is mandatory in its parent Aspect.
218    pub is_required: bool,
219}
220
221/// A SAMM Aspect node (top-level semantic grouping).
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct SammAspect {
224    /// Full URN of the Aspect node.
225    pub urn: String,
226    /// Human-readable label.
227    pub name: String,
228    /// Description.
229    pub description: Option<String>,
230    /// Properties that belong to this Aspect.
231    pub property_urns: Vec<String>,
232}
233
234/// Complete parsed SAMM Aspect model ready for bridging to physics simulations.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct SammAspectModel {
237    /// Aspect nodes found in the TTL.
238    pub aspects: Vec<SammAspect>,
239    /// All properties (across all aspects).
240    pub properties: Vec<SammPhysicsProperty>,
241    /// Namespace prefix map extracted from the TTL preamble.
242    pub prefix_map: HashMap<String, String>,
243}
244
245impl SammAspectModel {
246    /// Look up a property by its local name (case-insensitive).
247    pub fn property_by_name(&self, name: &str) -> Option<&SammPhysicsProperty> {
248        let lower = name.to_lowercase();
249        self.properties
250            .iter()
251            .find(|p| p.name.to_lowercase() == lower)
252    }
253
254    /// Return all numeric properties (Double / Integer data types).
255    pub fn numeric_properties(&self) -> impl Iterator<Item = &SammPhysicsProperty> {
256        self.properties.iter().filter(|p| p.data_type.is_numeric())
257    }
258
259    /// Return all properties that have a known physical unit.
260    pub fn measured_properties(&self) -> impl Iterator<Item = &SammPhysicsProperty> {
261        self.properties.iter().filter(|p| p.physical_unit.is_some())
262    }
263}
264
265// ──────────────────────────────────────────────────────────────────────────────
266// Parser
267// ──────────────────────────────────────────────────────────────────────────────
268
269/// Parses SAMM TTL format and bridges it to physics simulation types.
270///
271/// Internally uses an in-memory [`RdfStore`] to enable SPARQL-based extraction
272/// of structured data from the SAMM TTL.
273pub struct SammAspectParser {
274    /// Physics namespace – defaults to `http://oxirs.org/physics#`.
275    physics_ns: String,
276    /// SAMM meta-model namespace.
277    samm_ns: String,
278    /// SAMM characteristic namespace.
279    samm_c_ns: String,
280}
281
282impl Default for SammAspectParser {
283    fn default() -> Self {
284        Self::new().unwrap_or_else(|_| Self {
285            physics_ns: "http://oxirs.org/physics#".to_string(),
286            samm_ns: SAMM_NS.to_string(),
287            samm_c_ns: SAMM_C_NS.to_string(),
288        })
289    }
290}
291
292impl SammAspectParser {
293    /// Create a new parser with default configuration.
294    pub fn new() -> PhysicsResult<Self> {
295        Ok(Self {
296            physics_ns: "http://oxirs.org/physics#".to_string(),
297            samm_ns: SAMM_NS.to_string(),
298            samm_c_ns: SAMM_C_NS.to_string(),
299        })
300    }
301
302    /// Override the physics namespace prefix.
303    pub fn with_physics_namespace(mut self, ns: impl Into<String>) -> Self {
304        self.physics_ns = ns.into();
305        self
306    }
307
308    /// Parse a SAMM TTL file from disk.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`PhysicsError::SammParsing`] on I/O or parse errors.
313    pub async fn parse_samm_file(&self, path: &Path) -> PhysicsResult<SammAspectModel> {
314        let content = std::fs::read_to_string(path).map_err(|e| {
315            PhysicsError::SammParsing(format!("Failed to read SAMM file {:?}: {}", path, e))
316        })?;
317        self.parse_samm_string(&content).await
318    }
319
320    /// Parse a SAMM TTL string in memory.
321    ///
322    /// # Errors
323    ///
324    /// Returns [`PhysicsError::SammParsing`] if the Turtle syntax is invalid.
325    pub async fn parse_samm_string(&self, content: &str) -> PhysicsResult<SammAspectModel> {
326        // Build a fresh in-memory store for this parse session
327        let mut store = RdfStore::new()
328            .map_err(|e| PhysicsError::SammParsing(format!("Failed to create RDF store: {}", e)))?;
329
330        // Parse Turtle → quads
331        let parser = Parser::new(RdfFormat::Turtle);
332        let quads = parser
333            .parse_str_to_quads(content)
334            .map_err(|e| PhysicsError::SammParsing(format!("Turtle parse error: {}", e)))?;
335
336        for quad in quads {
337            store
338                .insert_quad(quad)
339                .map_err(|e| PhysicsError::SammParsing(format!("Failed to insert quad: {}", e)))?;
340        }
341
342        let store = Arc::new(store);
343
344        // Extract components via SPARQL
345        let aspects = self.extract_aspects(&store).await?;
346        let properties = self.extract_physics_properties(&store).await?;
347        let prefix_map = self.extract_prefix_map(content);
348
349        Ok(SammAspectModel {
350            aspects,
351            properties,
352            prefix_map,
353        })
354    }
355
356    // ── Aspect extraction ─────────────────────────────────────────────────────
357
358    async fn extract_aspects(&self, store: &Arc<RdfStore>) -> PhysicsResult<Vec<SammAspect>> {
359        let query = format!(
360            r#"
361            PREFIX samm: <{samm}>
362            PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
363            PREFIX rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
364
365            SELECT DISTINCT ?aspect ?label ?desc ?prop WHERE {{
366                ?aspect a samm:Aspect .
367                OPTIONAL {{ ?aspect rdfs:label ?label . }}
368                OPTIONAL {{ ?aspect samm:description ?desc . }}
369                OPTIONAL {{
370                    ?aspect samm:properties ?prop .
371                    ?prop a samm:Property .
372                }}
373            }}
374            "#,
375            samm = self.samm_ns
376        );
377
378        let results = store
379            .query(&query)
380            .map_err(|e| PhysicsError::SammParsing(format!("Aspect query failed: {}", e)))?;
381
382        let mut aspects: HashMap<String, SammAspect> = HashMap::new();
383
384        if let QueryResults::Bindings(ref bindings) = results.results() {
385            for binding in bindings {
386                let Some(Term::NamedNode(aspect_node)) = binding.get("aspect") else {
387                    continue;
388                };
389                let aspect_urn = aspect_node.as_str().to_string();
390
391                let label_opt = binding.get("label").and_then(literal_value);
392                let description = binding.get("desc").and_then(literal_value);
393
394                let aspect = aspects
395                    .entry(aspect_urn.clone())
396                    .or_insert_with(|| SammAspect {
397                        urn: aspect_urn.clone(),
398                        name: label_opt
399                            .clone()
400                            .unwrap_or_else(|| local_name_of(&aspect_urn)),
401                        description: description.clone(),
402                        property_urns: Vec::new(),
403                    });
404
405                // Update name from label if a later row provides it
406                if let Some(label) = label_opt {
407                    if aspect.name != label {
408                        aspect.name = label;
409                    }
410                }
411                // Update description if not yet set
412                if aspect.description.is_none() {
413                    aspect.description = description;
414                }
415
416                if let Some(Term::NamedNode(prop_node)) = binding.get("prop") {
417                    let prop_urn = prop_node.as_str().to_string();
418                    if !aspect.property_urns.contains(&prop_urn) {
419                        aspect.property_urns.push(prop_urn);
420                    }
421                }
422            }
423        }
424
425        Ok(aspects.into_values().collect())
426    }
427
428    // ── Property extraction ───────────────────────────────────────────────────
429
430    async fn extract_physics_properties(
431        &self,
432        store: &Arc<RdfStore>,
433    ) -> PhysicsResult<Vec<SammPhysicsProperty>> {
434        // Phase 1: basic property metadata
435        let prop_query = format!(
436            r#"
437            PREFIX samm:   <{samm}>
438            PREFIX rdfs:   <http://www.w3.org/2000/01/rdf-schema#>
439
440            SELECT ?prop ?label ?desc ?char WHERE {{
441                ?prop a samm:Property .
442                OPTIONAL {{ ?prop rdfs:label ?label . }}
443                OPTIONAL {{ ?prop samm:description ?desc . }}
444                OPTIONAL {{ ?prop samm:characteristic ?char . }}
445            }}
446            "#,
447            samm = self.samm_ns,
448        );
449
450        let prop_results = store
451            .query(&prop_query)
452            .map_err(|e| PhysicsError::SammParsing(format!("Property query failed: {}", e)))?;
453
454        // Phase 2: characteristic details (dataType, unit, charType)
455        let char_query = format!(
456            r#"
457            PREFIX samm:   <{samm}>
458            PREFIX samm-c: <{samm_c}>
459
460            SELECT ?char ?charType ?dataType ?unit WHERE {{
461                ?char samm:dataType ?dataType .
462                OPTIONAL {{ ?char a ?charType . }}
463                OPTIONAL {{ ?char samm:unit ?unit . }}
464            }}
465            "#,
466            samm = self.samm_ns,
467            samm_c = self.samm_c_ns,
468        );
469
470        let char_results = store.query(&char_query).map_err(|e| {
471            PhysicsError::SammParsing(format!("Characteristic query failed: {}", e))
472        })?;
473
474        // Build characteristic lookup
475        let mut char_details: HashMap<
476            String,
477            (Option<SammCharacteristic>, SammDataType, Option<String>),
478        > = HashMap::new();
479        if let QueryResults::Bindings(ref bindings) = char_results.results() {
480            for binding in bindings {
481                let Some(Term::NamedNode(char_node)) = binding.get("char") else {
482                    continue;
483                };
484                let char_iri = char_node.as_str().to_string();
485                let data_type = binding
486                    .get("dataType")
487                    .and_then(named_node_str)
488                    .map(SammDataType::from_iri)
489                    .unwrap_or(SammDataType::Unknown(String::new()));
490                let characteristic = binding
491                    .get("charType")
492                    .and_then(named_node_str)
493                    .map(SammCharacteristic::from_iri);
494                let unit_str = binding.get("unit").and_then(|t| match t {
495                    Term::Literal(lit) => Some(lit.value().to_string()),
496                    Term::NamedNode(nn) => Some(local_name_of(nn.as_str())),
497                    _ => None,
498                });
499                // Only update if we don't already have data for this characteristic
500                // (handles multiple rdf:type rows)
501                char_details
502                    .entry(char_iri)
503                    .or_insert((characteristic, data_type, unit_str));
504            }
505        }
506
507        let query = "SELECT ?x WHERE { }"; // placeholder, unused
508        let _ = query; // suppress unused warning
509
510        let mut props: HashMap<String, SammPhysicsProperty> = HashMap::new();
511
512        if let QueryResults::Bindings(ref bindings) = prop_results.results() {
513            for binding in bindings {
514                let Some(Term::NamedNode(prop_node)) = binding.get("prop") else {
515                    continue;
516                };
517                let prop_urn = prop_node.as_str().to_string();
518
519                let name = binding
520                    .get("label")
521                    .and_then(literal_value)
522                    .unwrap_or_else(|| local_name_of(&prop_urn));
523
524                let description = binding.get("desc").and_then(literal_value);
525
526                // Look up characteristic details from phase 2 results
527                let char_iri = binding
528                    .get("char")
529                    .and_then(named_node_str)
530                    .map(|s| s.to_string());
531                let (characteristic, data_type, unit_str) = char_iri
532                    .as_deref()
533                    .and_then(|iri| char_details.get(iri))
534                    .map(|(ch, dt, u)| (ch.clone(), dt.clone(), u.clone()))
535                    .unwrap_or((None, SammDataType::Unknown(String::new()), None));
536
537                let physical_unit = unit_str.as_deref().map(parse_unit_str).and_then(|u| {
538                    if matches!(u, PhysicalUnit::Custom(_)) {
539                        None
540                    } else {
541                        Some(u)
542                    }
543                });
544
545                props
546                    .entry(prop_urn.clone())
547                    .or_insert_with(|| SammPhysicsProperty {
548                        urn: prop_urn,
549                        name,
550                        description,
551                        data_type,
552                        characteristic,
553                        unit: unit_str,
554                        physical_unit,
555                        range_min: None,
556                        range_max: None,
557                        enum_values: Vec::new(),
558                        is_required: false,
559                    });
560            }
561        }
562
563        // Second pass: enrich with range constraints
564        self.enrich_with_constraints(store, &mut props).await?;
565
566        Ok(props.into_values().collect())
567    }
568
569    // ── Constraint enrichment ─────────────────────────────────────────────────
570
571    async fn enrich_with_constraints(
572        &self,
573        store: &Arc<RdfStore>,
574        props: &mut HashMap<String, SammPhysicsProperty>,
575    ) -> PhysicsResult<()> {
576        let constraint_query = format!(
577            r#"
578            PREFIX samm:   <{samm}>
579            PREFIX samm-c: <{samm_c}>
580            PREFIX xsd:    <{xsd}>
581
582            SELECT ?prop ?minVal ?maxVal WHERE {{
583                ?prop a samm:Property .
584                ?prop samm:characteristic ?char .
585                OPTIONAL {{ ?char samm-c:minValue ?minVal . }}
586                OPTIONAL {{ ?char samm-c:maxValue ?maxVal . }}
587            }}
588            "#,
589            samm = self.samm_ns,
590            samm_c = self.samm_c_ns,
591            xsd = XSD_NS,
592        );
593
594        let results = store
595            .query(&constraint_query)
596            .map_err(|e| PhysicsError::SammParsing(format!("Constraint query failed: {}", e)))?;
597
598        if let QueryResults::Bindings(ref bindings) = results.results() {
599            for binding in bindings {
600                let Some(Term::NamedNode(prop_node)) = binding.get("prop") else {
601                    continue;
602                };
603                let prop_urn = prop_node.as_str().to_string();
604
605                if let Some(prop) = props.get_mut(&prop_urn) {
606                    if let Some(min_lit) = binding.get("minVal").and_then(literal_value) {
607                        prop.range_min = min_lit.parse::<f64>().ok();
608                    }
609                    if let Some(max_lit) = binding.get("maxVal").and_then(literal_value) {
610                        prop.range_max = max_lit.parse::<f64>().ok();
611                    }
612                }
613            }
614        }
615
616        Ok(())
617    }
618
619    // ── Prefix map extraction ─────────────────────────────────────────────────
620
621    /// Extract `@prefix` / `PREFIX` declarations from a raw Turtle string.
622    ///
623    /// Returns a map of `prefix_name → namespace_iri`.
624    pub fn extract_prefix_map(&self, content: &str) -> HashMap<String, String> {
625        let mut map = HashMap::new();
626        for line in content.lines() {
627            let trimmed = line.trim();
628            // Handle both `@prefix` (Turtle) and `PREFIX` (SPARQL-style)
629            let rest = if trimmed.to_lowercase().starts_with("@prefix ") {
630                Some(&trimmed[8..])
631            } else if trimmed.to_lowercase().starts_with("prefix ") {
632                Some(&trimmed[7..])
633            } else {
634                None
635            };
636
637            if let Some(decl) = rest {
638                if let Some((prefix_part, iri_part)) = decl.split_once(':') {
639                    let prefix = prefix_part.trim().to_string();
640                    // Extract IRI between < >
641                    if let Some(start) = iri_part.find('<') {
642                        if let Some(end) = iri_part.find('>') {
643                            if end > start {
644                                let iri = iri_part[start + 1..end].to_string();
645                                map.insert(prefix, iri);
646                            }
647                        }
648                    }
649                }
650            }
651        }
652        map
653    }
654
655    // ── Bridge methods ────────────────────────────────────────────────────────
656
657    /// Bridge a [`SammAspectModel`] to [`SimulationParameters`] for the named entity.
658    ///
659    /// Numeric properties with known units are mapped to initial conditions.
660    /// Range constraints become boundary conditions.
661    ///
662    /// # Arguments
663    ///
664    /// * `model`          – Parsed SAMM model.
665    /// * `entity_iri`     – IRI of the physical entity to simulate.
666    /// * `simulation_type` – Simulation type string (e.g. `"thermal"`).
667    ///
668    /// # Errors
669    ///
670    /// Returns [`PhysicsError::SammParsing`] if the model is empty or inconsistent.
671    pub fn bridge_to_simulation_params(
672        &self,
673        model: &SammAspectModel,
674        entity_iri: &str,
675        simulation_type: &str,
676    ) -> PhysicsResult<SimulationParameters> {
677        if model.aspects.is_empty() && model.properties.is_empty() {
678            return Err(PhysicsError::SammParsing(
679                "SAMM model is empty – cannot bridge to simulation parameters".to_string(),
680            ));
681        }
682
683        let mut initial_conditions = HashMap::new();
684        let mut boundary_conditions = Vec::new();
685
686        for prop in model.numeric_properties() {
687            let unit_str = prop
688                .unit
689                .clone()
690                .unwrap_or_else(|| "dimensionless".to_string());
691
692            // Initial condition: use range midpoint if available, else 0
693            let initial_value = match (prop.range_min, prop.range_max) {
694                (Some(min), Some(max)) => (min + max) / 2.0,
695                (Some(min), None) => min,
696                (None, Some(max)) => max,
697                (None, None) => 0.0,
698            };
699
700            initial_conditions.insert(
701                prop.name.clone(),
702                PhysicalQuantity {
703                    value: initial_value,
704                    unit: unit_str.clone(),
705                    uncertainty: None,
706                },
707            );
708
709            // Boundary conditions from range constraints
710            if let Some(min_val) = prop.range_min {
711                boundary_conditions.push(BoundaryCondition {
712                    boundary_name: format!("{}_min", prop.name),
713                    condition_type: "lower_bound".to_string(),
714                    value: PhysicalQuantity {
715                        value: min_val,
716                        unit: unit_str.clone(),
717                        uncertainty: None,
718                    },
719                });
720            }
721            if let Some(max_val) = prop.range_max {
722                boundary_conditions.push(BoundaryCondition {
723                    boundary_name: format!("{}_max", prop.name),
724                    condition_type: "upper_bound".to_string(),
725                    value: PhysicalQuantity {
726                        value: max_val,
727                        unit: unit_str.clone(),
728                        uncertainty: None,
729                    },
730                });
731            }
732        }
733
734        Ok(SimulationParameters {
735            entity_iri: entity_iri.to_string(),
736            simulation_type: simulation_type.to_string(),
737            initial_conditions,
738            boundary_conditions,
739            time_span: (0.0, 100.0),
740            time_steps: 100,
741            material_properties: HashMap::new(),
742            constraints: Vec::new(),
743        })
744    }
745
746    /// Extract a [`PhysicalValue`] for a specific property name from the model.
747    ///
748    /// Uses the range midpoint as the representative value when only bounds are
749    /// available.  Returns `None` if the property is not found or is non-numeric.
750    pub fn extract_physical_value(
751        &self,
752        model: &SammAspectModel,
753        property_name: &str,
754    ) -> Option<PhysicalValue> {
755        let prop = model.property_by_name(property_name)?;
756
757        if !prop.data_type.is_numeric() {
758            return None;
759        }
760
761        let numeric_val = match (prop.range_min, prop.range_max) {
762            (Some(min), Some(max)) => (min + max) / 2.0,
763            (Some(min), None) => min,
764            (None, Some(max)) => max,
765            (None, None) => 0.0,
766        };
767
768        let unit = prop
769            .unit
770            .as_deref()
771            .map(parse_unit_str)
772            .unwrap_or(PhysicalUnit::Dimensionless);
773
774        Some(PhysicalValue::new(numeric_val, unit))
775    }
776
777    /// Validate that all required (numeric) properties have value ranges or defaults.
778    ///
779    /// Returns `Ok(())` if validation passes, or a descriptive error listing
780    /// missing data.
781    pub fn validate_model_for_simulation(&self, model: &SammAspectModel) -> PhysicsResult<()> {
782        let mut issues: Vec<String> = Vec::new();
783
784        for prop in model.numeric_properties() {
785            if prop.range_min.is_none() && prop.range_max.is_none() {
786                issues.push(format!(
787                    "Property '{}' has no range constraints – initial value will default to 0",
788                    prop.name
789                ));
790            }
791        }
792
793        if model.aspects.is_empty() {
794            issues.push("No samm:Aspect nodes found in model".to_string());
795        }
796
797        if issues.is_empty() {
798            Ok(())
799        } else {
800            // Treat as a warning only – log and continue
801            tracing::warn!("SAMM model validation warnings: {}", issues.join("; "));
802            Ok(())
803        }
804    }
805
806    /// Parse a raw RDF literal string for a given SAMM property.
807    ///
808    /// Delegates to [`parse_rdf_literal`] with the property's data type as hint.
809    pub fn parse_property_literal(
810        &self,
811        prop: &SammPhysicsProperty,
812        literal: &str,
813    ) -> PhysicsResult<PhysicalValue> {
814        let datatype_hint: Option<&str> = match &prop.data_type {
815            SammDataType::Double => Some("xsd:double"),
816            SammDataType::Integer => Some("xsd:integer"),
817            _ => None,
818        };
819
820        let mut pv = parse_rdf_literal(literal, datatype_hint)?;
821
822        // Override unit from SAMM property if the literal has no unit annotation
823        if matches!(pv.unit, PhysicalUnit::Dimensionless) {
824            if let Some(ref unit_str) = prop.unit {
825                pv.unit = parse_unit_str(unit_str);
826            }
827        }
828
829        Ok(pv)
830    }
831}
832
833// ──────────────────────────────────────────────────────────────────────────────
834// Private helper functions
835// ──────────────────────────────────────────────────────────────────────────────
836
837/// Extract the lexical value from a `Term::Literal`.
838fn literal_value(term: &Term) -> Option<String> {
839    if let Term::Literal(lit) = term {
840        Some(lit.value().to_string())
841    } else {
842        None
843    }
844}
845
846/// Extract the IRI string from a `Term::NamedNode`.
847fn named_node_str(term: &Term) -> Option<&str> {
848    if let Term::NamedNode(nn) = term {
849        Some(nn.as_str())
850    } else {
851        None
852    }
853}
854
855/// Return the local name portion of a URI (fragment or last path segment).
856fn local_name_of(uri: &str) -> String {
857    // Try fragment identifier first (text after #)
858    if let Some(fragment) = uri.split_once('#').map(|(_, frag)| frag) {
859        if !fragment.is_empty() {
860            return fragment.to_string();
861        }
862    }
863    // Fall back to last path segment
864    uri.split('/')
865        .next_back()
866        .and_then(|s| if s.is_empty() { None } else { Some(s) })
867        .unwrap_or("unknown")
868        .to_string()
869}
870
871// ──────────────────────────────────────────────────────────────────────────────
872// Tests
873// ──────────────────────────────────────────────────────────────────────────────
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    use std::env;
879
880    /// Minimal SAMM TTL that covers Aspects, Properties, Characteristics, and constraints.
881    const SAMPLE_SAMM_TTL: &str = r#"
882        @prefix samm:    <urn:samm:org.eclipse.esmf.samm:meta-model:2.0.0#> .
883        @prefix samm-c:  <urn:samm:org.eclipse.esmf.samm:characteristic:2.0.0#> .
884        @prefix xsd:     <http://www.w3.org/2001/XMLSchema#> .
885        @prefix phys:    <http://oxirs.org/physics#> .
886        @prefix rdfs:    <http://www.w3.org/2000/01/rdf-schema#> .
887        @prefix rdf:     <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
888        @prefix unit:    <urn:samm:org.eclipse.esmf.samm:unit:2.0.0#> .
889
890        phys:ThermalAspect a samm:Aspect ;
891            rdfs:label "Thermal Aspect" ;
892            samm:description "Thermal properties of a physical body" ;
893            samm:properties phys:temperature ;
894            samm:properties phys:heatCapacity .
895
896        phys:temperature a samm:Property ;
897            rdfs:label "temperature" ;
898            samm:description "Thermodynamic temperature of the body" ;
899            samm:characteristic phys:TemperatureChar .
900
901        phys:TemperatureChar a samm-c:Measurement ;
902            samm:dataType xsd:double ;
903            samm:unit "K" ;
904            samm-c:minValue "0.0"^^xsd:double ;
905            samm-c:maxValue "5000.0"^^xsd:double .
906
907        phys:heatCapacity a samm:Property ;
908            rdfs:label "heatCapacity" ;
909            samm:description "Specific heat capacity" ;
910            samm:characteristic phys:HeatCapChar .
911
912        phys:HeatCapChar a samm-c:Quantifiable ;
913            samm:dataType xsd:double ;
914            samm:unit "J" ;
915            samm-c:minValue "0.0"^^xsd:double .
916    "#;
917
918    // ── Parsing ───────────────────────────────────────────────────────────────
919
920    #[tokio::test]
921    async fn test_parse_samm_string_finds_aspect() {
922        let parser = SammAspectParser::new().expect("parser creation failed");
923        let model = parser
924            .parse_samm_string(SAMPLE_SAMM_TTL)
925            .await
926            .expect("parse failed");
927
928        assert!(
929            !model.aspects.is_empty(),
930            "Expected at least one samm:Aspect"
931        );
932        // HashMap ordering is not guaranteed, so search for the aspect by name
933        let thermal_aspect = model.aspects.iter().find(|a| a.name == "Thermal Aspect");
934        assert!(
935            thermal_aspect.is_some(),
936            "Expected 'Thermal Aspect' in aspects list, got: {:?}",
937            model.aspects.iter().map(|a| &a.name).collect::<Vec<_>>()
938        );
939    }
940
941    #[tokio::test]
942    async fn test_parse_samm_string_finds_properties() {
943        let parser = SammAspectParser::new().expect("parser creation failed");
944        let model = parser
945            .parse_samm_string(SAMPLE_SAMM_TTL)
946            .await
947            .expect("parse failed");
948
949        assert!(
950            !model.properties.is_empty(),
951            "Expected at least one samm:Property"
952        );
953    }
954
955    #[tokio::test]
956    async fn test_parse_samm_finds_temperature_property() {
957        let parser = SammAspectParser::new().expect("parser creation failed");
958        let model = parser
959            .parse_samm_string(SAMPLE_SAMM_TTL)
960            .await
961            .expect("parse failed");
962
963        let temp = model.property_by_name("temperature");
964        assert!(temp.is_some(), "temperature property not found");
965        let tp = temp.expect("already checked");
966        assert_eq!(tp.data_type, SammDataType::Double);
967    }
968
969    #[tokio::test]
970    async fn test_parse_samm_characteristic_type() {
971        let parser = SammAspectParser::new().expect("parser creation failed");
972        let model = parser
973            .parse_samm_string(SAMPLE_SAMM_TTL)
974            .await
975            .expect("parse failed");
976
977        let temp = model.property_by_name("temperature");
978        if let Some(prop) = temp {
979            if let Some(ref char_type) = prop.characteristic {
980                assert!(
981                    matches!(char_type, SammCharacteristic::Measurement),
982                    "Expected Measurement characteristic"
983                );
984            }
985        }
986    }
987
988    #[tokio::test]
989    async fn test_parse_samm_range_constraints() {
990        let parser = SammAspectParser::new().expect("parser creation failed");
991        let model = parser
992            .parse_samm_string(SAMPLE_SAMM_TTL)
993            .await
994            .expect("parse failed");
995
996        let temp = model.property_by_name("temperature");
997        if let Some(prop) = temp {
998            if let Some(min) = prop.range_min {
999                assert!((min - 0.0).abs() < 1e-10, "min should be 0.0");
1000            }
1001            if let Some(max) = prop.range_max {
1002                assert!((max - 5000.0).abs() < 1e-10, "max should be 5000.0");
1003            }
1004        }
1005    }
1006
1007    // ── File parsing ──────────────────────────────────────────────────────────
1008
1009    #[tokio::test]
1010    async fn test_parse_samm_file() {
1011        let parser = SammAspectParser::new().expect("parser creation failed");
1012
1013        // Write TTL to a temp file
1014        let tmp_dir = env::temp_dir();
1015        let tmp_path = tmp_dir.join("oxirs_physics_samm_test.ttl");
1016        std::fs::write(&tmp_path, SAMPLE_SAMM_TTL).expect("failed to write temp file");
1017
1018        let model = parser
1019            .parse_samm_file(&tmp_path)
1020            .await
1021            .expect("file parse failed");
1022
1023        // Clean up
1024        let _ = std::fs::remove_file(&tmp_path);
1025
1026        assert!(!model.aspects.is_empty() || !model.properties.is_empty());
1027    }
1028
1029    // ── Prefix map ────────────────────────────────────────────────────────────
1030
1031    #[test]
1032    fn test_extract_prefix_map() {
1033        let parser = SammAspectParser::new().expect("parser creation failed");
1034        let map = parser.extract_prefix_map(SAMPLE_SAMM_TTL);
1035
1036        assert!(map.contains_key("samm"), "samm prefix not found");
1037        assert!(map.contains_key("xsd"), "xsd prefix not found");
1038        assert!(map.contains_key("phys"), "phys prefix not found");
1039    }
1040
1041    #[test]
1042    fn test_extract_prefix_map_empty() {
1043        let parser = SammAspectParser::new().expect("parser creation failed");
1044        let map = parser.extract_prefix_map("# no prefixes here\n?x a ?y .");
1045        assert!(map.is_empty());
1046    }
1047
1048    // ── Bridge to simulation params ───────────────────────────────────────────
1049
1050    #[tokio::test]
1051    async fn test_bridge_to_simulation_params() {
1052        let parser = SammAspectParser::new().expect("parser creation failed");
1053        let model = parser
1054            .parse_samm_string(SAMPLE_SAMM_TTL)
1055            .await
1056            .expect("parse failed");
1057
1058        let params = parser
1059            .bridge_to_simulation_params(&model, "urn:example:body:1", "thermal")
1060            .expect("bridge failed");
1061
1062        assert_eq!(params.entity_iri, "urn:example:body:1");
1063        assert_eq!(params.simulation_type, "thermal");
1064        assert!(
1065            !params.initial_conditions.is_empty(),
1066            "no initial conditions"
1067        );
1068    }
1069
1070    #[test]
1071    fn test_bridge_empty_model_is_error() {
1072        let parser = SammAspectParser::new().expect("parser creation failed");
1073        let empty_model = SammAspectModel {
1074            aspects: Vec::new(),
1075            properties: Vec::new(),
1076            prefix_map: HashMap::new(),
1077        };
1078        let result = parser.bridge_to_simulation_params(&empty_model, "urn:e:1", "thermal");
1079        assert!(result.is_err(), "expected error for empty model");
1080    }
1081
1082    // ── extract_physical_value ────────────────────────────────────────────────
1083
1084    #[tokio::test]
1085    async fn test_extract_physical_value_temperature() {
1086        let parser = SammAspectParser::new().expect("parser creation failed");
1087        let model = parser
1088            .parse_samm_string(SAMPLE_SAMM_TTL)
1089            .await
1090            .expect("parse failed");
1091
1092        // Only test if property was parsed
1093        if model.property_by_name("temperature").is_some() {
1094            let pv = parser.extract_physical_value(&model, "temperature");
1095            assert!(pv.is_some(), "physical value should be extractable");
1096        }
1097    }
1098
1099    #[test]
1100    fn test_extract_physical_value_missing_property() {
1101        let parser = SammAspectParser::new().expect("parser creation failed");
1102        let empty_model = SammAspectModel {
1103            aspects: Vec::new(),
1104            properties: Vec::new(),
1105            prefix_map: HashMap::new(),
1106        };
1107        let pv = parser.extract_physical_value(&empty_model, "nonexistent");
1108        assert!(pv.is_none());
1109    }
1110
1111    // ── parse_property_literal ────────────────────────────────────────────────
1112
1113    #[test]
1114    fn test_parse_property_literal_double_with_unit() {
1115        let parser = SammAspectParser::new().expect("parser creation failed");
1116        let prop = SammPhysicsProperty {
1117            urn: "urn:test:prop".to_string(),
1118            name: "temperature".to_string(),
1119            description: None,
1120            data_type: SammDataType::Double,
1121            characteristic: Some(SammCharacteristic::Measurement),
1122            unit: Some("K".to_string()),
1123            physical_unit: Some(PhysicalUnit::Kelvin),
1124            range_min: Some(0.0),
1125            range_max: Some(5000.0),
1126            enum_values: Vec::new(),
1127            is_required: true,
1128        };
1129
1130        // Literal with explicit unit annotation
1131        let pv = parser
1132            .parse_property_literal(&prop, "300.0 K")
1133            .expect("parse failed");
1134        assert!((pv.value - 300.0).abs() < 1e-10);
1135        assert_eq!(pv.unit, PhysicalUnit::Kelvin);
1136    }
1137
1138    #[test]
1139    fn test_parse_property_literal_bare_number_uses_property_unit() {
1140        let parser = SammAspectParser::new().expect("parser creation failed");
1141        let prop = SammPhysicsProperty {
1142            urn: "urn:test:mass".to_string(),
1143            name: "mass".to_string(),
1144            description: None,
1145            data_type: SammDataType::Double,
1146            characteristic: None,
1147            unit: Some("kg".to_string()),
1148            physical_unit: Some(PhysicalUnit::KiloGram),
1149            range_min: None,
1150            range_max: None,
1151            enum_values: Vec::new(),
1152            is_required: false,
1153        };
1154
1155        let pv = parser
1156            .parse_property_literal(&prop, "75.5")
1157            .expect("parse failed");
1158        assert!((pv.value - 75.5).abs() < 1e-10);
1159        // Unit should be resolved to KiloGram from property definition
1160        assert_eq!(pv.unit, PhysicalUnit::KiloGram);
1161    }
1162
1163    // ── SammDataType helpers ──────────────────────────────────────────────────
1164
1165    #[test]
1166    fn test_samm_data_type_from_iri_xsd_double() {
1167        assert_eq!(
1168            SammDataType::from_iri("http://www.w3.org/2001/XMLSchema#double"),
1169            SammDataType::Double
1170        );
1171    }
1172
1173    #[test]
1174    fn test_samm_data_type_from_iri_xsd_integer() {
1175        assert_eq!(
1176            SammDataType::from_iri("http://www.w3.org/2001/XMLSchema#integer"),
1177            SammDataType::Integer
1178        );
1179    }
1180
1181    #[test]
1182    fn test_samm_data_type_from_iri_entity() {
1183        let dt = SammDataType::from_iri("http://example.org/physics#Vector3D");
1184        assert!(matches!(dt, SammDataType::Entity(_)));
1185    }
1186
1187    #[test]
1188    fn test_samm_data_type_is_numeric() {
1189        assert!(SammDataType::Double.is_numeric());
1190        assert!(SammDataType::Integer.is_numeric());
1191        assert!(!SammDataType::Text.is_numeric());
1192        assert!(!SammDataType::Boolean.is_numeric());
1193    }
1194
1195    // ── SammCharacteristic ────────────────────────────────────────────────────
1196
1197    #[test]
1198    fn test_samm_characteristic_from_iri() {
1199        assert_eq!(
1200            SammCharacteristic::from_iri("urn:samm:...Measurement"),
1201            SammCharacteristic::Measurement
1202        );
1203        assert_eq!(
1204            SammCharacteristic::from_iri("urn:samm:...Enumeration"),
1205            SammCharacteristic::Enumeration
1206        );
1207    }
1208
1209    // ── SammAspectModel helpers ───────────────────────────────────────────────
1210
1211    #[test]
1212    fn test_samm_model_numeric_properties_filter() {
1213        let model = SammAspectModel {
1214            aspects: Vec::new(),
1215            properties: vec![
1216                SammPhysicsProperty {
1217                    urn: "urn:a".to_string(),
1218                    name: "mass".to_string(),
1219                    description: None,
1220                    data_type: SammDataType::Double,
1221                    characteristic: None,
1222                    unit: Some("kg".to_string()),
1223                    physical_unit: Some(PhysicalUnit::KiloGram),
1224                    range_min: None,
1225                    range_max: None,
1226                    enum_values: Vec::new(),
1227                    is_required: true,
1228                },
1229                SammPhysicsProperty {
1230                    urn: "urn:b".to_string(),
1231                    name: "label".to_string(),
1232                    description: None,
1233                    data_type: SammDataType::Text,
1234                    characteristic: None,
1235                    unit: None,
1236                    physical_unit: None,
1237                    range_min: None,
1238                    range_max: None,
1239                    enum_values: Vec::new(),
1240                    is_required: false,
1241                },
1242            ],
1243            prefix_map: HashMap::new(),
1244        };
1245
1246        let numeric: Vec<_> = model.numeric_properties().collect();
1247        assert_eq!(numeric.len(), 1);
1248        assert_eq!(numeric[0].name, "mass");
1249    }
1250
1251    #[test]
1252    fn test_validate_model_for_simulation_ok() {
1253        let parser = SammAspectParser::new().expect("parser creation failed");
1254        let model = SammAspectModel {
1255            aspects: vec![SammAspect {
1256                urn: "urn:aspect:1".to_string(),
1257                name: "TestAspect".to_string(),
1258                description: None,
1259                property_urns: Vec::new(),
1260            }],
1261            properties: Vec::new(),
1262            prefix_map: HashMap::new(),
1263        };
1264        // Should succeed even with no numeric properties
1265        assert!(parser.validate_model_for_simulation(&model).is_ok());
1266    }
1267
1268    // ── local_name_of helper ──────────────────────────────────────────────────
1269
1270    #[test]
1271    fn test_local_name_of_fragment() {
1272        assert_eq!(local_name_of("http://example.org/ns#mass"), "mass");
1273    }
1274
1275    #[test]
1276    fn test_local_name_of_path() {
1277        assert_eq!(local_name_of("http://example.org/physics/mass"), "mass");
1278    }
1279
1280    #[test]
1281    fn test_local_name_of_empty() {
1282        let result = local_name_of("");
1283        assert_eq!(result, "unknown");
1284    }
1285}