dynamecs_app/
lib.rs

1//! Opinionated framework for building simulation apps with `dynamecs`.
2use checkpointing::{compressed_binary_checkpointing_system, restore_checkpoint_file};
3use clap::Parser;
4use cli::CliOptions;
5use dynamecs::components::{
6    get_simulation_time, get_step_index, register_default_components, DynamecsAppSettings, SimulationTime, StepIndex,
7    TimeStep,
8};
9use dynamecs::storages::{ImmutableSingularStorage, SingularStorage};
10use dynamecs::{register_component, Component, System, Systems, Universe};
11use eyre::{eyre, Context};
12use serde::{Deserialize, Serialize};
13use std::fs::read_to_string;
14use std::path::{Path, PathBuf};
15use tracing::{debug, info, info_span, instrument, warn};
16
17pub extern crate eyre;
18pub extern crate serde;
19pub extern crate tracing;
20
21mod checkpointing;
22mod cli;
23mod config_override;
24mod tracing_impl;
25
26pub use tracing_impl::register_signal_handler;
27pub use tracing_impl::setup_tracing;
28
29#[derive(Debug)]
30pub struct Scenario {
31    name: String,
32    pub duration: Option<f64>,
33    pub state: Universe,
34    pub pre_systems: Systems,
35    pub simulation_systems: Systems,
36    pub post_systems: Systems,
37}
38
39impl Scenario {
40    pub fn default_with_name(name: impl Into<String>) -> Self {
41        Self {
42            name: name.into(),
43            duration: None,
44            state: Default::default(),
45            pre_systems: Default::default(),
46            simulation_systems: Default::default(),
47            post_systems: Default::default(),
48        }
49    }
50
51    pub fn name(&self) -> &str {
52        &self.name
53    }
54}
55
56pub struct DynamecsApp<Config = ()> {
57    config: Config,
58    scenario: Option<Scenario>,
59    /// Optionally override the time step dt (otherwise use scenario-provided or default)
60    dt_override: Option<f64>,
61    max_steps: Option<usize>,
62    /// Optionally restore the simulation state from the given checkpoint file
63    restore_from_checkpoint: Option<PathBuf>,
64    /// Optional system for writing checkpoints
65    checkpoint_system: Option<Box<dyn System>>,
66}
67
68impl<Config> DynamecsApp<Config> {
69    pub fn from_config_and_app_settings(config: Config) -> Self {
70        Self {
71            config,
72            scenario: None,
73            dt_override: None,
74            max_steps: None,
75            restore_from_checkpoint: None,
76            checkpoint_system: None,
77        }
78    }
79
80    pub fn with_scenario_initializer<I>(mut self, initializer: I) -> eyre::Result<Self>
81    where
82        I: FnOnce(&Config) -> eyre::Result<Scenario>,
83    {
84        let mut scenario = initializer(&self.config)?;
85
86        let scenario_name = scenario.name().to_string();
87        let app_settings = DynamecsAppSettings {
88            scenario_output_dir: get_output_dir().join(&scenario_name),
89            scenario_name,
90        };
91
92        scenario
93            .state
94            .insert_storage(ImmutableSingularStorage::new(app_settings));
95
96        if let Some(dt) = self.dt_override {
97            info!("Overriding time step dt = {}", dt);
98            scenario
99                .state
100                .insert_storage(SingularStorage::new(TimeStep(dt)));
101        }
102
103        self.scenario = Some(scenario);
104        Ok(self)
105    }
106
107    /// Enables or disables writing checkpoints for the app.
108    pub fn write_checkpoints(mut self, enable_write_checkpoints: bool) -> Self {
109        self.checkpoint_system = enable_write_checkpoints.then(|| compressed_binary_checkpointing_system().into());
110        self
111    }
112
113    /// Restores a checkpoint from the given file when the app is run.
114    pub fn restore_checkpoint<P: Into<PathBuf>>(mut self, checkpoint_path: P) -> Self {
115        self.restore_from_checkpoint = Some(checkpoint_path.into());
116        self
117    }
118
119    #[instrument(level = "info", skip_all)]
120    pub fn run(mut self) -> eyre::Result<()> {
121        if let Some(scenario) = &mut self.scenario {
122            // Register components of all systems
123            register_default_components();
124            register_component::<DynamecsAppSettings>();
125            scenario.pre_systems.register_components();
126            scenario.simulation_systems.register_components();
127            scenario.post_systems.register_components();
128
129            if let Some(checkpoint_path) = &self.restore_from_checkpoint {
130                let universe = restore_checkpoint_file(checkpoint_path)?;
131                scenario.state = universe;
132
133                let step_index = get_step_index(&scenario.state).0;
134                info!(
135                    "Restored simulation state with step index {} from file \"{}\"",
136                    step_index,
137                    checkpoint_path.display()
138                );
139            }
140
141            info!("Starting simulation of scenario \"{}\"", scenario.name());
142            loop {
143                let state = &mut scenario.state;
144                let SimulationTime(mut sim_time) = get_simulation_time(&*state);
145                let StepIndex(step_index) = get_step_index(&*state);
146                let TimeStep(dt) = get_time_step_or_set_default(state);
147
148                if let Some(max_steps) = self.max_steps {
149                    if step_index > max_steps {
150                        break;
151                    }
152                } else if let Some(duration) = scenario.duration {
153                    if sim_time >= duration {
154                        break;
155                    }
156                }
157
158                // Note: We enter the step span *after* checking if we should abort the loop,
159                // so that we don't get an additional step span in the logs
160                let _span = info_span!("step", step_index).entered();
161
162                if step_index == 0 {
163                    // Post systems must run on the initial state in order to do post-initialization
164                    // For example, a system that outputs data after every simulation step should
165                    // also output the initial state
166                    debug!("Running post-systems for initial state");
167                    {
168                        let _span = info_span!("post_systems").entered();
169                        scenario.post_systems.run_all(state)?;
170                    }
171                }
172
173                // TODO: Use some more better formatting here...
174                info!(
175                    "Starting step {} at simulation time {:3.5} (dt = {:3.5e})",
176                    step_index, sim_time, dt
177                );
178                {
179                    let _span = info_span!("pre_systems").entered();
180                    scenario.pre_systems.run_all(state)?;
181                }
182                {
183                    let _span = info_span!("simulation_systems").entered();
184                    scenario.simulation_systems.run_all(state)?;
185                }
186
187                sim_time += dt;
188                set_singular_component(state, SimulationTime(sim_time));
189                set_singular_component(state, StepIndex(step_index + 1));
190
191                {
192                    let _span = info_span!("post_systems").entered();
193                    scenario.post_systems.run_all(state)?;
194                }
195
196                if let Some(checkpoint_system) = &mut self.checkpoint_system {
197                    checkpoint_system
198                        .run(state)
199                        .wrap_err("failed to run checkpointing system")?;
200                }
201            }
202
203            info!("Simulation ended");
204            Ok(())
205        } else {
206            Err(eyre!("cannot run scenario: no scenario initializer provided",))
207        }
208    }
209}
210
211fn set_singular_component<C>(state: &mut Universe, component: C)
212where
213    C: Serialize + for<'de> Deserialize<'de>,
214    C: Component<Storage = SingularStorage<C>>,
215{
216    state.insert_storage(SingularStorage::new(component));
217}
218
219fn get_time_step_or_set_default(state: &mut Universe) -> TimeStep {
220    if let Some(storage) = state.try_get_component_storage::<TimeStep>() {
221        storage.get_component().clone()
222    } else {
223        let default_dt = state.get_component_storage::<TimeStep>().get_component();
224        info!("No time step configured. Using default dt = {}", default_dt.0);
225        default_dt.clone()
226    }
227}
228
229impl DynamecsApp<()> {
230    pub fn configure_from_cli<Config>() -> eyre::Result<DynamecsApp<Config>>
231    where
232        Config: Serialize,
233        for<'de> Config: Deserialize<'de>,
234    {
235        let opt = CliOptions::parse();
236
237        info!("Output base path: {}", opt.output_dir.display());
238
239        if opt.config_file.is_some() && opt.config_string.is_some() {
240            return Err(eyre!("config file and config string are mutually exclusive"));
241        }
242
243        let initial_config: Config = if let Some(path) = opt.config_file {
244            info!("Reading config file from {}.", path.display());
245            let config_str =
246                read_to_string(&path).wrap_err_with(|| format!("failed to read config file at {}", path.display()))?;
247            json5::from_str(&config_str).wrap_err("failed to deserialize supplied JSON5 configuration file")
248        } else if let Some(config_str) = opt.config_string {
249            info!("Using configuration provided from CLI interface");
250            json5::from_str(&config_str).wrap_err("failed to deserialize supplied JSON5 configuration string")
251        } else {
252            let default_config_str = "{}";
253            info!(
254                r#"No configuration specified. Trying to use the empty document {} as default."#,
255                default_config_str
256            );
257            Ok(json5::from_str("{}").wrap_err(
258                "failed to deserialize configuration from empty document {}. \
259            You need to either provide all required configuration parameters, \
260            or make sure that your configuration can be deserialized from an empty document,",
261            )?)
262        }?;
263
264        let mut config_json =
265            serde_json::to_value(initial_config).wrap_err("failed to serialize initial config as JSON")?;
266
267        if !opt.overrides.is_empty() {
268            let overridden_config: serde_json::Value =
269                config_override::apply_config_overrides(config_json, &opt.overrides)?;
270            config_json = serde_json::from_value(overridden_config).wrap_err_with(|| {
271                "invalid config overrides: cannot deserialize configuration from \
272                overridden configuration"
273            })?;
274        }
275
276        // Emit warnings whenever we run into JSON fields that are not part of the
277        // configuration
278        let mut unknown_fields = false;
279        let config: Config = serde_ignored::deserialize(&config_json, |path| {
280            warn!(
281                "Ignored unknown field {} during deserialization of configuration",
282                path.to_string()
283            );
284            unknown_fields = true;
285        })
286        .wrap_err_with(|| {
287            let json_str = serde_json::to_string_pretty(&config_json)
288                .unwrap_or_else(|err| format!("<failed to serialize to JSON: {err}>"));
289            format!(
290                "failed to deserialize the following JSON configuration \
291                into a valid configuration: \n{json_str}"
292            )
293        })?;
294
295        if unknown_fields && !opt.allow_unknown_config {
296            return Err(eyre!("There were unknown fields in the configuration. Please fix provided config or see --help for how to ignore unknown fields."));
297        }
298
299        // TODO: We use serde_json because json5 cannot pretty-print JSON, and unfortunately
300        // its serializer is limited to producing JSON
301        let config_json_str = serde_json::to_string_pretty(&config)?;
302        info!("Using configuration: \n{}", config_json_str);
303
304        if let Some(dt) = opt.dt {
305            if dt <= 0.0 {
306                return Err(eyre!("time step dt must be positive"));
307            }
308        }
309
310        let checkpoint_system = opt
311            .write_checkpoints
312            .then(|| compressed_binary_checkpointing_system().into());
313
314        Ok(DynamecsApp {
315            config,
316            scenario: None,
317            dt_override: opt.dt,
318            max_steps: opt.max_steps,
319            restore_from_checkpoint: opt.restore_checkpoint,
320            checkpoint_system,
321        })
322    }
323}
324
325/// Returns the intended root directory for app output.
326///
327/// The returned path is relative to the current working directory.
328pub fn get_output_dir() -> PathBuf {
329    let cli_args = CliOptions::parse();
330    cli_args.output_dir
331}
332
333/// Returns the *default* intended root directory for app output.
334///
335/// The returned path is relative to the current working directory.
336///
337/// This is the default path used when not overriden through the command-line interface.
338/// Users would probably usually want to use [`get_output_dir`] instead.
339pub fn get_default_output_dir() -> &'static Path {
340    Path::new("output")
341}
342
343/// Convenience macro for generating an appropriate main function for use with `dynamecs-app`.
344///
345/// The macro sets up logging through the `tracing` integration, sets up a signal handler
346/// to ensure clean log termination, configures a `DynamecsApp`
347/// based on CLI arguments and runs the scenario defined by the given scenario initializer.
348///
349/// For example, consider the following program.
350/// ```no_run
351/// use serde::{Deserialize, Serialize};
352/// use dynamecs_app::{dynamecs_main, Scenario};
353///
354/// #[derive(Debug, Serialize, Deserialize)]
355/// struct Config {
356///     resolution: usize
357/// }
358///
359/// fn initialize_scenario(_config: &Config) -> eyre::Result<Scenario> {
360///     todo!()
361/// }
362///
363/// dynamecs_main!(initialize_scenario);
364/// ```
365#[macro_export]
366macro_rules! dynamecs_main {
367    ($scenario:expr) => {
368        fn main() -> Result<(), Box<dyn std::error::Error>> {
369            let _tracing_guard = $crate::setup_tracing()?;
370            $crate::register_signal_handler()?;
371            fn main_internal() -> Result<(), Box<dyn std::error::Error>> {
372                $crate::DynamecsApp::configure_from_cli()?
373                    .with_scenario_initializer($scenario)?
374                    .run()?;
375                Ok(())
376            }
377
378            main_internal().map_err(|err| {
379                let msg = if let Some(source) = err.source() {
380                    format!("{err:#},\ncaused by: {}", source)
381                } else {
382                    format!("{err:#}")
383                };
384                $crate::tracing::error!("{msg}");
385                err
386            })
387        }
388    };
389}