use std::collections::HashMap;
use crate::error::{PhysicsError, PhysicsResult};
use super::state_to_rdf::{PhysicsState, PhysicsStateValue};
#[derive(Debug, Clone, PartialEq)]
pub struct RdfPropertyRow {
pub predicate: String,
pub literal: String,
pub datatype: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RdfToStateExtractor;
#[derive(Debug, Clone)]
pub struct RdfToStateOutput {
pub state: PhysicsState,
pub skipped: Vec<String>,
}
impl RdfToStateExtractor {
pub fn new() -> Self {
Self
}
pub fn extract(
&self,
entity_iri: impl Into<String>,
step: u64,
rows: &[RdfPropertyRow],
) -> PhysicsResult<RdfToStateOutput> {
let entity = entity_iri.into();
if entity.is_empty() {
return Err(PhysicsError::ParameterExtraction(
"entity_iri must not be empty".to_string(),
));
}
let mut state = PhysicsState::new(&entity);
state.step = step;
let mut skipped = Vec::new();
let mut values: HashMap<String, PhysicsStateValue> = HashMap::new();
for row in rows {
match row.predicate.as_str() {
"step" | "timestamp" | "simulatesEntity" => continue,
_ => {}
}
match parse_value(row) {
Some(v) => {
values.insert(row.predicate.clone(), v);
}
None => skipped.push(row.predicate.clone()),
}
}
state.values = values;
Ok(RdfToStateOutput { state, skipped })
}
}
fn parse_value(row: &RdfPropertyRow) -> Option<PhysicsStateValue> {
let datatype = row.datatype.as_deref().unwrap_or("");
if datatype.ends_with("#double")
|| datatype.ends_with("#float")
|| datatype.ends_with("#decimal")
{
return row
.literal
.parse::<f64>()
.ok()
.map(PhysicsStateValue::Scalar);
}
if datatype.ends_with("#integer") || datatype.ends_with("#int") || datatype.ends_with("#long") {
return row
.literal
.parse::<i64>()
.ok()
.map(PhysicsStateValue::Integer);
}
if datatype.ends_with("#boolean") {
return match row.literal.as_str() {
"true" | "1" => Some(PhysicsStateValue::Bool(true)),
"false" | "0" => Some(PhysicsStateValue::Bool(false)),
_ => None,
};
}
if datatype.ends_with("#string") || datatype.is_empty() {
let trimmed = row.literal.trim();
if trimmed.contains(',') {
let parts: Result<Vec<f64>, _> = trimmed
.split(',')
.map(|s| s.trim().parse::<f64>())
.collect();
if let Ok(v) = parts {
return Some(PhysicsStateValue::Vector(v));
}
}
return Some(PhysicsStateValue::Text(row.literal.clone()));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn double_row(p: &str, lit: &str) -> RdfPropertyRow {
RdfPropertyRow {
predicate: p.to_string(),
literal: lit.to_string(),
datatype: Some("http://www.w3.org/2001/XMLSchema#double".to_string()),
}
}
fn boolean_row(p: &str, lit: &str) -> RdfPropertyRow {
RdfPropertyRow {
predicate: p.to_string(),
literal: lit.to_string(),
datatype: Some("http://www.w3.org/2001/XMLSchema#boolean".to_string()),
}
}
fn string_row(p: &str, lit: &str) -> RdfPropertyRow {
RdfPropertyRow {
predicate: p.to_string(),
literal: lit.to_string(),
datatype: Some("http://www.w3.org/2001/XMLSchema#string".to_string()),
}
}
fn integer_row(p: &str, lit: &str) -> RdfPropertyRow {
RdfPropertyRow {
predicate: p.to_string(),
literal: lit.to_string(),
datatype: Some("http://www.w3.org/2001/XMLSchema#integer".to_string()),
}
}
#[test]
fn extract_basic_state() {
let rows = vec![
double_row("temperature", "298.15"),
double_row("voltage", "3.71"),
boolean_row("is_charging", "true"),
string_row("label", "running"),
integer_row("cycles", "42"),
];
let extractor = RdfToStateExtractor::new();
let out = extractor
.extract("urn:example:battery:001", 7, &rows)
.expect("extract should succeed");
assert_eq!(out.state.entity_iri, "urn:example:battery:001");
assert_eq!(out.state.step, 7);
assert_eq!(out.state.values.len(), 5);
assert!(matches!(
out.state.values.get("temperature"),
Some(PhysicsStateValue::Scalar(_))
));
assert!(matches!(
out.state.values.get("is_charging"),
Some(PhysicsStateValue::Bool(true))
));
assert!(matches!(
out.state.values.get("cycles"),
Some(PhysicsStateValue::Integer(42))
));
assert!(out.skipped.is_empty());
}
#[test]
fn extract_rejects_empty_entity_iri() {
let extractor = RdfToStateExtractor::new();
let result = extractor.extract("", 0, &[]);
assert!(matches!(result, Err(PhysicsError::ParameterExtraction(_))));
}
#[test]
fn extract_skips_unparseable_double() {
let rows = vec![double_row("voltage", "not-a-number")];
let extractor = RdfToStateExtractor::new();
let out = extractor
.extract("urn:example:e", 0, &rows)
.expect("extract should succeed");
assert_eq!(out.skipped, vec!["voltage".to_string()]);
assert!(out.state.values.is_empty());
}
#[test]
fn extract_recovers_vector_from_csv_string() {
let rows = vec![string_row("velocity", "1.0,2.0,3.0")];
let out = RdfToStateExtractor::new()
.extract("urn:example:e", 0, &rows)
.expect("extract should succeed");
match out.state.values.get("velocity") {
Some(PhysicsStateValue::Vector(v)) => assert_eq!(v.as_slice(), &[1.0, 2.0, 3.0]),
other => panic!("expected vector, got {other:?}"),
}
}
#[test]
fn extract_keeps_textual_string() {
let rows = vec![string_row("status", "running")];
let out = RdfToStateExtractor::new()
.extract("urn:example:e", 0, &rows)
.expect("extract should succeed");
match out.state.values.get("status") {
Some(PhysicsStateValue::Text(s)) => assert_eq!(s, "running"),
other => panic!("expected text, got {other:?}"),
}
}
#[test]
fn extract_skips_core_predicates() {
let rows = vec![
integer_row("step", "12"),
string_row("timestamp", "2026-04-30T00:00:00Z"),
string_row("simulatesEntity", "urn:example:e"),
double_row("voltage", "3.71"),
];
let out = RdfToStateExtractor::new()
.extract("urn:example:e", 12, &rows)
.expect("extract should succeed");
assert_eq!(out.state.values.len(), 1);
assert!(out.state.values.contains_key("voltage"));
}
#[test]
fn boolean_supports_alternate_lexical_forms() {
let rows = vec![
boolean_row("is_charging", "1"),
boolean_row("is_running", "false"),
];
let out = RdfToStateExtractor::new()
.extract("urn:example:e", 0, &rows)
.expect("extract should succeed");
assert!(matches!(
out.state.values.get("is_charging"),
Some(PhysicsStateValue::Bool(true))
));
assert!(matches!(
out.state.values.get("is_running"),
Some(PhysicsStateValue::Bool(false))
));
}
}