1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use clap::ValueEnum;
5use serde::{Deserialize, Serialize};
6
7use crate::model::ModelParameters;
8
9pub const DEFAULT_OUTPUT_ROOT: &str = "output-mech-sim";
10pub const LIMB_COUNT: usize = 4;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
13#[serde(rename_all = "kebab-case")]
14pub enum ScenarioPreset {
15 Burst,
16 Recharge,
17 DutyCycle,
18 Hover,
19 Stress,
20 ConstraintViolation,
21}
22
23impl ScenarioPreset {
24 pub fn as_str(self) -> &'static str {
25 match self {
26 Self::Burst => "burst",
27 Self::Recharge => "recharge",
28 Self::DutyCycle => "duty-cycle",
29 Self::Hover => "hover",
30 Self::Stress => "stress",
31 Self::ConstraintViolation => "constraint-violation",
32 }
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
37#[serde(rename_all = "kebab-case")]
38pub enum SweepPreset {
39 Baseline,
40 ThermalDutyMatrix,
41 LimbAllocationComparison,
42}
43
44impl SweepPreset {
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Baseline => "baseline",
48 Self::ThermalDutyMatrix => "thermal-duty-matrix",
49 Self::LimbAllocationComparison => "limb-allocation-comparison",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
55#[serde(rename_all = "kebab-case")]
56pub enum AllocationStrategy {
57 Equal,
58 FrontBiased,
59 RearBiased,
60 DiagonalBias,
61}
62
63impl AllocationStrategy {
64 pub fn normalized_weights(self) -> [f64; LIMB_COUNT] {
65 let raw = match self {
66 Self::Equal => [1.0, 1.0, 1.0, 1.0],
67 Self::FrontBiased => [1.35, 1.35, 0.65, 0.65],
68 Self::RearBiased => [0.65, 0.65, 1.35, 1.35],
69 Self::DiagonalBias => [1.30, 0.70, 0.70, 1.30],
70 };
71 let total = raw.iter().sum::<f64>();
72 raw.map(|value| value / total)
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum IntegratorKind {
79 SemiImplicitEuler,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SolverConfig {
84 pub dt_s: f64,
85 pub duration_s: f64,
86 pub integrator: IntegratorKind,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ScenarioSegment {
91 pub label: String,
92 pub start_s: f64,
93 pub end_s: f64,
94 pub demand_fraction: f64,
95 pub disturbance_n: f64,
96 pub allocation_strategy: Option<AllocationStrategy>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ScenarioProfile {
101 pub preset: ScenarioPreset,
102 pub name: String,
103 pub description: String,
104 pub idle_command: f64,
105 pub baseline_allocation: AllocationStrategy,
106 pub seeded_command_wobble: f64,
107 pub seeded_disturbance_n: f64,
108 pub segments: Vec<ScenarioSegment>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct SimulationConfig {
113 pub name: String,
114 pub description: String,
115 pub seed: u64,
116 pub solver: SolverConfig,
117 pub model: ModelParameters,
118 pub scenario: ScenarioProfile,
119}
120
121#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122pub struct ScenarioOverrides {
123 pub continuous_power_mw: Option<f64>,
124 pub pulse_energy_gj: Option<f64>,
125 pub initial_ep_gj: Option<f64>,
126 pub duration_s: Option<f64>,
127 pub dt_s: Option<f64>,
128 pub thermal_rejection_mw_per_k: Option<f64>,
129 pub burst_power_mw: Option<f64>,
130 pub burst_duration_s: Option<f64>,
131 pub actuator_demand_scale: Option<f64>,
132 pub allocation_strategy: Option<AllocationStrategy>,
133 pub local_buffer_energy_mj: Option<f64>,
134 pub damping_scale: Option<f64>,
135 pub stiffness_scale: Option<f64>,
136 pub seeded_command_wobble: Option<f64>,
137 pub seeded_disturbance_n: Option<f64>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct SweepCaseMetadata {
142 pub case_id: String,
143 pub group: String,
144 pub note: String,
145 pub continuous_power_mw: f64,
146 pub burst_power_mw: f64,
147 pub burst_duration_s: f64,
148 pub pulse_energy_gj: f64,
149 pub initial_ep_gj: f64,
150 pub thermal_rejection_mw_per_k: f64,
151 pub actuator_demand_scale: f64,
152 pub damping_scale: f64,
153 pub stiffness_scale: f64,
154 pub burst_cadence_s: Option<f64>,
155 pub allocation_strategy: Option<AllocationStrategy>,
156}
157
158#[derive(Debug, Clone)]
159pub struct SweepCase {
160 pub metadata: SweepCaseMetadata,
161 pub config: SimulationConfig,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct OutputLayout {
166 pub output_root: PathBuf,
167}
168
169impl Default for OutputLayout {
170 fn default() -> Self {
171 Self {
172 output_root: PathBuf::from(DEFAULT_OUTPUT_ROOT),
173 }
174 }
175}
176
177impl OutputLayout {
178 pub fn output_root(&self) -> &Path {
179 &self.output_root
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(tag = "mode", rename_all = "kebab-case")]
185pub enum RunConfig {
186 Scenario {
187 preset: ScenarioPreset,
188 seed: Option<u64>,
189 output_root: Option<PathBuf>,
190 overrides: Option<ScenarioOverrides>,
191 },
192 Sweep {
193 preset: SweepPreset,
194 seed: Option<u64>,
195 output_root: Option<PathBuf>,
196 overrides: Option<ScenarioOverrides>,
197 },
198}
199
200#[derive(Debug, Clone)]
201pub enum ResolvedRunConfig {
202 Scenario {
203 config: SimulationConfig,
204 output_layout: OutputLayout,
205 },
206 Sweep {
207 preset: SweepPreset,
208 cases: Vec<SweepCase>,
209 output_layout: OutputLayout,
210 },
211}
212
213impl ResolvedRunConfig {
214 pub fn from_run_config(run_config: RunConfig, base_dir: Option<&Path>) -> Result<Self> {
215 match run_config {
216 RunConfig::Scenario {
217 preset,
218 seed,
219 output_root,
220 overrides,
221 } => {
222 let seed = seed.unwrap_or(1);
223 let config = crate::scenarios::build_scenario_config(
224 preset,
225 overrides.unwrap_or_default(),
226 seed,
227 )?;
228 Ok(Self::Scenario {
229 config,
230 output_layout: OutputLayout {
231 output_root: resolve_output_root(base_dir, output_root),
232 },
233 })
234 }
235 RunConfig::Sweep {
236 preset,
237 seed,
238 output_root,
239 overrides,
240 } => {
241 let seed = seed.unwrap_or(1);
242 let cases = crate::scenarios::build_sweep_cases(
243 preset,
244 overrides.unwrap_or_default(),
245 seed,
246 )?;
247 Ok(Self::Sweep {
248 preset,
249 cases,
250 output_layout: OutputLayout {
251 output_root: resolve_output_root(base_dir, output_root),
252 },
253 })
254 }
255 }
256 }
257}
258
259fn resolve_output_root(_base_dir: Option<&Path>, output_root: Option<PathBuf>) -> PathBuf {
260 match output_root {
261 Some(path) => path,
262 None => PathBuf::from(DEFAULT_OUTPUT_ROOT),
263 }
264}
265
266impl SimulationConfig {
267 pub fn validate(&self) -> Result<()> {
268 if self.solver.dt_s <= 0.0 {
269 anyhow::bail!("solver dt_s must be positive");
270 }
271 if self.solver.duration_s <= 0.0 {
272 anyhow::bail!("scenario duration must be positive");
273 }
274 if self.model.pulse_energy_max_j <= 0.0 {
275 anyhow::bail!("pulse_energy_max_j must be positive");
276 }
277 if self.model.local_buffer_count != LIMB_COUNT {
278 anyhow::bail!("local_buffer_count must equal {LIMB_COUNT}");
279 }
280 if self
281 .scenario
282 .segments
283 .iter()
284 .any(|segment| segment.end_s < segment.start_s)
285 {
286 anyhow::bail!("scenario segment end time must be >= start time");
287 }
288 Ok(())
289 }
290
291 pub fn apply_overrides(&mut self, overrides: &ScenarioOverrides) -> Result<()> {
292 if let Some(value) = overrides.continuous_power_mw {
293 self.model.continuous_power_w = mw_to_w(value);
294 }
295 if let Some(value) = overrides.pulse_energy_gj {
296 let pulse_max_j = gj_to_j(value);
297 let ratio = if self.model.pulse_energy_max_j > 0.0 {
298 self.model.pulse_energy_min_j / self.model.pulse_energy_max_j
299 } else {
300 0.05
301 };
302 self.model.pulse_energy_max_j = pulse_max_j;
303 self.model.pulse_energy_min_j = pulse_max_j * ratio;
304 self.model.low_energy_threshold_j = pulse_max_j * 0.15;
305 self.model.pulse_energy_initial_j = self
306 .model
307 .pulse_energy_initial_j
308 .min(self.model.pulse_energy_max_j);
309 }
310 if let Some(value) = overrides.initial_ep_gj {
311 self.model.pulse_energy_initial_j = gj_to_j(value);
312 }
313 if let Some(value) = overrides.duration_s {
314 self.solver.duration_s = value;
315 }
316 if let Some(value) = overrides.dt_s {
317 self.solver.dt_s = value;
318 }
319 if let Some(value) = overrides.thermal_rejection_mw_per_k {
320 self.model.thermal_rejection_w_per_k = mw_to_w(value);
321 }
322 if let Some(value) = overrides.burst_power_mw {
323 self.model.actuator_peak_power_w = mw_to_w(value);
324 }
325 if let Some(value) = overrides.burst_duration_s {
326 for segment in &mut self.scenario.segments {
327 if segment.label.contains("burst") {
328 segment.end_s = segment.start_s + value;
329 }
330 }
331 }
332 if let Some(value) = overrides.actuator_demand_scale {
333 self.model.actuator_demand_scale = value;
334 }
335 if let Some(value) = overrides.allocation_strategy {
336 self.scenario.baseline_allocation = value;
337 }
338 if let Some(value) = overrides.local_buffer_energy_mj {
339 let energy_j = value * 1.0e6;
340 self.model.local_buffer_energy_max_j = energy_j;
341 self.model.local_buffer_initial_j = energy_j;
342 self.model.local_buffer_low_threshold_j = energy_j * 0.20;
343 }
344 if let Some(value) = overrides.damping_scale {
345 self.model.damping_scale = value;
346 }
347 if let Some(value) = overrides.stiffness_scale {
348 self.model.stiffness_scale = value;
349 }
350 if let Some(value) = overrides.seeded_command_wobble {
351 self.scenario.seeded_command_wobble = value;
352 }
353 if let Some(value) = overrides.seeded_disturbance_n {
354 self.scenario.seeded_disturbance_n = value;
355 }
356 self.model.pulse_energy_initial_j = self
357 .model
358 .pulse_energy_initial_j
359 .clamp(0.0, self.model.pulse_energy_max_j);
360 self.validate()
361 .context("post-override config validation failed")
362 }
363}
364
365pub fn mw_to_w(value_mw: f64) -> f64 {
366 value_mw * 1.0e6
367}
368
369pub fn gj_to_j(value_gj: f64) -> f64 {
370 value_gj * 1.0e9
371}
372
373pub fn w_to_mw(value_w: f64) -> f64 {
374 value_w / 1.0e6
375}
376
377pub fn j_to_gj(value_j: f64) -> f64 {
378 value_j / 1.0e9
379}