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}