Skip to main content

mech_sim/
config.rs

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}