1pub 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
80pub const SAMM_NS: &str = "urn:samm:org.eclipse.esmf.samm:meta-model:2.0.0#";
86pub const SAMM_C_NS: &str = "urn:samm:org.eclipse.esmf.samm:characteristic:2.0.0#";
88pub const SAMM_UNIT_NS: &str = "urn:samm:org.eclipse.esmf.samm:unit:2.0.0#";
90pub const QUDT_UNIT_NS: &str = "http://qudt.org/vocab/unit/";
92pub const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema#";
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub enum SammDataType {
102 Double,
104 Integer,
106 Text,
108 Boolean,
110 DateTime,
112 Entity(String),
114 Unknown(String),
116}
117
118impl SammDataType {
119 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 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 pub fn is_numeric(&self) -> bool {
150 matches!(self, Self::Double | Self::Integer)
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub enum SammCharacteristic {
157 Measurement,
159 Quantifiable,
161 Enumeration,
163 Duration,
165 Collection,
167 Code,
169 Other(String),
171}
172
173impl SammCharacteristic {
174 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#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SammPhysicsProperty {
197 pub urn: String,
199 pub name: String,
201 pub description: Option<String>,
203 pub data_type: SammDataType,
205 pub characteristic: Option<SammCharacteristic>,
207 pub unit: Option<String>,
209 pub physical_unit: Option<PhysicalUnit>,
211 pub range_min: Option<f64>,
213 pub range_max: Option<f64>,
215 pub enum_values: Vec<String>,
217 pub is_required: bool,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct SammAspect {
224 pub urn: String,
226 pub name: String,
228 pub description: Option<String>,
230 pub property_urns: Vec<String>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct SammAspectModel {
237 pub aspects: Vec<SammAspect>,
239 pub properties: Vec<SammPhysicsProperty>,
241 pub prefix_map: HashMap<String, String>,
243}
244
245impl SammAspectModel {
246 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 pub fn numeric_properties(&self) -> impl Iterator<Item = &SammPhysicsProperty> {
256 self.properties.iter().filter(|p| p.data_type.is_numeric())
257 }
258
259 pub fn measured_properties(&self) -> impl Iterator<Item = &SammPhysicsProperty> {
261 self.properties.iter().filter(|p| p.physical_unit.is_some())
262 }
263}
264
265pub struct SammAspectParser {
274 physics_ns: String,
276 samm_ns: String,
278 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 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 pub fn with_physics_namespace(mut self, ns: impl Into<String>) -> Self {
304 self.physics_ns = ns.into();
305 self
306 }
307
308 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 pub async fn parse_samm_string(&self, content: &str) -> PhysicsResult<SammAspectModel> {
326 let mut store = RdfStore::new()
328 .map_err(|e| PhysicsError::SammParsing(format!("Failed to create RDF store: {}", e)))?;
329
330 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 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 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 if let Some(label) = label_opt {
407 if aspect.name != label {
408 aspect.name = label;
409 }
410 }
411 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 async fn extract_physics_properties(
431 &self,
432 store: &Arc<RdfStore>,
433 ) -> PhysicsResult<Vec<SammPhysicsProperty>> {
434 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 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 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 char_details
502 .entry(char_iri)
503 .or_insert((characteristic, data_type, unit_str));
504 }
505 }
506
507 let query = "SELECT ?x WHERE { }"; let _ = query; 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 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 self.enrich_with_constraints(store, &mut props).await?;
565
566 Ok(props.into_values().collect())
567 }
568
569 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 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 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 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 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 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 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 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 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 tracing::warn!("SAMM model validation warnings: {}", issues.join("; "));
802 Ok(())
803 }
804 }
805
806 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 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
833fn 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
846fn 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
855fn local_name_of(uri: &str) -> String {
857 if let Some(fragment) = uri.split_once('#').map(|(_, frag)| frag) {
859 if !fragment.is_empty() {
860 return fragment.to_string();
861 }
862 }
863 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#[cfg(test)]
876mod tests {
877 use super::*;
878 use std::env;
879
880 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 #[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 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 #[tokio::test]
1010 async fn test_parse_samm_file() {
1011 let parser = SammAspectParser::new().expect("parser creation failed");
1012
1013 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 let _ = std::fs::remove_file(&tmp_path);
1025
1026 assert!(!model.aspects.is_empty() || !model.properties.is_empty());
1027 }
1028
1029 #[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 #[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 #[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 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 #[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 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 assert_eq!(pv.unit, PhysicalUnit::KiloGram);
1161 }
1162
1163 #[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 #[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 #[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 assert!(parser.validate_model_for_simulation(&model).is_ok());
1266 }
1267
1268 #[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}