Skip to main content

oxiphysics_io/database_io/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#[allow(unused_imports)]
6use super::functions::*;
7use std::collections::{HashMap, VecDeque};
8
9/// A single row in the simulation database, mapping column names to values.
10#[derive(Debug, Clone, Default)]
11pub struct DbRow {
12    /// Map from column name to value.
13    pub columns: HashMap<String, DbValue>,
14}
15impl DbRow {
16    /// Create an empty row.
17    pub fn new() -> Self {
18        Self::default()
19    }
20    /// Insert a key-value pair into this row.
21    pub fn set(&mut self, key: impl Into<String>, val: impl Into<DbValue>) {
22        self.columns.insert(key.into(), val.into());
23    }
24    /// Get a value by column name.
25    pub fn get(&self, key: &str) -> Option<&DbValue> {
26        self.columns.get(key)
27    }
28}
29/// Metadata for a single simulation run.
30#[derive(Debug, Clone)]
31pub struct SimulationMetadata {
32    /// Unique simulation identifier.
33    pub sim_id: String,
34    /// Human-readable description.
35    pub description: String,
36    /// Creation timestamp (Unix seconds, as f64).
37    pub created_at: f64,
38    /// Arbitrary key-value parameters.
39    pub parameters: HashMap<String, String>,
40    /// List of artifact file paths associated with this simulation.
41    pub artifacts: Vec<String>,
42}
43impl SimulationMetadata {
44    /// Create a new metadata entry with empty parameters and artifacts.
45    pub fn new(sim_id: impl Into<String>, description: impl Into<String>, created_at: f64) -> Self {
46        Self {
47            sim_id: sim_id.into(),
48            description: description.into(),
49            created_at,
50            parameters: HashMap::new(),
51            artifacts: Vec::new(),
52        }
53    }
54    /// Add a simulation parameter.
55    pub fn set_param(&mut self, key: impl Into<String>, val: impl Into<String>) {
56        self.parameters.insert(key.into(), val.into());
57    }
58    /// Add an artifact path.
59    pub fn add_artifact(&mut self, path: impl Into<String>) {
60        self.artifacts.push(path.into());
61    }
62}
63/// Registry of simulation metadata entries (data catalog / provenance store).
64#[derive(Debug, Default)]
65pub struct DataCatalog {
66    pub(super) entries: HashMap<String, SimulationMetadata>,
67}
68impl DataCatalog {
69    /// Create an empty data catalog.
70    pub fn new() -> Self {
71        Self::default()
72    }
73    /// Register a simulation entry.
74    pub fn register(&mut self, meta: SimulationMetadata) {
75        self.entries.insert(meta.sim_id.clone(), meta);
76    }
77    /// Look up a simulation by ID.
78    pub fn lookup(&self, sim_id: &str) -> Option<&SimulationMetadata> {
79        self.entries.get(sim_id)
80    }
81    /// Look up a simulation by ID (mutable).
82    pub fn lookup_mut(&mut self, sim_id: &str) -> Option<&mut SimulationMetadata> {
83        self.entries.get_mut(sim_id)
84    }
85    /// Remove a simulation by ID.
86    ///
87    /// Returns `true` if an entry was removed.
88    pub fn remove(&mut self, sim_id: &str) -> bool {
89        self.entries.remove(sim_id).is_some()
90    }
91    /// Search for simulations whose description contains `query` (case-insensitive).
92    pub fn search_description(&self, query: &str) -> Vec<&SimulationMetadata> {
93        let q = query.to_lowercase();
94        self.entries
95            .values()
96            .filter(|m| m.description.to_lowercase().contains(&q))
97            .collect()
98    }
99    /// Return all simulation IDs.
100    pub fn all_ids(&self) -> Vec<&str> {
101        self.entries.keys().map(|s| s.as_str()).collect()
102    }
103    /// Number of registered simulations.
104    pub fn len(&self) -> usize {
105        self.entries.len()
106    }
107    /// Returns `true` if no simulations are registered.
108    pub fn is_empty(&self) -> bool {
109        self.entries.is_empty()
110    }
111}
112/// Append-only time series with range query and decimation.
113#[derive(Debug, Default)]
114pub struct TimeSeriesStore {
115    /// Internal storage (always sorted by time).
116    pub(super) samples: Vec<TsSample>,
117    /// Label/name of this time series.
118    pub label: String,
119}
120impl TimeSeriesStore {
121    /// Create a new, empty time series with the given label.
122    pub fn new(label: impl Into<String>) -> Self {
123        Self {
124            samples: Vec::new(),
125            label: label.into(),
126        }
127    }
128    /// Append a sample.  Samples should be added in ascending time order.
129    pub fn append(&mut self, time: f64, value: f64) {
130        self.samples.push(TsSample::new(time, value));
131    }
132    /// Number of samples stored.
133    pub fn len(&self) -> usize {
134        self.samples.len()
135    }
136    /// Returns `true` if no samples have been recorded.
137    pub fn is_empty(&self) -> bool {
138        self.samples.is_empty()
139    }
140    /// Return all samples whose timestamp is in `[t_start, t_end]`.
141    pub fn range_query(&self, t_start: f64, t_end: f64) -> Vec<&TsSample> {
142        self.samples
143            .iter()
144            .filter(|s| s.time >= t_start && s.time <= t_end)
145            .collect()
146    }
147    /// Decimate the series by keeping every `n`-th sample.
148    ///
149    /// Returns a new `TimeSeriesStore` with the decimated data.
150    pub fn decimate(&self, n: usize) -> TimeSeriesStore {
151        let mut out = TimeSeriesStore::new(format!("{}_dec{n}", self.label));
152        if n == 0 {
153            return out;
154        }
155        for (i, s) in self.samples.iter().enumerate() {
156            if i % n == 0 {
157                out.append(s.time, s.value);
158            }
159        }
160        out
161    }
162    /// Compute the mean value over a time range `[t_start, t_end]`.
163    pub fn mean_in_range(&self, t_start: f64, t_end: f64) -> Option<f64> {
164        let vals: Vec<f64> = self
165            .range_query(t_start, t_end)
166            .iter()
167            .map(|s| s.value)
168            .collect();
169        if vals.is_empty() {
170            None
171        } else {
172            Some(vals.iter().sum::<f64>() / vals.len() as f64)
173        }
174    }
175    /// Return the maximum value in the entire series.
176    pub fn max_value(&self) -> Option<f64> {
177        self.samples.iter().map(|s| s.value).reduce(f64::max)
178    }
179    /// Return the minimum value in the entire series.
180    pub fn min_value(&self) -> Option<f64> {
181        self.samples.iter().map(|s| s.value).reduce(f64::min)
182    }
183    /// Export the series to a CSV string `"time,value\n..."`.
184    pub fn to_csv(&self) -> String {
185        let mut out = String::from("time,value\n");
186        for s in &self.samples {
187            out.push_str(&format!("{},{}\n", s.time, s.value));
188        }
189        out
190    }
191    /// Interpolate the value at time `t` using linear interpolation.
192    ///
193    /// Returns `None` if the series is empty or `t` is out of range.
194    pub fn interpolate(&self, t: f64) -> Option<f64> {
195        if self.samples.is_empty() {
196            return None;
197        }
198        let pos = self.samples.partition_point(|s| s.time <= t);
199        if pos == 0 {
200            return Some(self.samples[0].value);
201        }
202        if pos >= self.samples.len() {
203            return Some(
204                self.samples
205                    .last()
206                    .expect("collection should not be empty")
207                    .value,
208            );
209        }
210        let lo = &self.samples[pos - 1];
211        let hi = &self.samples[pos];
212        let dt = hi.time - lo.time;
213        if dt < 1e-15 {
214            return Some(lo.value);
215        }
216        let frac = (t - lo.time) / dt;
217        Some(lo.value + frac * (hi.value - lo.value))
218    }
219}
220/// Catalog of simulation snapshots supporting lazy-loading semantics.
221///
222/// Snapshots are stored by ID. The catalog provides range queries by
223/// simulation time and tag-based filtering.
224#[derive(Debug, Default)]
225pub struct SnapshotCatalog {
226    pub(super) snapshots: HashMap<String, SnapshotEntry>,
227    /// Auto-increment counter for generated IDs.
228    pub(super) next_id: u64,
229}
230impl SnapshotCatalog {
231    /// Create an empty catalog.
232    pub fn new() -> Self {
233        Self::default()
234    }
235    /// Register a snapshot entry and return its id.
236    pub fn register(&mut self, entry: SnapshotEntry) -> &str {
237        let id = entry.id.clone();
238        self.snapshots.insert(id.clone(), entry);
239        self.snapshots[&id].id.as_str()
240    }
241    /// Auto-generate an id and register a snapshot.
242    ///
243    /// Returns the generated id as an owned `String`.
244    pub fn register_auto(&mut self, sim_time: f64, path: impl Into<String>) -> String {
245        let id = format!("snap_{:06}", self.next_id);
246        self.next_id += 1;
247        self.register(SnapshotEntry::new(id.clone(), sim_time, path));
248        id
249    }
250    /// Look up a snapshot by id.
251    pub fn get(&self, id: &str) -> Option<&SnapshotEntry> {
252        self.snapshots.get(id)
253    }
254    /// Look up a snapshot mutably by id.
255    pub fn get_mut(&mut self, id: &str) -> Option<&mut SnapshotEntry> {
256        self.snapshots.get_mut(id)
257    }
258    /// Remove a snapshot by id.  Returns `true` if removed.
259    pub fn remove(&mut self, id: &str) -> bool {
260        self.snapshots.remove(id).is_some()
261    }
262    /// Number of snapshots in the catalog.
263    pub fn len(&self) -> usize {
264        self.snapshots.len()
265    }
266    /// Returns `true` if the catalog is empty.
267    pub fn is_empty(&self) -> bool {
268        self.snapshots.is_empty()
269    }
270    /// Return all snapshots whose `sim_time` is in `[t_start, t_end]`.
271    pub fn range_query(&self, t_start: f64, t_end: f64) -> Vec<&SnapshotEntry> {
272        self.snapshots
273            .values()
274            .filter(|s| s.sim_time >= t_start && s.sim_time <= t_end)
275            .collect()
276    }
277    /// Return all snapshots that contain `tag`.
278    pub fn query_by_tag(&self, tag: &str) -> Vec<&SnapshotEntry> {
279        self.snapshots
280            .values()
281            .filter(|s| s.tags.iter().any(|t| t == tag))
282            .collect()
283    }
284    /// Return ids of all unloaded snapshots.
285    pub fn unloaded_ids(&self) -> Vec<&str> {
286        self.snapshots
287            .values()
288            .filter(|s| !s.loaded)
289            .map(|s| s.id.as_str())
290            .collect()
291    }
292    /// Mark all snapshots in a time range as loaded.
293    pub fn mark_range_loaded(&mut self, t_start: f64, t_end: f64) {
294        for s in self.snapshots.values_mut() {
295            if s.sim_time >= t_start && s.sim_time <= t_end {
296                s.loaded = true;
297            }
298        }
299    }
300    /// Compute total file size across all snapshots.
301    pub fn total_file_size(&self) -> usize {
302        self.snapshots.values().map(|s| s.file_size).sum()
303    }
304    /// Return snapshots sorted by sim_time (ascending).
305    pub fn sorted_by_time(&self) -> Vec<&SnapshotEntry> {
306        let mut v: Vec<&SnapshotEntry> = self.snapshots.values().collect();
307        v.sort_by(|a, b| {
308            a.sim_time
309                .partial_cmp(&b.sim_time)
310                .unwrap_or(std::cmp::Ordering::Equal)
311        });
312        v
313    }
314}
315/// A cached simulation result identified by a string key.
316#[derive(Debug, Clone)]
317pub struct CacheEntry {
318    /// Cache key.
319    pub key: String,
320    /// Serialised or raw result data.
321    pub data: Vec<f64>,
322    /// Simulation time at which this result was computed.
323    pub sim_time: f64,
324    /// Cache version tag for invalidation.
325    pub version: u64,
326}
327impl CacheEntry {
328    /// Create a new cache entry.
329    pub fn new(key: impl Into<String>, data: Vec<f64>, sim_time: f64, version: u64) -> Self {
330        Self {
331            key: key.into(),
332            data,
333            sim_time,
334            version,
335        }
336    }
337}
338/// A single simulation run record with metadata.
339///
340/// Stores the simulation name, Unix timestamp, and arbitrary string parameters.
341#[derive(Debug, Clone)]
342pub struct SimulationRecord {
343    /// Unique run name / identifier.
344    pub name: String,
345    /// Unix timestamp (seconds since epoch) as f64.
346    pub timestamp: f64,
347    /// Arbitrary key-value simulation parameters.
348    pub params: HashMap<String, String>,
349    /// Optional output file path.
350    pub output_path: Option<String>,
351}
352impl SimulationRecord {
353    /// Create a new record with the given name and timestamp.
354    pub fn new(name: impl Into<String>, timestamp: f64) -> Self {
355        Self {
356            name: name.into(),
357            timestamp,
358            params: HashMap::new(),
359            output_path: None,
360        }
361    }
362    /// Set a parameter value.
363    pub fn set_param(&mut self, key: impl Into<String>, val: impl Into<String>) {
364        self.params.insert(key.into(), val.into());
365    }
366    /// Get a parameter value by key.
367    pub fn get_param(&self, key: &str) -> Option<&str> {
368        self.params.get(key).map(|s| s.as_str())
369    }
370    /// Set the output file path.
371    pub fn set_output(&mut self, path: impl Into<String>) {
372        self.output_path = Some(path.into());
373    }
374}
375/// Serialize/deserialize `SimulationRecord` to a simple JSON-like text format.
376///
377/// The format is:
378/// ```text
379/// {"name":"`n`","timestamp":`t`,"params":{"k1":"v1",...},"output":"`path`"}
380/// ```
381#[derive(Debug, Clone, Default)]
382pub struct DatabaseSerializer;
383impl DatabaseSerializer {
384    /// Create a new serializer.
385    pub fn new() -> Self {
386        Self
387    }
388    /// Serialize a `SimulationRecord` to a JSON-like string.
389    pub fn serialize(&self, rec: &SimulationRecord) -> String {
390        let params_str: Vec<String> = rec
391            .params
392            .iter()
393            .map(|(k, v)| format!(r#""{k}":"{v}""#))
394            .collect();
395        let params_json = format!("{{{}}}", params_str.join(","));
396        let output_str = match &rec.output_path {
397            Some(p) => format!(r#""{p}""#),
398            None => "null".to_string(),
399        };
400        format!(
401            r#"{{"name":"{name}","timestamp":{ts},"params":{params},"output":{out}}}"#,
402            name = rec.name,
403            ts = rec.timestamp,
404            params = params_json,
405            out = output_str,
406        )
407    }
408    /// Serialize a list of records to a JSON array string.
409    pub fn serialize_all(&self, recs: &[&SimulationRecord]) -> String {
410        let items: Vec<String> = recs.iter().map(|r| self.serialize(r)).collect();
411        format!("[{}]", items.join(","))
412    }
413    /// Deserialize a single record from a JSON-like string.
414    ///
415    /// Uses minimal parsing; fields must appear in the order produced by
416    /// `serialize`. Returns `None` on parse failure.
417    pub fn deserialize(&self, s: &str) -> Option<SimulationRecord> {
418        let name = self.extract_string_field(s, "name")?;
419        let ts_str = self.extract_raw_field(s, "timestamp")?;
420        let timestamp: f64 = ts_str.trim().parse().ok()?;
421        let mut rec = SimulationRecord::new(name, timestamp);
422        let out_raw = self.extract_raw_field(s, "output").unwrap_or_default();
423        let out_raw = out_raw.trim();
424        if out_raw != "null" && out_raw.starts_with('"') {
425            let cleaned = out_raw.trim_matches('"').to_string();
426            rec.set_output(cleaned);
427        }
428        Some(rec)
429    }
430    fn extract_string_field(&self, s: &str, field: &str) -> Option<String> {
431        let key = format!(r#""{field}":""#);
432        let start = s.find(key.as_str())? + key.len();
433        let rest = &s[start..];
434        let end = rest.find('"')?;
435        Some(rest[..end].to_string())
436    }
437    fn extract_raw_field(&self, s: &str, field: &str) -> Option<String> {
438        let key = format!(r#""{field}":"#);
439        let start = s.find(key.as_str())? + key.len();
440        let rest = &s[start..];
441        let end = rest.find([',', '}']).unwrap_or(rest.len());
442        Some(rest[..end].to_string())
443    }
444}
445/// A single sample in a time series.
446#[derive(Debug, Clone)]
447pub struct TsSample {
448    /// Timestamp in seconds.
449    pub time: f64,
450    /// Scalar value at this timestamp.
451    pub value: f64,
452}
453impl TsSample {
454    /// Create a new time series sample.
455    pub fn new(time: f64, value: f64) -> Self {
456        Self { time, value }
457    }
458}
459/// A material entry in the material database.
460#[derive(Debug, Clone)]
461pub struct MaterialRecord {
462    /// Material name/identifier.
463    pub name: String,
464    /// Mass density in kg/m³.
465    pub density: f64,
466    /// Young's modulus in Pa.
467    pub youngs_modulus: f64,
468    /// Poisson's ratio (dimensionless).
469    pub poisson_ratio: f64,
470    /// Thermal conductivity in W/(m·K).
471    pub thermal_conductivity: f64,
472    /// Yield strength in Pa (0 if not applicable).
473    pub yield_strength: f64,
474    /// Arbitrary tags for fuzzy search.
475    pub tags: Vec<String>,
476}
477impl MaterialRecord {
478    /// Create a new material record.
479    #[allow(clippy::too_many_arguments)]
480    pub fn new(
481        name: impl Into<String>,
482        density: f64,
483        youngs_modulus: f64,
484        poisson_ratio: f64,
485        thermal_conductivity: f64,
486        yield_strength: f64,
487        tags: Vec<String>,
488    ) -> Self {
489        Self {
490            name: name.into(),
491            density,
492            youngs_modulus,
493            poisson_ratio,
494            thermal_conductivity,
495            yield_strength,
496            tags,
497        }
498    }
499}
500/// Column filter for export: keep only these columns (if empty, keep all).
501#[derive(Debug, Clone, Default)]
502pub struct ExportFilter {
503    /// Columns to include (empty = all columns).
504    pub columns: Vec<String>,
505    /// Optional minimum value for a numeric column named `filter_col`.
506    pub min_value: Option<(String, f64)>,
507    /// Optional maximum value for a numeric column named `filter_col`.
508    pub max_value: Option<(String, f64)>,
509}
510impl ExportFilter {
511    /// Create an empty (pass-all) filter.
512    pub fn new() -> Self {
513        Self::default()
514    }
515    /// Check whether a row passes the numeric range filters.
516    pub fn row_passes(&self, row: &DbRow) -> bool {
517        if let Some((col, lo)) = &self.min_value {
518            let v = row
519                .get(col)
520                .and_then(|v| v.as_f64())
521                .unwrap_or(f64::NEG_INFINITY);
522            if v < *lo {
523                return false;
524            }
525        }
526        if let Some((col, hi)) = &self.max_value {
527            let v = row
528                .get(col)
529                .and_then(|v| v.as_f64())
530                .unwrap_or(f64::INFINITY);
531            if v > *hi {
532                return false;
533            }
534        }
535        true
536    }
537}
538/// Export format selector.
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum ExportFormat {
541    /// Comma-separated values.
542    Csv,
543    /// JSON array of objects.
544    Json,
545    /// Simplified HDF5-like text representation.
546    Hdf5Text,
547}
548/// Export pipeline: converts `SimulationDatabase` rows to text output.
549#[derive(Debug)]
550pub struct ExportPipeline {
551    /// Output format.
552    pub format: ExportFormat,
553    /// Row filter.
554    pub filter: ExportFilter,
555}
556impl ExportPipeline {
557    /// Create a new export pipeline with the given format and no filter.
558    pub fn new(format: ExportFormat) -> Self {
559        Self {
560            format,
561            filter: ExportFilter::new(),
562        }
563    }
564    /// Set the export filter.
565    pub fn with_filter(mut self, filter: ExportFilter) -> Self {
566        self.filter = filter;
567        self
568    }
569    /// Run the export, returning the serialised output as a `String`.
570    pub fn export(&self, db: &SimulationDatabase) -> String {
571        let cols = &self.filter.columns;
572        let rows: Vec<&DbRow> = db
573            .rows
574            .iter()
575            .filter(|r| self.filter.row_passes(r))
576            .collect();
577        match self.format {
578            ExportFormat::Csv => self.to_csv(&rows, cols),
579            ExportFormat::Json => self.to_json(&rows, cols),
580            ExportFormat::Hdf5Text => self.to_hdf5_text(&rows, cols, &db.name),
581        }
582    }
583    /// Determine effective column list from rows.
584    fn effective_cols(&self, rows: &[&DbRow], cols: &[String]) -> Vec<String> {
585        if cols.is_empty() {
586            let mut seen: Vec<String> = Vec::new();
587            for row in rows {
588                for k in row.columns.keys() {
589                    if !seen.contains(k) {
590                        seen.push(k.clone());
591                    }
592                }
593            }
594            seen.sort();
595            seen
596        } else {
597            cols.to_vec()
598        }
599    }
600    fn value_to_str(v: &DbValue) -> String {
601        match v {
602            DbValue::Int(i) => i.to_string(),
603            DbValue::Float(f) => format!("{f}"),
604            DbValue::Text(s) => s.clone(),
605            DbValue::Bool(b) => b.to_string(),
606            DbValue::Null => "".to_string(),
607        }
608    }
609    fn to_csv(&self, rows: &[&DbRow], cols: &[String]) -> String {
610        let headers = self.effective_cols(rows, cols);
611        let mut out = headers.join(",");
612        out.push('\n');
613        for row in rows {
614            let line: Vec<String> = headers
615                .iter()
616                .map(|c| row.get(c).map(Self::value_to_str).unwrap_or_default())
617                .collect();
618            out.push_str(&line.join(","));
619            out.push('\n');
620        }
621        out
622    }
623    fn to_json(&self, rows: &[&DbRow], cols: &[String]) -> String {
624        let headers = self.effective_cols(rows, cols);
625        let mut out = String::from("[\n");
626        for (ri, row) in rows.iter().enumerate() {
627            out.push_str("  {");
628            let fields: Vec<String> = headers
629                .iter()
630                .map(|c| {
631                    let val = row
632                        .get(c)
633                        .map(|v| match v {
634                            DbValue::Text(s) => format!(r#""{s}""#),
635                            DbValue::Null => "null".to_string(),
636                            other => Self::value_to_str(other),
637                        })
638                        .unwrap_or_else(|| "null".to_string());
639                    format!(r#""{c}":{val}"#)
640                })
641                .collect();
642            out.push_str(&fields.join(","));
643            out.push('}');
644            if ri + 1 < rows.len() {
645                out.push(',');
646            }
647            out.push('\n');
648        }
649        out.push(']');
650        out
651    }
652    fn to_hdf5_text(&self, rows: &[&DbRow], cols: &[String], table_name: &str) -> String {
653        let headers = self.effective_cols(rows, cols);
654        let mut out = format!("# HDF5-like text dump of table '{table_name}'\n");
655        out.push_str(&format!("# columns: {}\n", headers.join(",")));
656        out.push_str(&format!("# rows: {}\n", rows.len()));
657        for row in rows {
658            let line: Vec<String> = headers
659                .iter()
660                .map(|c| row.get(c).map(Self::value_to_str).unwrap_or_default())
661                .collect();
662            out.push_str(&line.join(" "));
663            out.push('\n');
664        }
665        out
666    }
667}
668/// In-memory material property database with fuzzy search and interpolation.
669#[derive(Debug, Default)]
670pub struct MaterialDatabase {
671    pub(super) records: Vec<MaterialRecord>,
672}
673impl MaterialDatabase {
674    /// Create an empty material database.
675    pub fn new() -> Self {
676        Self::default()
677    }
678    /// Create a database pre-populated with common engineering materials.
679    pub fn with_defaults() -> Self {
680        let mut db = Self::new();
681        db.insert(MaterialRecord::new(
682            "steel_1020",
683            7850.0,
684            210e9,
685            0.29,
686            50.0,
687            250e6,
688            vec!["metal".into(), "steel".into(), "ferrous".into()],
689        ));
690        db.insert(MaterialRecord::new(
691            "aluminium_6061",
692            2700.0,
693            69e9,
694            0.33,
695            167.0,
696            276e6,
697            vec!["metal".into(), "aluminium".into(), "light".into()],
698        ));
699        db.insert(MaterialRecord::new(
700            "copper",
701            8960.0,
702            110e9,
703            0.34,
704            385.0,
705            70e6,
706            vec!["metal".into(), "copper".into(), "conductor".into()],
707        ));
708        db.insert(MaterialRecord::new(
709            "polycarbonate",
710            1200.0,
711            2.4e9,
712            0.37,
713            0.2,
714            60e6,
715            vec!["polymer".into(), "plastic".into(), "transparent".into()],
716        ));
717        db.insert(MaterialRecord::new(
718            "concrete",
719            2300.0,
720            30e9,
721            0.20,
722            1.7,
723            3e6,
724            vec!["composite".into(), "concrete".into(), "brittle".into()],
725        ));
726        db
727    }
728    /// Insert a material record.
729    pub fn insert(&mut self, record: MaterialRecord) {
730        self.records.push(record);
731    }
732    /// Exact lookup by name.
733    pub fn lookup(&self, name: &str) -> Option<&MaterialRecord> {
734        self.records.iter().find(|r| r.name == name)
735    }
736    /// Fuzzy search: return all records whose name or tags contain `query`
737    /// (case-insensitive substring match).
738    pub fn fuzzy_search(&self, query: &str) -> Vec<&MaterialRecord> {
739        let q = query.to_lowercase();
740        self.records
741            .iter()
742            .filter(|r| {
743                r.name.to_lowercase().contains(&q)
744                    || r.tags.iter().any(|t| t.to_lowercase().contains(&q))
745            })
746            .collect()
747    }
748    /// Interpolate material properties between two named materials.
749    ///
750    /// Returns a new `MaterialRecord` with blended properties at weight `t`
751    /// (0 = pure first material, 1 = pure second material).
752    ///
753    /// Returns `None` if either material is not found.
754    pub fn interpolate(&self, name_a: &str, name_b: &str, t: f64) -> Option<MaterialRecord> {
755        let a = self.lookup(name_a)?;
756        let b = self.lookup(name_b)?;
757        let lerp = |va: f64, vb: f64| va + t * (vb - va);
758        Some(MaterialRecord::new(
759            format!("{name_a}_to_{name_b}_{t:.2}"),
760            lerp(a.density, b.density),
761            lerp(a.youngs_modulus, b.youngs_modulus),
762            lerp(a.poisson_ratio, b.poisson_ratio),
763            lerp(a.thermal_conductivity, b.thermal_conductivity),
764            lerp(a.yield_strength, b.yield_strength),
765            vec![],
766        ))
767    }
768    /// Number of materials in the database.
769    pub fn len(&self) -> usize {
770        self.records.len()
771    }
772    /// Returns `true` if no materials are stored.
773    pub fn is_empty(&self) -> bool {
774        self.records.is_empty()
775    }
776    /// Return all material names.
777    pub fn names(&self) -> Vec<&str> {
778        self.records.iter().map(|r| r.name.as_str()).collect()
779    }
780}
781/// LRU cache for expensive simulation results.
782///
783/// Evicts least-recently-used entries when the capacity limit is reached.
784/// Invalidation is by version number: entries with a stale version are evicted.
785#[derive(Debug)]
786pub struct ResultCache {
787    /// Maximum number of entries.
788    pub capacity: usize,
789    /// Current cache version; entries below this are invalid.
790    pub current_version: u64,
791    /// LRU queue: front = most recently used.
792    pub(super) entries: VecDeque<CacheEntry>,
793}
794impl ResultCache {
795    /// Create a new LRU cache with the given capacity.
796    pub fn new(capacity: usize) -> Self {
797        Self {
798            capacity,
799            current_version: 0,
800            entries: VecDeque::new(),
801        }
802    }
803    /// Insert or update a cache entry.  Evicts LRU entry when over capacity.
804    pub fn put(&mut self, entry: CacheEntry) {
805        self.entries.retain(|e| e.key != entry.key);
806        self.entries.push_front(entry);
807        while self.entries.len() > self.capacity {
808            self.entries.pop_back();
809        }
810    }
811    /// Retrieve a cached entry by key, moving it to the front (MRU).
812    ///
813    /// Returns `None` if the key is not found or the entry is stale.
814    pub fn get(&mut self, key: &str) -> Option<&CacheEntry> {
815        let pos = self.entries.iter().position(|e| e.key == key)?;
816        let entry = self.entries.remove(pos)?;
817        if entry.version < self.current_version {
818            return None;
819        }
820        self.entries.push_front(entry);
821        self.entries.front()
822    }
823    /// Increment the version, invalidating all current cache entries.
824    pub fn invalidate_all(&mut self) {
825        self.current_version += 1;
826    }
827    /// Remove a specific key from the cache.
828    pub fn remove(&mut self, key: &str) {
829        self.entries.retain(|e| e.key != key);
830    }
831    /// Number of entries currently in the cache.
832    pub fn len(&self) -> usize {
833        self.entries.len()
834    }
835    /// Returns `true` if the cache is empty.
836    pub fn is_empty(&self) -> bool {
837        self.entries.is_empty()
838    }
839    /// Evict all stale entries (version < current_version).
840    pub fn evict_stale(&mut self) {
841        let ver = self.current_version;
842        self.entries.retain(|e| e.version >= ver);
843    }
844}
845/// In-memory CRUD store for `SimulationRecord` entries.
846///
847/// Supports insert, lookup by name, update, delete, and query filtering.
848#[derive(Debug, Default)]
849pub struct SimulationRecordDatabase {
850    pub(super) records: HashMap<String, SimulationRecord>,
851}
852impl SimulationRecordDatabase {
853    /// Create an empty database.
854    pub fn new() -> Self {
855        Self::default()
856    }
857    /// Insert or replace a record.
858    pub fn insert(&mut self, rec: SimulationRecord) {
859        self.records.insert(rec.name.clone(), rec);
860    }
861    /// Look up a record by name.
862    pub fn get(&self, name: &str) -> Option<&SimulationRecord> {
863        self.records.get(name)
864    }
865    /// Look up a record mutably by name.
866    pub fn get_mut(&mut self, name: &str) -> Option<&mut SimulationRecord> {
867        self.records.get_mut(name)
868    }
869    /// Delete a record by name.  Returns `true` if the record existed.
870    pub fn delete(&mut self, name: &str) -> bool {
871        self.records.remove(name).is_some()
872    }
873    /// Number of records.
874    pub fn count(&self) -> usize {
875        self.records.len()
876    }
877    /// Returns `true` if there are no records.
878    pub fn is_empty(&self) -> bool {
879        self.records.is_empty()
880    }
881    /// Query: return all records that match the given `DatabaseQuery`.
882    pub fn query(&self, q: &DatabaseQuery) -> Vec<&SimulationRecord> {
883        self.records.values().filter(|r| q.matches(r)).collect()
884    }
885    /// Return all record names.
886    pub fn names(&self) -> Vec<&str> {
887        self.records.keys().map(|s| s.as_str()).collect()
888    }
889    /// Clear all records.
890    pub fn clear(&mut self) {
891        self.records.clear();
892    }
893}
894/// Metadata for a single simulation snapshot.
895#[derive(Debug, Clone)]
896pub struct SnapshotEntry {
897    /// Snapshot identifier (e.g. `"frame_0042"`).
898    pub id: String,
899    /// Simulation time at which this snapshot was taken (s).
900    pub sim_time: f64,
901    /// File path or URI for lazy loading (empty = not yet saved).
902    pub path: String,
903    /// File size in bytes (0 if unknown).
904    pub file_size: usize,
905    /// Whether this snapshot has been loaded into memory.
906    pub loaded: bool,
907    /// Optional per-snapshot tags.
908    pub tags: Vec<String>,
909}
910impl SnapshotEntry {
911    /// Create a new, unloaded snapshot entry.
912    pub fn new(id: impl Into<String>, sim_time: f64, path: impl Into<String>) -> Self {
913        Self {
914            id: id.into(),
915            sim_time,
916            path: path.into(),
917            file_size: 0,
918            loaded: false,
919            tags: Vec::new(),
920        }
921    }
922    /// Mark this snapshot as loaded.
923    pub fn mark_loaded(&mut self) {
924        self.loaded = true;
925    }
926    /// Add a tag.
927    pub fn add_tag(&mut self, tag: impl Into<String>) {
928        self.tags.push(tag.into());
929    }
930}
931/// A simple in-memory relational table for simulation data.
932///
933/// Supports insert, column-based equality filtering, and projection.
934#[derive(Debug, Default)]
935pub struct SimulationDatabase {
936    /// All rows stored in the table.
937    pub rows: Vec<DbRow>,
938    /// Table name for metadata/export purposes.
939    pub name: String,
940}
941impl SimulationDatabase {
942    /// Create a new, named, empty table.
943    pub fn new(name: impl Into<String>) -> Self {
944        Self {
945            rows: Vec::new(),
946            name: name.into(),
947        }
948    }
949    /// Insert a row into the table.
950    pub fn insert(&mut self, row: DbRow) {
951        self.rows.push(row);
952    }
953    /// Count the number of rows.
954    pub fn row_count(&self) -> usize {
955        self.rows.len()
956    }
957    /// Query rows where column `col` equals `val`.
958    pub fn query_eq(&self, col: &str, val: &DbValue) -> Vec<&DbRow> {
959        self.rows
960            .iter()
961            .filter(|r| r.get(col).map(|v| v == val).unwrap_or(false))
962            .collect()
963    }
964    /// Query rows where column `col` (numeric) is in `[lo, hi]`.
965    pub fn query_range(&self, col: &str, lo: f64, hi: f64) -> Vec<&DbRow> {
966        self.rows
967            .iter()
968            .filter(|r| {
969                r.get(col)
970                    .and_then(|v| v.as_f64())
971                    .map(|f| f >= lo && f <= hi)
972                    .unwrap_or(false)
973            })
974            .collect()
975    }
976    /// Return a projected view: for each row, extract the listed columns.
977    pub fn project(&self, cols: &[&str]) -> Vec<HashMap<String, DbValue>> {
978        self.rows
979            .iter()
980            .map(|r| {
981                cols.iter()
982                    .filter_map(|&c| r.get(c).map(|v| (c.to_string(), v.clone())))
983                    .collect()
984            })
985            .collect()
986    }
987    /// Delete all rows where column `col` equals `val`.
988    ///
989    /// Returns the number of rows deleted.
990    pub fn delete_eq(&mut self, col: &str, val: &DbValue) -> usize {
991        let before = self.rows.len();
992        self.rows
993            .retain(|r| r.get(col).map(|v| v != val).unwrap_or(true));
994        before - self.rows.len()
995    }
996    /// Clear all rows.
997    pub fn clear(&mut self) {
998        self.rows.clear();
999    }
1000    /// Compute the mean of a numeric column across all rows.
1001    ///
1002    /// Returns `None` if no numeric values are found.
1003    pub fn column_mean(&self, col: &str) -> Option<f64> {
1004        let vals: Vec<f64> = self
1005            .rows
1006            .iter()
1007            .filter_map(|r| r.get(col).and_then(|v| v.as_f64()))
1008            .collect();
1009        if vals.is_empty() {
1010            None
1011        } else {
1012            Some(vals.iter().sum::<f64>() / vals.len() as f64)
1013        }
1014    }
1015}
1016/// Query predicate for filtering `SimulationRecord` entries.
1017#[derive(Debug, Clone, Default)]
1018pub struct DatabaseQuery {
1019    /// Keep only records whose timestamp >= this value.
1020    pub time_start: Option<f64>,
1021    /// Keep only records whose timestamp <= this value.
1022    pub time_end: Option<f64>,
1023    /// Keep only records whose name starts with this prefix.
1024    pub name_prefix: Option<String>,
1025    /// Keep only records that have param `key` equal to `value`.
1026    pub param_filter: Option<(String, String)>,
1027}
1028impl DatabaseQuery {
1029    /// Create an empty (pass-all) query.
1030    pub fn new() -> Self {
1031        Self::default()
1032    }
1033    /// Filter by time range `[t_start, t_end]`.
1034    pub fn with_time_range(mut self, t_start: f64, t_end: f64) -> Self {
1035        self.time_start = Some(t_start);
1036        self.time_end = Some(t_end);
1037        self
1038    }
1039    /// Filter by name prefix.
1040    pub fn with_name_prefix(mut self, prefix: impl Into<String>) -> Self {
1041        self.name_prefix = Some(prefix.into());
1042        self
1043    }
1044    /// Filter by a parameter key=value match.
1045    pub fn with_param(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
1046        self.param_filter = Some((key.into(), val.into()));
1047        self
1048    }
1049    /// Check whether a `SimulationRecord` passes this query.
1050    pub fn matches(&self, rec: &SimulationRecord) -> bool {
1051        if let Some(t0) = self.time_start
1052            && rec.timestamp < t0
1053        {
1054            return false;
1055        }
1056        if let Some(t1) = self.time_end
1057            && rec.timestamp > t1
1058        {
1059            return false;
1060        }
1061        if let Some(ref prefix) = self.name_prefix
1062            && !rec.name.starts_with(prefix.as_str())
1063        {
1064            return false;
1065        }
1066        if let Some((ref k, ref v)) = self.param_filter {
1067            match rec.params.get(k.as_str()) {
1068                Some(pv) if pv == v => {}
1069                _ => return false,
1070            }
1071        }
1072        true
1073    }
1074}
1075/// A column value in the simulation database.
1076#[derive(Debug, Clone, PartialEq)]
1077pub enum DbValue {
1078    /// Integer value.
1079    Int(i64),
1080    /// Floating-point value.
1081    Float(f64),
1082    /// Text value.
1083    Text(String),
1084    /// Boolean value.
1085    Bool(bool),
1086    /// Null / missing value.
1087    Null,
1088}
1089impl DbValue {
1090    /// Return `Some(f64)` if this value is `Float` or `Int`, else `None`.
1091    pub fn as_f64(&self) -> Option<f64> {
1092        match self {
1093            DbValue::Float(v) => Some(*v),
1094            DbValue::Int(v) => Some(*v as f64),
1095            _ => None,
1096        }
1097    }
1098    /// Return `Some(&str)` if this value is `Text`, else `None`.
1099    pub fn as_str(&self) -> Option<&str> {
1100        match self {
1101            DbValue::Text(s) => Some(s.as_str()),
1102            _ => None,
1103        }
1104    }
1105}