Skip to main content

mech_sim/
metrics.rs

1use serde::{Deserialize, Serialize};
2
3use crate::config::SimulationConfig;
4use crate::state::{DerivedMetricRecord, EventRecord, LimbBufferRecord, TimeSeriesRecord};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct RunSummary {
8    pub run_name: String,
9    pub scenario: String,
10    pub success: bool,
11    pub failure_reasons: Vec<String>,
12    pub duration_s: f64,
13    pub dt_s: f64,
14    pub seed: u64,
15    pub min_ep_j: f64,
16    pub max_ep_j: f64,
17    pub final_ep_j: f64,
18    pub peak_temperature_k: f64,
19    pub final_temperature_k: f64,
20    pub time_below_energy_threshold_s: f64,
21    pub time_above_thermal_threshold_s: f64,
22    pub max_actuator_demand_w: f64,
23    pub max_delivered_actuator_power_w: f64,
24    pub saturation_count: usize,
25    pub recharge_time_s: Option<f64>,
26    pub delivered_mechanical_work_j: f64,
27    pub mean_delivered_ratio: f64,
28    pub mean_authority_utilization: f64,
29    pub reduced_response_efficiency: f64,
30    pub effective_duty_cycle: f64,
31    pub local_imbalance_max_j: f64,
32    pub local_imbalance_rms_j: f64,
33    pub min_local_buffer_j: f64,
34    pub max_abs_y_m: f64,
35    pub max_abs_v_mps: f64,
36    pub min_gain: f64,
37    pub min_delivered_ratio: f64,
38    pub energy_depleted_j: f64,
39    pub energy_depleted_gj: f64,
40    pub recharge_fraction_of_full_reserve: f64,
41    pub ideal_refill_time_s: Option<f64>,
42    pub recharge_readiness_fraction: f64,
43    pub successful_burst_fraction: f64,
44    pub degraded_state_fraction: f64,
45    pub percent_time_thermal_limited: f64,
46    pub authority_loss_at_thermal_breach: Option<f64>,
47    pub event_count: usize,
48    pub energy_breach: bool,
49    pub thermal_breach: bool,
50    pub local_buffer_breach: bool,
51    pub saturation_breach: bool,
52    pub first_admissible_breach_s: Option<f64>,
53    pub admissible_breach_count: usize,
54    pub ep_clamped_count: usize,
55    pub t_clamped_count: usize,
56    pub y_clamped_count: usize,
57    pub ydot_clamped_count: usize,
58    pub percent_time_outside_admissible_region: f64,
59    pub first_energy_breach_s: Option<f64>,
60    pub first_thermal_breach_s: Option<f64>,
61    pub first_local_buffer_breach_s: Option<f64>,
62    pub first_saturation_breach_s: Option<f64>,
63    pub stability_target_proxy: String,
64    pub v_initial: f64,
65    pub v_final: f64,
66    pub v_max: f64,
67    pub d_v_positive_fraction: f64,
68    pub local_stability_margin: f64,
69    pub first_positive_d_v_time_s: Option<f64>,
70    pub time_to_any_threshold_s: Option<f64>,
71}
72
73pub fn summarize(
74    config: &SimulationConfig,
75    time_series: &[TimeSeriesRecord],
76    limb_buffers: &[LimbBufferRecord],
77    events: &[EventRecord],
78) -> RunSummary {
79    let dt_s = config.solver.dt_s;
80    let min_ep_j = time_series
81        .iter()
82        .map(|record| record.ep_j)
83        .fold(f64::INFINITY, f64::min);
84    let max_ep_j = time_series
85        .iter()
86        .map(|record| record.ep_j)
87        .fold(f64::NEG_INFINITY, f64::max);
88    let final_ep_j = time_series.last().map(|record| record.ep_j).unwrap_or(0.0);
89    let peak_temperature_k = time_series
90        .iter()
91        .map(|record| record.temperature_k)
92        .fold(f64::NEG_INFINITY, f64::max);
93    let final_temperature_k = time_series
94        .last()
95        .map(|record| record.temperature_k)
96        .unwrap_or(config.model.thermal_initial_k);
97    let time_below_energy_threshold_s = time_series
98        .iter()
99        .filter(|record| record.ep_j < config.model.low_energy_threshold_j)
100        .count() as f64
101        * dt_s;
102    let time_above_thermal_threshold_s = time_series
103        .iter()
104        .filter(|record| record.temperature_k >= config.model.thermal_limit_k)
105        .count() as f64
106        * dt_s;
107    let percent_time_thermal_limited = if config.solver.duration_s > 0.0 {
108        100.0 * time_above_thermal_threshold_s / config.solver.duration_s
109    } else {
110        0.0
111    };
112    let max_actuator_demand_w = time_series
113        .iter()
114        .map(|record| record.requested_actuator_power_w)
115        .fold(0.0, f64::max);
116    let max_delivered_actuator_power_w = time_series
117        .iter()
118        .map(|record| record.delivered_actuator_power_w)
119        .fold(0.0, f64::max);
120    let mean_delivered_ratio = mean(time_series.iter().map(|record| record.delivered_ratio));
121    let mean_authority_utilization = mean(
122        time_series
123            .iter()
124            .map(|record| record.authority_utilization),
125    );
126    let saturation_count = time_series
127        .iter()
128        .filter(|record| record.saturation_fraction > 0.0)
129        .count();
130    let delivered_mechanical_work_j = time_series
131        .iter()
132        .map(|record| record.mechanical_power_w.max(0.0) * dt_s)
133        .sum();
134    let total_requested_energy_j: f64 = time_series
135        .iter()
136        .map(|record| record.requested_actuator_power_w.max(0.0) * dt_s)
137        .sum();
138    let reduced_response_efficiency = if total_requested_energy_j > 1.0 {
139        delivered_mechanical_work_j / total_requested_energy_j
140    } else {
141        0.0
142    };
143    let effective_duty_cycle = if config.solver.duration_s > 0.0 {
144        time_series
145            .iter()
146            .filter(|record| record.command_fraction > 0.2 && record.saturation_fraction < 0.05)
147            .count() as f64
148            * dt_s
149            / config.solver.duration_s
150    } else {
151        0.0
152    };
153    let max_abs_y_m = time_series
154        .iter()
155        .map(|record| record.y_m.abs())
156        .fold(0.0, f64::max);
157    let max_abs_v_mps = time_series
158        .iter()
159        .map(|record| record.v_mps.abs())
160        .fold(0.0, f64::max);
161    let min_gain = time_series
162        .iter()
163        .filter(|record| record.time_s > 0.0 || record.gain > 0.0)
164        .map(|record| record.gain)
165        .fold(f64::INFINITY, f64::min);
166    let min_delivered_ratio = time_series
167        .iter()
168        .filter(|record| record.time_s > 0.0 || record.delivered_ratio < 1.0)
169        .map(|record| record.delivered_ratio)
170        .fold(f64::INFINITY, f64::min);
171
172    let (local_imbalance_max_j, local_imbalance_rms_j, min_local_buffer_j) =
173        local_imbalance(limb_buffers);
174    let recharge_time_s = recharge_time(time_series, config.model.pulse_energy_max_j);
175    let energy_depleted_j = (config.model.pulse_energy_initial_j - min_ep_j).max(0.0);
176    let energy_depleted_gj = energy_depleted_j / 1.0e9;
177    let recharge_fraction_of_full_reserve =
178        energy_depleted_j / config.model.pulse_energy_max_j.max(1.0);
179    let ideal_refill_time_s =
180        if config.model.recharge_efficiency * config.model.continuous_power_w > 1.0 {
181            Some(
182                energy_depleted_j
183                    / (config.model.recharge_efficiency * config.model.continuous_power_w),
184            )
185        } else {
186            None
187        };
188    let recharge_readiness_fraction = recharge_readiness_fraction(
189        time_series,
190        limb_buffers,
191        config.model.pulse_energy_max_j,
192        config.model.local_buffer_energy_max_j,
193    );
194    let successful_burst_fraction = successful_burst_fraction(config, time_series);
195    let degraded_state_fraction = degraded_state_fraction(config, time_series);
196
197    let first_energy_breach_s = events
198        .iter()
199        .find(|event| event.event_type == "energy_low")
200        .map(|event| event.time_s);
201    let first_thermal_breach_s = events
202        .iter()
203        .find(|event| event.event_type == "thermal_high")
204        .map(|event| event.time_s);
205    let authority_loss_at_thermal_breach = first_thermal_breach_s.and_then(|time_s| {
206        time_series
207            .iter()
208            .find(|record| record.time_s >= time_s)
209            .map(|record| 1.0 - record.authority_utilization)
210    });
211    let first_local_buffer_breach_s = events
212        .iter()
213        .find(|event| event.event_type == "local_buffer_low")
214        .map(|event| event.time_s);
215    let first_saturation_breach_s = events
216        .iter()
217        .find(|event| event.event_type == "actuator_saturation")
218        .map(|event| event.time_s);
219    let first_admissible_breach_s = events
220        .iter()
221        .find(|event| event.event_type == "admissible_region_breach")
222        .map(|event| event.time_s);
223    let admissible_breach_count = events
224        .iter()
225        .filter(|event| event.event_type == "admissible_region_breach")
226        .count();
227    let ep_clamped_count = time_series
228        .iter()
229        .filter(|record| record.ep_clamped)
230        .count();
231    let t_clamped_count = time_series
232        .iter()
233        .filter(|record| record.temperature_clamped)
234        .count();
235    let y_clamped_count = time_series.iter().filter(|record| record.y_clamped).count();
236    let ydot_clamped_count = time_series
237        .iter()
238        .filter(|record| record.ydot_clamped)
239        .count();
240    let percent_time_outside_admissible_region = percent(
241        time_series
242            .iter()
243            .filter(|record| record.outside_admissible_region)
244            .count(),
245        time_series.len(),
246    );
247    let time_to_any_threshold_s = [
248        first_energy_breach_s,
249        first_thermal_breach_s,
250        first_local_buffer_breach_s,
251        first_saturation_breach_s,
252        first_admissible_breach_s,
253    ]
254    .into_iter()
255    .flatten()
256    .fold(None::<f64>, |acc, value| match acc {
257        Some(current) => Some(current.min(value)),
258        None => Some(value),
259    });
260
261    let energy_breach = first_energy_breach_s.is_some();
262    let thermal_breach = first_thermal_breach_s.is_some();
263    let local_buffer_breach = first_local_buffer_breach_s.is_some();
264    let saturation_breach = first_saturation_breach_s.is_some();
265    let mut failure_reasons = Vec::new();
266    if energy_breach {
267        failure_reasons.push("pulse energy dropped below threshold".to_string());
268    }
269    if thermal_breach {
270        failure_reasons.push("aggregate thermal state exceeded threshold".to_string());
271    }
272    if local_buffer_breach {
273        failure_reasons.push("local limb buffer dropped below threshold".to_string());
274    }
275    if saturation_breach {
276        failure_reasons.push("actuator delivery saturated".to_string());
277    }
278
279    RunSummary {
280        run_name: config.name.clone(),
281        scenario: config.scenario.name.clone(),
282        success: failure_reasons.is_empty(),
283        failure_reasons,
284        duration_s: config.solver.duration_s,
285        dt_s,
286        seed: config.seed,
287        min_ep_j,
288        max_ep_j,
289        final_ep_j,
290        peak_temperature_k,
291        final_temperature_k,
292        time_below_energy_threshold_s,
293        time_above_thermal_threshold_s,
294        max_actuator_demand_w,
295        max_delivered_actuator_power_w,
296        saturation_count,
297        recharge_time_s,
298        delivered_mechanical_work_j,
299        mean_delivered_ratio,
300        mean_authority_utilization,
301        reduced_response_efficiency,
302        effective_duty_cycle,
303        local_imbalance_max_j,
304        local_imbalance_rms_j,
305        min_local_buffer_j,
306        max_abs_y_m,
307        max_abs_v_mps,
308        min_gain: if min_gain.is_finite() { min_gain } else { 0.0 },
309        min_delivered_ratio: if min_delivered_ratio.is_finite() {
310            min_delivered_ratio
311        } else {
312            1.0
313        },
314        energy_depleted_j,
315        energy_depleted_gj,
316        recharge_fraction_of_full_reserve,
317        ideal_refill_time_s,
318        recharge_readiness_fraction,
319        successful_burst_fraction,
320        degraded_state_fraction,
321        percent_time_thermal_limited,
322        authority_loss_at_thermal_breach,
323        event_count: events.len(),
324        energy_breach,
325        thermal_breach,
326        local_buffer_breach,
327        saturation_breach,
328        first_admissible_breach_s,
329        admissible_breach_count,
330        ep_clamped_count,
331        t_clamped_count,
332        y_clamped_count,
333        ydot_clamped_count,
334        percent_time_outside_admissible_region,
335        first_energy_breach_s,
336        first_thermal_breach_s,
337        first_local_buffer_breach_s,
338        first_saturation_breach_s,
339        stability_target_proxy: "command-scaled reduced maneuver response proxy".to_string(),
340        v_initial: time_series
341            .first()
342            .map(|record| record.lyapunov_v)
343            .unwrap_or(0.0),
344        v_final: time_series
345            .last()
346            .map(|record| record.lyapunov_v)
347            .unwrap_or(0.0),
348        v_max: time_series
349            .iter()
350            .map(|record| record.lyapunov_v)
351            .fold(0.0, f64::max),
352        d_v_positive_fraction: ratio(
353            time_series
354                .iter()
355                .filter(|record| record.lyapunov_dv_dt > 0.0)
356                .count(),
357            time_series.len(),
358        ),
359        local_stability_margin: stability_margin(time_series),
360        first_positive_d_v_time_s: time_series
361            .iter()
362            .find(|record| record.lyapunov_dv_dt > 0.0)
363            .map(|record| record.time_s),
364        time_to_any_threshold_s,
365    }
366}
367
368pub fn derived_metrics(summary: &RunSummary) -> Vec<DerivedMetricRecord> {
369    let mut metrics = vec![
370        metric("min_ep_j", summary.min_ep_j, "J"),
371        metric("max_ep_j", summary.max_ep_j, "J"),
372        metric("final_ep_j", summary.final_ep_j, "J"),
373        metric("peak_temperature_k", summary.peak_temperature_k, "K"),
374        metric(
375            "time_below_energy_threshold_s",
376            summary.time_below_energy_threshold_s,
377            "s",
378        ),
379        metric(
380            "time_above_thermal_threshold_s",
381            summary.time_above_thermal_threshold_s,
382            "s",
383        ),
384        metric("max_actuator_demand_w", summary.max_actuator_demand_w, "W"),
385        metric(
386            "max_delivered_actuator_power_w",
387            summary.max_delivered_actuator_power_w,
388            "W",
389        ),
390        metric("saturation_count", summary.saturation_count as f64, "count"),
391        metric(
392            "delivered_mechanical_work_j",
393            summary.delivered_mechanical_work_j,
394            "J",
395        ),
396        metric(
397            "mean_delivered_ratio",
398            summary.mean_delivered_ratio,
399            "fraction",
400        ),
401        metric(
402            "mean_authority_utilization",
403            summary.mean_authority_utilization,
404            "fraction",
405        ),
406        metric(
407            "reduced_response_efficiency",
408            summary.reduced_response_efficiency,
409            "fraction",
410        ),
411        metric(
412            "effective_duty_cycle",
413            summary.effective_duty_cycle,
414            "fraction",
415        ),
416        metric("local_imbalance_max_j", summary.local_imbalance_max_j, "J"),
417        metric("local_imbalance_rms_j", summary.local_imbalance_rms_j, "J"),
418        metric("min_local_buffer_j", summary.min_local_buffer_j, "J"),
419        metric("max_abs_y_m", summary.max_abs_y_m, "m"),
420        metric("max_abs_v_mps", summary.max_abs_v_mps, "m/s"),
421        metric("min_gain", summary.min_gain, "N"),
422        metric(
423            "min_delivered_ratio",
424            summary.min_delivered_ratio,
425            "fraction",
426        ),
427        metric("energy_depleted_j", summary.energy_depleted_j, "J"),
428        metric("energy_depleted_gj", summary.energy_depleted_gj, "GJ"),
429        metric(
430            "recharge_fraction_of_full_reserve",
431            summary.recharge_fraction_of_full_reserve,
432            "fraction",
433        ),
434        metric(
435            "recharge_readiness_fraction",
436            summary.recharge_readiness_fraction,
437            "fraction",
438        ),
439        metric(
440            "successful_burst_fraction",
441            summary.successful_burst_fraction,
442            "fraction",
443        ),
444        metric(
445            "degraded_state_fraction",
446            summary.degraded_state_fraction,
447            "fraction",
448        ),
449        metric(
450            "percent_time_thermal_limited",
451            summary.percent_time_thermal_limited,
452            "percent",
453        ),
454        metric(
455            "admissible_breach_count",
456            summary.admissible_breach_count as f64,
457            "count",
458        ),
459        metric("ep_clamped_count", summary.ep_clamped_count as f64, "count"),
460        metric("t_clamped_count", summary.t_clamped_count as f64, "count"),
461        metric("y_clamped_count", summary.y_clamped_count as f64, "count"),
462        metric(
463            "ydot_clamped_count",
464            summary.ydot_clamped_count as f64,
465            "count",
466        ),
467        metric(
468            "percent_time_outside_admissible_region",
469            summary.percent_time_outside_admissible_region,
470            "percent",
471        ),
472        metric("v_initial", summary.v_initial, "J"),
473        metric("v_final", summary.v_final, "J"),
474        metric("v_max", summary.v_max, "J"),
475        metric(
476            "d_v_positive_fraction",
477            summary.d_v_positive_fraction,
478            "fraction",
479        ),
480        metric(
481            "local_stability_margin",
482            summary.local_stability_margin,
483            "1/s",
484        ),
485    ];
486    if let Some(value) = summary.recharge_time_s {
487        metrics.push(metric("recharge_time_s", value, "s"));
488    }
489    if let Some(value) = summary.ideal_refill_time_s {
490        metrics.push(metric("ideal_refill_time_s", value, "s"));
491    }
492    if let Some(value) = summary.authority_loss_at_thermal_breach {
493        metrics.push(metric(
494            "authority_loss_at_thermal_breach",
495            value,
496            "fraction",
497        ));
498    }
499    if let Some(value) = summary.time_to_any_threshold_s {
500        metrics.push(metric("time_to_any_threshold_s", value, "s"));
501    }
502    if let Some(value) = summary.first_admissible_breach_s {
503        metrics.push(metric("first_admissible_breach_s", value, "s"));
504    }
505    if let Some(value) = summary.first_positive_d_v_time_s {
506        metrics.push(metric("first_positive_d_v_time_s", value, "s"));
507    }
508    metrics
509}
510
511fn metric(name: &str, value: f64, unit: &str) -> DerivedMetricRecord {
512    DerivedMetricRecord {
513        metric: name.to_string(),
514        value,
515        unit: unit.to_string(),
516    }
517}
518
519fn local_imbalance(limb_buffers: &[LimbBufferRecord]) -> (f64, f64, f64) {
520    let mut spreads = Vec::new();
521    let min_local_buffer_j = limb_buffers
522        .iter()
523        .map(|record| record.buffer_energy_j)
524        .fold(f64::INFINITY, f64::min);
525    for chunk in limb_buffers.chunks_exact(4) {
526        let min_value = chunk
527            .iter()
528            .map(|record| record.buffer_energy_j)
529            .fold(f64::INFINITY, f64::min);
530        let max_value = chunk
531            .iter()
532            .map(|record| record.buffer_energy_j)
533            .fold(f64::NEG_INFINITY, f64::max);
534        spreads.push(max_value - min_value);
535    }
536    if spreads.is_empty() {
537        return (0.0, 0.0, 0.0);
538    }
539    let max_spread = spreads.iter().copied().fold(0.0, f64::max);
540    let rms =
541        (spreads.iter().map(|spread| spread * spread).sum::<f64>() / spreads.len() as f64).sqrt();
542    (
543        max_spread,
544        rms,
545        if min_local_buffer_j.is_finite() {
546            min_local_buffer_j
547        } else {
548            0.0
549        },
550    )
551}
552
553fn recharge_time(time_series: &[TimeSeriesRecord], pulse_energy_max_j: f64) -> Option<f64> {
554    let mut min_index = None;
555    let mut min_ep = f64::INFINITY;
556    for (index, record) in time_series.iter().enumerate() {
557        if record.ep_j < min_ep {
558            min_ep = record.ep_j;
559            min_index = Some(index);
560        }
561    }
562    let min_index = min_index?;
563    let target = pulse_energy_max_j * 0.95;
564    let min_time = time_series[min_index].time_s;
565    time_series[min_index..]
566        .iter()
567        .find(|record| record.ep_j >= target)
568        .map(|record| record.time_s - min_time)
569}
570
571fn recharge_readiness_fraction(
572    time_series: &[TimeSeriesRecord],
573    limb_buffers: &[LimbBufferRecord],
574    pulse_energy_max_j: f64,
575    local_buffer_energy_max_j: f64,
576) -> f64 {
577    if time_series.is_empty() {
578        return 0.0;
579    }
580    let ep_ready_threshold = pulse_energy_max_j * 0.90;
581    let local_ready_threshold = local_buffer_energy_max_j * 0.85;
582    let mut ready_count = 0usize;
583    for (record, chunk) in time_series.iter().zip(limb_buffers.chunks_exact(4)) {
584        let local_ready = chunk
585            .iter()
586            .all(|limb| limb.buffer_energy_j >= local_ready_threshold);
587        if record.ep_j >= ep_ready_threshold && local_ready {
588            ready_count += 1;
589        }
590    }
591    ratio(ready_count, time_series.len())
592}
593
594fn successful_burst_fraction(config: &SimulationConfig, time_series: &[TimeSeriesRecord]) -> f64 {
595    let burst_segments: Vec<_> = config
596        .scenario
597        .segments
598        .iter()
599        .filter(|segment| segment.label.contains("burst") || segment.demand_fraction >= 0.75)
600        .collect();
601    if burst_segments.is_empty() {
602        return 0.0;
603    }
604    let successful = burst_segments
605        .iter()
606        .filter(|segment| {
607            let samples: Vec<_> = time_series
608                .iter()
609                .filter(|record| record.time_s >= segment.start_s && record.time_s <= segment.end_s)
610                .collect();
611            !samples.is_empty()
612                && samples.iter().all(|record| {
613                    record.delivered_ratio >= 0.90
614                        && !record.outside_admissible_region
615                        && record.temperature_k < config.model.thermal_limit_k
616                        && record.ep_j >= config.model.pulse_energy_min_j
617                })
618        })
619        .count();
620    ratio(successful, burst_segments.len())
621}
622
623fn degraded_state_fraction(config: &SimulationConfig, time_series: &[TimeSeriesRecord]) -> f64 {
624    ratio(
625        time_series
626            .iter()
627            .filter(|record| {
628                record.delivered_ratio < 0.90
629                    || record.authority_utilization < 0.70
630                    || record.temperature_k >= config.model.thermal_soft_limit_k
631                    || record.ep_j < config.model.low_energy_threshold_j
632            })
633            .count(),
634        time_series.len(),
635    )
636}
637
638fn stability_margin(time_series: &[TimeSeriesRecord]) -> f64 {
639    if time_series.is_empty() {
640        return 0.0;
641    }
642    let mean_dv_dt = mean(time_series.iter().map(|record| record.lyapunov_dv_dt));
643    let v_max = time_series
644        .iter()
645        .map(|record| record.lyapunov_v)
646        .fold(0.0, f64::max);
647    -mean_dv_dt / v_max.max(1.0)
648}
649
650fn mean(values: impl Iterator<Item = f64>) -> f64 {
651    let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
652        (sum + value, count + 1)
653    });
654    if count == 0 { 0.0 } else { sum / count as f64 }
655}
656
657fn ratio(numerator: usize, denominator: usize) -> f64 {
658    if denominator == 0 {
659        0.0
660    } else {
661        numerator as f64 / denominator as f64
662    }
663}
664
665fn percent(numerator: usize, denominator: usize) -> f64 {
666    ratio(numerator, denominator) * 100.0
667}