Skip to main content

fastsim_core/vehicle/
vehicle_model.rs

1use super::{hev::HEVPowertrainControls, hev::HEVStartStopControl, *};
2use crate::{
3    prelude::*,
4    vehicle::conv::{ConvPowertrainControls, ConvStartStopControl},
5};
6
7use semver::Version;
8
9/// Possible aux load power sources
10#[derive(
11    Clone, Debug, Serialize, Deserialize, PartialEq, IsVariant, derive_more::From, TryInto,
12)]
13pub enum AuxSource {
14    /// Aux load power provided by ReversibleEnergyStorage with help from FuelConverter, if present
15    /// and needed
16    ReversibleEnergyStorage,
17    /// Aux load power provided by FuelConverter with help from ReversibleEnergyStorage, if present
18    /// and needed
19    FuelConverter,
20}
21
22impl SerdeAPI for AuxSource {}
23impl Init for AuxSource {}
24
25#[serde_api]
26#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
27#[derive(PartialEq, Clone, Debug, Serialize, Deserialize, StateMethods)]
28#[non_exhaustive]
29#[serde(deny_unknown_fields)]
30/// Struct for simulating vehicle
31pub struct Vehicle {
32    /// Vehicle name
33    pub name: String,
34    /// Minimum FASTSim version required
35    #[serde(default = "crate::current_fastsim_version")]
36    pub min_fastsim_version: Version,
37    /// Where in the database this vehicle lives, if applicable
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub db_path: Option<database::VehicleSchema>,
40    /// Documentation (e.g. how this file was generated, calibration details)
41    pub doc: Option<String>,
42    /// Vehicle year (e.g. 2020, 2025)
43    pub year: Option<String>,
44    /// Vehicle make (e.g. Toyota, Ford)
45    pub make: Option<String>,
46    /// Vehicle model (e.g. Camry, F-150)
47    pub model: Option<String>,
48    /// Vehicle trim (e.g. SE, XLE 4cyl)
49    pub trim: Option<String>,
50
51    #[has_state]
52    /// type of vehicle powertrain including contained type-specific parameters and variables
53    pub pt_type: PowertrainType,
54
55    /// Chassis model with various chassis-related parameters
56    pub chassis: Chassis,
57
58    /// Cabin thermal model
59    #[has_state]
60    #[serde(default)]
61    pub cabin: CabinOption,
62
63    /// HVAC model
64    #[has_state]
65    #[serde(default)]
66    pub hvac: HVACOption,
67
68    /// Total vehicle mass
69    pub(crate) mass: Option<si::Mass>,
70
71    /// Baseline power required by auxilliary systems
72    pub pwr_aux_base: si::Power,
73
74    /// time step interval at which `state` is saved into `history`
75    pub(crate) save_interval: Option<usize>,
76    /// current state of vehicle
77    #[serde(default)]
78    pub state: VehicleState,
79    /// Vector-like history of [Self::state]
80    #[serde(default, skip_serializing_if = "VehicleStateHistoryVec::is_empty")]
81    pub history: VehicleStateHistoryVec,
82}
83
84#[pyo3_api]
85impl Vehicle {
86    #[pyo3(name = "set_save_interval")]
87    #[pyo3(signature = (save_interval=None))]
88    /// Set save interval and cascade to nested components.
89    fn set_save_interval_py(&mut self, save_interval: Option<usize>) -> PyResult<()> {
90        self.set_save_interval(save_interval)
91            .map_err(|e| PyAttributeError::new_err(e.to_string()))
92    }
93
94    // despite having `getter` here, this seems to work as a function
95    #[getter("save_interval")]
96    /// Set save interval and cascade to nested components.
97    fn get_save_interval_py(&self) -> anyhow::Result<Option<usize>> {
98        self.save_interval()
99    }
100
101    #[getter]
102    fn get_fc(&self) -> Option<FuelConverter> {
103        self.fc().cloned()
104    }
105
106    #[getter]
107    fn get_res(&self) -> Option<ReversibleEnergyStorage> {
108        self.res().cloned()
109    }
110
111    #[getter]
112    fn get_em(&self) -> Option<ElectricMachine> {
113        self.em().cloned()
114    }
115
116    fn veh_type(&self) -> String {
117        self.pt_type.to_string()
118    }
119
120    /// Load vehicle from file saved in fastsim-2 format
121    #[cfg(feature = "compat")]
122    #[pyo3(name = "from_f2_file")]
123    #[staticmethod]
124    #[allow(deprecated)]
125    fn from_f2_file_py(py: pyo3::Python<'_>, file: PathBuf) -> anyhow::Result<Self> {
126        if let Ok(warnings) = py.import("warnings") {
127            let msg = "Vehicle.from_f2_file is deprecated; use Vehicle.from_file / from_reader / from_yaml / from_json / from_toml instead.";
128            let kwargs = pyo3::types::PyDict::new(py);
129            let _ = kwargs.set_item("stacklevel", 2);
130            if let Ok(dep_warn) = warnings.getattr("DeprecationWarning") {
131                let _ = kwargs.set_item("category", dep_warn);
132            }
133            let _ = warnings.call_method("warn", (msg,), Some(&kwargs));
134        }
135        Self::from_f2_file(file)
136    }
137
138    /// Load from schema v1 using a pre-serialized path string.
139    /// Auto-detects local vs remote based on db_path_or_url.
140    #[pyo3(name = "from_db_path_v1")]
141    #[staticmethod]
142    #[pyo3(signature = (
143        db_path_or_url,
144        path,
145        extension,
146        skip_init=false,
147    ))]
148    fn from_db_path_v1_py(
149        db_path_or_url: Option<&str>,
150        path: &str,
151        extension: &str,
152        skip_init: bool,
153    ) -> anyhow::Result<Self> {
154        Self::from_db_path_v1(db_path_or_url, path, extension, skip_init)
155    }
156
157    /// Load from schema v1 using individual fields.
158    /// Auto-detects local vs remote based on db_path_or_url.
159    #[pyo3(name = "from_db_fields_v1")]
160    #[staticmethod]
161    #[pyo3(signature = (
162        db_path_or_url,
163        fastsim_version,
164        powertrain,
165        make,
166        model,
167        year,
168        variant,
169        revision,
170        extension,
171        skip_init=false,
172    ))]
173    fn from_db_fields_v1_py(
174        db_path_or_url: Option<&str>,
175        fastsim_version: u32,
176        powertrain: &str,
177        make: &str,
178        model: &str,
179        year: &str,
180        variant: &str,
181        revision: u32,
182        extension: &str,
183        skip_init: bool,
184    ) -> anyhow::Result<Self> {
185        Self::from_db_fields_v1(
186            db_path_or_url,
187            fastsim_version,
188            powertrain,
189            make,
190            model,
191            year,
192            variant,
193            revision,
194            extension,
195            skip_init,
196        )
197    }
198
199    #[pyo3(name = "reset_py")]
200    /// Combines [Self::reset_cumulative], [Self::reset_step], [Self::clear]
201    fn reset_py(&mut self) -> anyhow::Result<()> {
202        self.reset_cumulative(|| format_dbg!())?;
203        self.reset_step(|| format_dbg!())?;
204        self.clear();
205        Ok(())
206    }
207
208    #[pyo3(name = "clear")]
209    fn clear_py(&mut self) {
210        self.clear()
211    }
212
213    #[pyo3(name = "reset_step")]
214    fn reset_step_py(&mut self) -> anyhow::Result<()> {
215        self.reset_step(|| format_dbg!())
216    }
217
218    #[pyo3(name = "reset_cumulative")]
219    fn reset_cumulative_py(&mut self) -> anyhow::Result<()> {
220        self.reset_cumulative(|| format_dbg!())
221    }
222
223    #[pyo3(name = "use_start_stop_controller")]
224    fn use_start_stop_controller_py(&mut self) -> anyhow::Result<()> {
225        self.use_start_stop_controller()
226    }
227
228    #[pyo3(name = "use_normal_controller")]
229    fn use_normal_controller_py(&mut self) -> anyhow::Result<()> {
230        self.use_normal_controller()
231    }
232
233    #[pyo3(name = "set_dfco_params")]
234    fn set_dfco_params_py(
235        &mut self,
236        enabled: bool,
237        min_dfco_speed_m_per_s: f64,
238        max_accel_for_dfco_m_per_s2: f64,
239    ) -> anyhow::Result<()> {
240        self.set_dfco_params(enabled, min_dfco_speed_m_per_s, max_accel_for_dfco_m_per_s2)
241    }
242}
243
244/// implementing constructor function for Vehicle
245impl Vehicle {
246    /// Create new Vehicle with specified parameters
247    pub fn new(
248        name: String,
249        min_fastsim_version: Option<Version>,
250        db_path: Option<database::VehicleSchema>,
251        doc: Option<String>,
252        year: Option<String>,
253        make: Option<String>,
254        model: Option<String>,
255        trim: Option<String>,
256        pt_type: PowertrainType,
257        chassis: Chassis,
258        cabin: CabinOption,
259        hvac: HVACOption,
260        mass: Option<si::Mass>,
261        pwr_aux_base: si::Power,
262        save_interval: Option<usize>,
263    ) -> anyhow::Result<Self> {
264        let mut veh = Self {
265            name,
266            min_fastsim_version: min_fastsim_version
267                .unwrap_or_else(|| crate::FASTSIM_VERSION.clone()),
268            db_path,
269            doc,
270            year,
271            make,
272            model,
273            trim,
274            pt_type,
275            chassis,
276            cabin,
277            hvac,
278            mass,
279            pwr_aux_base,
280            state: VehicleState::default(),
281            history: VehicleStateHistoryVec::default(),
282            save_interval,
283        };
284        veh.init()?;
285        Ok(veh)
286    }
287
288    pub fn use_start_stop_controller(&mut self) -> anyhow::Result<()> {
289        match &mut self.pt_type {
290            PowertrainType::ConventionalVehicle(veh) => match veh.pt_cntrl {
291                ConvPowertrainControls::Normal => {
292                    let save_interval = veh.save_interval().unwrap_or(Option::None);
293                    veh.pt_cntrl =
294                        ConvPowertrainControls::StartStop(Box::new(ConvStartStopControl::new(
295                            None, // fc_min_time_on
296                            None, // temp_fc_forced_on
297                            None, // temp_fc_allowed_off
298                            None, // time_delay_after_stop_until_fc_can_turn_off
299                            save_interval,
300                        )?));
301                }
302                ConvPowertrainControls::StartStop(_) => (),
303            },
304            PowertrainType::HybridElectricVehicle(veh) => match veh.pt_cntrl {
305                HEVPowertrainControls::RGWDB(_) => {
306                    let save_interval = veh.save_interval().unwrap_or(Option::None);
307                    veh.pt_cntrl =
308                        HEVPowertrainControls::StartStop(Box::new(HEVStartStopControl::new(
309                            None, // fc_min_time_on
310                            None, // soc_fc_forced_on
311                            None, // frac_of_most_eff_pwr_to_run_fc
312                            None, // temp_fc_forced_on
313                            None, // temp_fc_allowed_off
314                            None, // time_delay_after_stop_until_fc_can_turn_off
315                            None, // em_can_regen
316                            save_interval,
317                        )?));
318                }
319                HEVPowertrainControls::StartStop(_) => (),
320            },
321            _ => (),
322        }
323        Ok(())
324    }
325
326    pub fn use_normal_controller(&mut self) -> anyhow::Result<()> {
327        let save_interval = self.save_interval().unwrap_or(Option::None);
328        match &mut self.pt_type {
329            PowertrainType::ConventionalVehicle(conv) => match &conv.pt_cntrl {
330                ConvPowertrainControls::StartStop(_) => {
331                    conv.pt_cntrl = ConvPowertrainControls::Normal;
332                }
333                ConvPowertrainControls::Normal => (),
334            },
335            PowertrainType::HybridElectricVehicle(hev) => match &hev.pt_cntrl {
336                HEVPowertrainControls::StartStop(_) => {
337                    hev.pt_cntrl =
338                        HEVPowertrainControls::RGWDB(Box::new(RESGreedyWithDynamicBuffers::new(
339                            None, // speed_soc_disch_buffer
340                            None, // speed_soc_disch_buffer_coeff
341                            None, // speed_soc_fc_on_buffer
342                            None, // speed_soc_fc_on_buffer_coeff
343                            None, // speed_soc_regen_buffer
344                            None, // speed_soc_regen_buffer_coeff
345                            None, // fc_min_time_on
346                            None, // speed_fc_forced_on
347                            None, // frac_pwr_demand_fc_forced_on
348                            None, // frac_of_most_eff_pwr_to_run_fc
349                            None, // temp_fc_forced_on
350                            None, // temp_fc_allowed_off
351                            save_interval,
352                        )?))
353                }
354                HEVPowertrainControls::RGWDB(_) => (),
355            },
356            _ => (),
357        }
358        Ok(())
359    }
360
361    pub fn set_dfco_params(
362        &mut self,
363        enabled: bool,
364        min_dfco_speed_m_per_s: f64,
365        max_accel_for_dfco_m_per_s2: f64,
366    ) -> anyhow::Result<()> {
367        let min_dfco_speed_m_per_s = min_dfco_speed_m_per_s.max(0.0);
368        let max_accel_for_dfco_m_per_s2 = max_accel_for_dfco_m_per_s2.min(0.0);
369        match &mut self.pt_type {
370            PowertrainType::ConventionalVehicle(conv) => {
371                conv.dfco_cntrl.dfco_enabled = enabled;
372                conv.dfco_cntrl.minimum_dfco_speed = min_dfco_speed_m_per_s * uc::MPS;
373                conv.dfco_cntrl.minimum_dfco_deceleration = max_accel_for_dfco_m_per_s2 * uc::MPS2;
374                conv.dfco_cntrl.save_interval = self.save_interval;
375            }
376            _ => (),
377        }
378        Ok(())
379    }
380}
381
382impl Mass for Vehicle {
383    fn mass(&self) -> anyhow::Result<Option<si::Mass>> {
384        let derived_mass = self
385            .derived_mass()
386            .with_context(|| anyhow!(format_dbg!()))?;
387        match (derived_mass, self.mass) {
388            (Some(derived_mass), Some(set_mass)) => {
389                ensure!(
390                    utils::almost_eq_uom(&set_mass, &derived_mass, None),
391                    format!(
392                        "{}",
393                        format_dbg!(utils::almost_eq_uom(&set_mass, &derived_mass, None)),
394                    )
395                );
396                Ok(Some(set_mass))
397            }
398            (None, None) => bail!(
399                "Not all mass fields in `{}` are set and no mass was previously set.",
400                stringify!(Vehicle)
401            ),
402            _ => Ok(self.mass.or(derived_mass)),
403        }
404    }
405
406    fn set_mass(
407        &mut self,
408        new_mass: Option<si::Mass>,
409        side_effect: MassSideEffect,
410    ) -> anyhow::Result<()> {
411        ensure!(
412            side_effect == MassSideEffect::None,
413            "At the vehicle level, only `MassSideEffect::None` is allowed"
414        );
415        let derived_mass = self
416            .derived_mass()
417            .with_context(|| anyhow!(format_dbg!()))?;
418        self.mass = match (new_mass, derived_mass) {
419            // Set using provided `new_mass`, setting constituent mass fields to `None` to match if inconsistent
420            (Some(new_mass), Some(dm)) => {
421                if dm != new_mass {
422                    self.expunge_mass_fields();
423                }
424                Some(new_mass)
425            }
426            (Some(new_mass), None) => Some(new_mass),
427            (None, Some(dm)) => Some(dm),
428            (None, None) => bail!(
429                "Not all mass fields in `{}` are set and no mass was provided.",
430                stringify!(Vehicle)
431            ),
432        };
433        ensure!(
434            self.mass > Some(0.0 * uc::KG),
435            "{} mass must be positive",
436            stringify!(Vehicle)
437        );
438        Ok(())
439    }
440
441    fn derived_mass(&self) -> anyhow::Result<Option<si::Mass>> {
442        let chassis_mass = self
443            .chassis
444            .mass()
445            .with_context(|| anyhow!(format_dbg!()))?;
446        let pt_mass = match &self.pt_type {
447            PowertrainType::ConventionalVehicle(conv) => conv.mass()?,
448            PowertrainType::HybridElectricVehicle(hev) => hev.mass()?,
449            PowertrainType::PlugInHybridElectricVehicle(phev) => phev.mass()?,
450            PowertrainType::BatteryElectricVehicle(bev) => bev.mass()?,
451        };
452        if let (Some(pt_mass), Some(chassis_mass)) = (pt_mass, chassis_mass) {
453            Ok(Some(pt_mass + chassis_mass))
454        } else {
455            Ok(None)
456        }
457    }
458
459    fn expunge_mass_fields(&mut self) {
460        self.chassis.expunge_mass_fields();
461        match &mut self.pt_type {
462            PowertrainType::ConventionalVehicle(conv) => conv.expunge_mass_fields(),
463            PowertrainType::HybridElectricVehicle(hev) => hev.expunge_mass_fields(),
464            PowertrainType::PlugInHybridElectricVehicle(phev) => phev.expunge_mass_fields(),
465            PowertrainType::BatteryElectricVehicle(bev) => bev.expunge_mass_fields(),
466        };
467    }
468}
469
470impl Vehicle {
471    fn warn_if_version_mismatch(min_ver: Option<Version>) {
472        if let Some(min_ver) = min_ver {
473            if min_ver > *crate::FASTSIM_VERSION {
474                eprintln!(
475                    "WARNING: vehicle file requires FASTSim >= {min_ver} but the installed \
476                    version is {}. Loading will be attempted but may fail or produce \
477                    unexpected results. Please update FASTSim.",
478                    *crate::FASTSIM_VERSION
479                );
480            } else if min_ver.major < crate::FASTSIM_VERSION.major {
481                eprintln!(
482                    "WARNING: vehicle file has min_fastsim_version {min_ver}, which is from \
483                    an older major version than the installed FASTSim {}. Major-version \
484                    upgrades may introduce breaking changes; loading will be attempted.",
485                    *crate::FASTSIM_VERSION
486                );
487            }
488        }
489    }
490}
491
492impl SerdeAPI for Vehicle {
493    #[cfg(feature = "resources")]
494    const RESOURCES_SUBDIR: &'static str = "vehicles";
495
496    /// Deserialize a [`Vehicle`] from a reader, emitting a warning if
497    /// [`Vehicle::min_fastsim_version`] exceeds the installed version before attempting full
498    /// deserialization. This ensures version incompatibilities produce a clear diagnostic even
499    /// when the full parse would fail due to unrecognized fields added in a newer release.
500    ///
501    /// If deserialization in contemporary vehicle format fails and the `compat` feature is
502    /// enabled, this will fall back to the FASTSim-2 vehicle format.
503    fn from_reader<R: std::io::Read>(
504        rdr: &mut R,
505        format: &str,
506        skip_init: bool,
507    ) -> Result<Self, Error> {
508        #[derive(Deserialize)]
509        struct VersionCheck {
510            #[serde(default = "crate::current_fastsim_version")]
511            min_fastsim_version: Version,
512        }
513
514        let mut buf = Vec::new();
515        rdr.read_to_end(&mut buf)
516            .map_err(|err| Error::SerdeError(format!("{err}")))?;
517
518        let fmt = format.trim_start_matches('.').to_lowercase();
519
520        // Try to extract `min_fastsim_version` from the raw buffer before full deserialization so
521        // that a version mismatch is reported even when full parsing fails due to newer fields.
522        let min_ver: Option<Version> = match fmt.as_str() {
523            #[cfg(feature = "yaml")]
524            "yaml" | "yml" => serde_yaml::from_slice::<VersionCheck>(&buf)
525                .ok()
526                .map(|v| v.min_fastsim_version),
527            #[cfg(feature = "json")]
528            "json" => serde_json::from_slice::<VersionCheck>(&buf)
529                .ok()
530                .map(|v| v.min_fastsim_version),
531            #[cfg(feature = "msgpack")]
532            "msgpack" => rmp_serde::decode::from_slice::<VersionCheck>(&buf)
533                .ok()
534                .map(|v| v.min_fastsim_version),
535            #[cfg(feature = "toml")]
536            "toml" => std::str::from_utf8(&buf)
537                .ok()
538                .and_then(|s| toml::from_str::<VersionCheck>(s).ok())
539                .map(|v| v.min_fastsim_version),
540            _ => None,
541        };
542        Self::warn_if_version_mismatch(min_ver);
543
544        // Try deserializing from the contemporary vehicle format
545        let parse_result: Result<Self, Error> = match fmt.as_str() {
546            #[cfg(feature = "yaml")]
547            "yaml" | "yml" => {
548                serde_yaml::from_slice(&buf).map_err(|err| Error::SerdeError(format!("{err}")))
549            }
550            #[cfg(feature = "json")]
551            "json" => {
552                serde_json::from_slice(&buf).map_err(|err| Error::SerdeError(format!("{err}")))
553            }
554            #[cfg(feature = "msgpack")]
555            "msgpack" => rmp_serde::decode::from_slice(&buf)
556                .map_err(|err| Error::SerdeError(format!("{err}"))),
557            #[cfg(feature = "toml")]
558            "toml" => {
559                let toml_str =
560                    std::str::from_utf8(&buf).map_err(|err| Error::SerdeError(format!("{err}")))?;
561                toml::from_str(toml_str).map_err(|err| Error::SerdeError(format!("{err}")))
562            }
563            _ => Err(Error::SerdeError(format!(
564                "Unsupported format {format:?}, must be one of {:?}",
565                Self::ACCEPTED_BYTE_FORMATS,
566            ))),
567        };
568        match parse_result {
569            // Normal behavior:
570            // If deserialization in contemporary vehicle format succeeds,
571            // return the deserialized initialized vehicle
572            Ok(mut deserialized) => {
573                if !skip_init {
574                    deserialized.init()?;
575                }
576                Ok(deserialized)
577            }
578            // Fallback behavior:
579            // If deserialization in contemporary vehicle format fails
580            // (and the `compat` feature is enabled),
581            // attempt to deserialize in fastsim-2 format,
582            // otherwise return the original error
583            Err(format_parse_err) => {
584                #[cfg(feature = "compat")]
585                {
586                    let mut buf_rdr = std::io::Cursor::new(buf.as_slice());
587                    use crate::compat::fastsim_2::fastsim_core::traits::SerdeAPI;
588                    if let Ok(f2_veh) =
589                        crate::compat::fastsim_2::fastsim_core::vehicle::RustVehicle::from_reader(
590                            &mut buf_rdr,
591                            format,
592                            skip_init,
593                        )
594                    {
595                        return Vehicle::try_from(f2_veh)
596                            .map_err(|err| Error::SerdeError(format!("{err}")));
597                    }
598                }
599                Err(format_parse_err)
600            }
601        }
602    }
603
604    // Specialized `from_yaml` that allows for compatibility with fastsim-2 vehicle format
605    #[cfg(feature = "yaml")]
606    fn from_yaml<S: AsRef<str>>(yaml_str: S, skip_init: bool) -> anyhow::Result<Self> {
607        #[derive(Deserialize)]
608        struct VersionCheck {
609            #[serde(default = "crate::current_fastsim_version")]
610            min_fastsim_version: Version,
611        }
612        let min_ver = serde_yaml::from_str::<VersionCheck>(yaml_str.as_ref())
613            .ok()
614            .map(|v| v.min_fastsim_version);
615        Self::warn_if_version_mismatch(min_ver);
616
617        match serde_yaml::from_str::<Self>(yaml_str.as_ref()) {
618            // Normal behavior:
619            // If deserialization in contemporary vehicle format succeeds,
620            // return the deserialized initialized vehicle
621            Ok(mut yaml_de) => {
622                if !skip_init {
623                    yaml_de.init()?;
624                }
625                Ok(yaml_de)
626            }
627            // Fallback behavior:
628            // If deserialization in contemporary vehicle format fails
629            // (and the `compat` feature is enabled),
630            // attempt to deserialize in fastsim-2 format,
631            // otherwise return the original error
632            Err(format_parse_err) => {
633                #[cfg(feature = "compat")]
634                {
635                    use crate::compat::fastsim_2::fastsim_core::traits::SerdeAPI;
636                    if let Ok(f2_veh) =
637                        crate::compat::fastsim_2::fastsim_core::vehicle::RustVehicle::from_yaml(
638                            &yaml_str, skip_init,
639                        )
640                    {
641                        return Vehicle::try_from(f2_veh);
642                    }
643                }
644                Err(format_parse_err.into())
645            }
646        }
647    }
648
649    // Specialized `from_json` that allows for compatibility with fastsim-2 vehicle format
650    #[cfg(feature = "json")]
651    fn from_json<S: AsRef<str>>(json_str: S, skip_init: bool) -> anyhow::Result<Self> {
652        #[derive(Deserialize)]
653        struct VersionCheck {
654            #[serde(default = "crate::current_fastsim_version")]
655            min_fastsim_version: Version,
656        }
657        let min_ver = serde_json::from_str::<VersionCheck>(json_str.as_ref())
658            .ok()
659            .map(|v| v.min_fastsim_version);
660        Self::warn_if_version_mismatch(min_ver);
661
662        match serde_json::from_str::<Self>(json_str.as_ref()) {
663            // Normal behavior:
664            // If deserialization in contemporary vehicle format succeeds,
665            // return the deserialized initialized vehicle
666            Ok(mut json_de) => {
667                if !skip_init {
668                    json_de.init()?;
669                }
670                Ok(json_de)
671            }
672            // Fallback behavior:
673            // If deserialization in contemporary vehicle format fails
674            // (and the `compat` feature is enabled),
675            // attempt to deserialize in fastsim-2 format,
676            // otherwise return the original error
677            Err(format_parse_err) => {
678                #[cfg(feature = "compat")]
679                {
680                    use crate::compat::fastsim_2::fastsim_core::traits::SerdeAPI;
681                    if let Ok(f2_veh) =
682                        crate::compat::fastsim_2::fastsim_core::vehicle::RustVehicle::from_json(
683                            &json_str, skip_init,
684                        )
685                    {
686                        return Vehicle::try_from(f2_veh);
687                    }
688                }
689                Err(format_parse_err.into())
690            }
691        }
692    }
693
694    // Specialized `from_toml` that allows for compatibility with fastsim-2 vehicle format
695    #[cfg(feature = "toml")]
696    fn from_toml<S: AsRef<str>>(toml_str: S, skip_init: bool) -> anyhow::Result<Self> {
697        #[derive(Deserialize)]
698        struct VersionCheck {
699            #[serde(default = "crate::current_fastsim_version")]
700            min_fastsim_version: Version,
701        }
702        let min_ver = toml::from_str::<VersionCheck>(toml_str.as_ref())
703            .ok()
704            .map(|v| v.min_fastsim_version);
705        Self::warn_if_version_mismatch(min_ver);
706
707        match toml::from_str::<Self>(toml_str.as_ref()) {
708            // Normal behavior:
709            // If deserialization in contemporary vehicle format succeeds,
710            // return the deserialized initialized vehicle
711            Ok(mut toml_de) => {
712                if !skip_init {
713                    toml_de.init()?;
714                }
715                Ok(toml_de)
716            }
717            // Fallback behavior:
718            // If deserialization in contemporary vehicle format fails
719            // (and the `compat` feature is enabled),
720            // attempt to deserialize in fastsim-2 format,
721            // otherwise return the original error
722            Err(format_parse_err) => {
723                #[cfg(feature = "compat")]
724                {
725                    use crate::compat::fastsim_2::fastsim_core::traits::SerdeAPI;
726                    if let Ok(f2_veh) =
727                        crate::compat::fastsim_2::fastsim_core::vehicle::RustVehicle::from_toml(
728                            &toml_str, skip_init,
729                        )
730                    {
731                        return Vehicle::try_from(f2_veh);
732                    }
733                }
734                Err(format_parse_err.into())
735            }
736        }
737    }
738}
739
740impl Init for Vehicle {
741    fn init(&mut self) -> Result<(), Error> {
742        let _mass = self
743            .mass()
744            .map_err(|err| Error::InitError(format_dbg!(err)))?;
745        self.calculate_wheel_radius()
746            .map_err(|err| Error::InitError(format_dbg!(err)))?;
747        self.pt_type
748            .init()
749            .map_err(|err| Error::InitError(format_dbg!(err)))?;
750        let mass = self
751            .mass()
752            .unwrap_or(Some(0.0 * uc::KG))
753            .unwrap_or(0.0 * uc::KG);
754        let _ = match &self.pt_type {
755            PowertrainType::HybridElectricVehicle(hev) => hev.check_buffers(mass),
756            PowertrainType::PlugInHybridElectricVehicle(hev) => hev.check_buffers(mass),
757            _ => Ok(()),
758        };
759        Ok(())
760    }
761}
762
763impl HistoryMethods for Vehicle {
764    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
765        Ok(self.save_interval)
766    }
767    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
768        self.save_interval = save_interval;
769        self.pt_type.set_save_interval(save_interval)?;
770        self.cabin.set_save_interval(save_interval)?;
771        self.hvac.set_save_interval(save_interval)?;
772        Ok(())
773    }
774    fn clear(&mut self) {
775        self.history.clear();
776        self.pt_type.clear();
777        self.cabin.clear();
778        self.hvac.clear();
779    }
780}
781
782impl SetCumulative for Vehicle {
783    fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
784        self.state
785            .set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?;
786        self.pt_type
787            .set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?;
788        self.cabin
789            .set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?;
790        self.hvac
791            .set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?;
792        // this does not get handled by the `SetCumulative` derive macro
793        self.state.dist.increment(
794            *self.state.speed_ach.get_fresh(|| format_dbg!())? * dt,
795            || format_dbg!(),
796        )?;
797        Ok(())
798    }
799
800    fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
801        self.state
802            .reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?;
803        self.pt_type
804            .reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?;
805        self.cabin
806            .reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?;
807        self.hvac
808            .reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?;
809        // this does not get handled by the `SetCumulative` derive macro
810        self.state.dist.mark_stale();
811        self.state.dist.update(si::Length::ZERO, || format_dbg!())?;
812        self.state.time.mark_stale();
813        self.state.time.update(si::Time::ZERO, || format_dbg!())?;
814        self.state.speed_ach.mark_stale();
815        self.state
816            .speed_ach
817            .update(si::Velocity::ZERO, || format_dbg!())?;
818        Ok(())
819    }
820}
821
822impl Vehicle {
823    /// # Assumptions
824    /// - peak power of all components can be produced concurrently.
825    pub fn get_pwr_rated(&self) -> si::Power {
826        match (self.fc(), self.res()) {
827            (Some(fc), Some(res)) => fc.pwr_out_max + res.pwr_out_max,
828            (Some(fc), None) => fc.pwr_out_max,
829            (None, Some(res)) => res.pwr_out_max,
830            (None, None) => unreachable!(),
831        }
832    }
833
834    pub fn conv(&self) -> Option<&ConventionalVehicle> {
835        self.pt_type.conv()
836    }
837
838    pub fn hev(&self) -> Option<&HybridElectricVehicle> {
839        self.pt_type.hev()
840    }
841
842    // pub fn phev(&self) -> Option<&HybridElectricVehicle> {
843    //     self.pt_type.phev()
844    // }
845
846    pub fn bev(&self) -> Option<&BatteryElectricVehicle> {
847        self.pt_type.bev()
848    }
849
850    pub fn conv_mut(&mut self) -> Option<&mut ConventionalVehicle> {
851        self.pt_type.conv_mut()
852    }
853
854    pub fn hev_mut(&mut self) -> Option<&mut HybridElectricVehicle> {
855        self.pt_type.hev_mut()
856    }
857
858    // pub fn phev_mut(&mut self) -> Option<&mut HybridElectricVehicle> {
859    //     self.pt_type.phev_mut()
860    // }
861
862    pub fn bev_mut(&mut self) -> Option<&mut BatteryElectricVehicle> {
863        self.pt_type.bev_mut()
864    }
865
866    pub fn fc(&self) -> Option<&FuelConverter> {
867        self.pt_type.fc()
868    }
869
870    pub fn fc_mut(&mut self) -> Option<&mut FuelConverter> {
871        self.pt_type.fc_mut()
872    }
873
874    pub fn set_fc(&mut self, fc: FuelConverter) -> anyhow::Result<()> {
875        self.pt_type.set_fc(fc)
876    }
877
878    pub fn fs(&self) -> Option<&FuelStorage> {
879        self.pt_type.fs()
880    }
881
882    pub fn fs_mut(&mut self) -> Option<&mut FuelStorage> {
883        self.pt_type.fs_mut()
884    }
885
886    pub fn set_fs(&mut self, fs: FuelStorage) -> anyhow::Result<()> {
887        self.pt_type.set_fs(fs)
888    }
889
890    pub fn res(&self) -> Option<&ReversibleEnergyStorage> {
891        self.pt_type.res()
892    }
893
894    pub fn res_mut(&mut self) -> Option<&mut ReversibleEnergyStorage> {
895        self.pt_type.res_mut()
896    }
897
898    pub fn set_res(&mut self, res: ReversibleEnergyStorage) -> anyhow::Result<()> {
899        self.pt_type.set_res(res)
900    }
901
902    pub fn em(&self) -> Option<&ElectricMachine> {
903        self.pt_type.em()
904    }
905
906    pub fn em_mut(&mut self) -> Option<&mut ElectricMachine> {
907        self.pt_type.em_mut()
908    }
909
910    pub fn set_em(&mut self, em: ElectricMachine) -> anyhow::Result<()> {
911        self.pt_type.set_em(em)
912    }
913
914    pub fn trans(&self) -> Option<&Transmission> {
915        self.pt_type.trans()
916    }
917
918    pub fn trans_mut(&mut self) -> Option<&mut Transmission> {
919        self.pt_type.trans_mut()
920    }
921
922    pub fn set_trans(&mut self, trans: Transmission) -> anyhow::Result<()> {
923        self.pt_type.set_trans(trans)
924    }
925
926    /// Calculate wheel radius from tire code, if applicable
927    fn calculate_wheel_radius(&mut self) -> anyhow::Result<()> {
928        ensure!(
929            self.chassis.wheel_radius.is_some() || self.chassis.tire_code.is_some(),
930            "Either `wheel_radius` or `tire_code` must be supplied"
931        );
932        if self.chassis.wheel_radius.is_none() {
933            self.chassis.wheel_radius =
934                Some(utils::tire_code_to_radius(self.chassis.tire_code.as_ref().unwrap())? * uc::M)
935        }
936        Ok(())
937    }
938
939    /// Solves for energy consumption
940    pub fn solve_powertrain(&mut self, dt: si::Time) -> anyhow::Result<()> {
941        self.pt_type
942            .solve(
943                *self.state.pwr_tractive.get_fresh(|| format_dbg!())?,
944                true, // `enabled` should always be true at the powertrain level
945                dt,
946            )
947            .map_err(|err| {
948                anyhow::anyhow!(
949                    "solve() failed at line {} with originating error [{}]",
950                    format_dbg!(),
951                    err
952                )
953            })?;
954        self.state.pwr_brake.update(
955            -self
956                .state
957                .pwr_tractive
958                .get_fresh(|| format_dbg!())?
959                .max(si::Power::ZERO)
960                - self.pt_type.pwr_regen().with_context(|| format_dbg!())?,
961            || format_dbg!(),
962        )?;
963        Ok(())
964    }
965
966    pub fn set_curr_pwr_out_max(&mut self, dt: si::Time) -> anyhow::Result<()> {
967        // Calculate traction-limited max speed (acceleration-based, not power-based).
968        // This is stored as a speed ceiling and enforced after the trace-miss solver,
969        // rather than being converted to a power cap that starves the solver at low speeds.
970        //
971        // Weight transfer sign depends on drive type:
972        //   FWD: under acceleration, weight shifts to rear (away from drive axle) → reduces traction
973        //   RWD: under acceleration, weight shifts to rear (toward drive axle) → increases traction
974        //   AWD/4WD: both axles drive, weight transfer doesn't reduce total drive grip
975        let cg_height_abs = self.chassis.cg_height.abs();
976        let weight_transfer_sign: f64 = match self.chassis.drive_type {
977            chassis::DriveTypes::FWD => 1.0, // weight shifts away from front drive axle → reduces traction
978            chassis::DriveTypes::RWD => -1.0, // weight shifts toward rear drive axle → increases traction
979            chassis::DriveTypes::AWD | chassis::DriveTypes::FourWD => 0.0, // net zero effect on total drive traction
980        };
981        let max_trac_accel =
982            self.chassis.wheel_fric_coef * self.chassis.drive_axle_weight_frac * uc::ACC_GRAV
983                / (1.0 * uc::R
984                    + weight_transfer_sign * cg_height_abs * self.chassis.wheel_fric_coef
985                        / self.chassis.wheel_base);
986        let prev_speed = *self.state.speed_ach.get_stale(|| format_dbg!())?;
987        let max_trac_speed = prev_speed + (max_trac_accel * dt);
988        self.state
989            .speed_trac_fwd_max
990            .update(max_trac_speed, || format_dbg!())?;
991
992        // Calculate powertrain limits (no traction power cap applied here)
993        self.pt_type
994            .set_curr_pwr_prop_out_max(
995                (si::Power::ZERO, si::Power::ZERO),
996                *self.state.pwr_aux.get_fresh(|| format_dbg!())?,
997                dt,
998                &self.state,
999            )
1000            .with_context(|| anyhow!(format_dbg!()))?;
1001        let pwr_prop_maxes = self
1002            .pt_type
1003            .get_curr_pwr_prop_out_max()
1004            .with_context(|| anyhow!(format_dbg!()))?;
1005        self.state
1006            .pwr_prop_fwd_max
1007            .update(pwr_prop_maxes.0, || format_dbg!())?;
1008        self.state
1009            .pwr_prop_bwd_max
1010            .update(pwr_prop_maxes.1, || format_dbg!())?;
1011
1012        Ok(())
1013    }
1014
1015    pub fn solve_thermal(
1016        &mut self,
1017        te_amb_air: si::Temperature,
1018        dt: si::Time,
1019    ) -> anyhow::Result<()> {
1020        let te_fc: Option<si::Temperature> = self
1021            .fc()
1022            .and_then(|fc| fc.temperature().map(|fct| fct.get_stale(|| format_dbg!())))
1023            .transpose()
1024            .with_context(|| {
1025                format!(
1026                    "{}\nfuel converter temperature has not been properly set",
1027                    format_dbg!()
1028                )
1029            })?
1030            .copied();
1031        let pwr_thrml_cab_to_res: si::Power = match self.res() {
1032            Some(res) => match &res.thrml {
1033                RESThermalOption::RESLumpedThermal(rlt) => {
1034                    *rlt.state.pwr_thrml_from_cabin.get_stale(|| format_dbg!())?
1035                }
1036                RESThermalOption::None => si::Power::ZERO,
1037            },
1038            None => si::Power::ZERO,
1039        };
1040
1041        let (pwr_thrml_fc_to_cabin, pwr_thrml_hvac_to_res, te_cab) = self
1042            .solve_hvac_cab_res(te_amb_air, dt, te_fc, pwr_thrml_cab_to_res)
1043            .with_context(|| format_dbg!())?;
1044
1045        self.pt_type
1046            .solve_thermal(
1047                te_amb_air,
1048                pwr_thrml_fc_to_cabin,
1049                &mut self.state,
1050                pwr_thrml_hvac_to_res,
1051                te_cab,
1052                dt,
1053            )
1054            .with_context(|| format_dbg!())?;
1055        Ok(())
1056    }
1057
1058    fn solve_hvac_cab_res(
1059        &mut self,
1060        te_amb_air: si::Temperature,
1061        dt: si::Time,
1062        te_fc: Option<si::Temperature>,
1063        pwr_thrml_cab_to_res: si::Power,
1064    ) -> anyhow::Result<(
1065        Option<si::Power>,
1066        Option<si::Power>,
1067        Option<si::Temperature>,
1068    )> {
1069        let res_thrml_state = self.pt_type.res_mut().and_then(|rm| rm.res_thrml_state());
1070        let (pwr_thrml_fc_to_cabin, pwr_thrml_hvac_to_res, te_cab): (
1071            Option<si::Power>,
1072            Option<si::Power>,
1073            Option<si::Temperature>,
1074        ) = match (&mut self.cabin, &mut self.hvac, res_thrml_state) {
1075            (CabinOption::None, HVACOption::None, None) => {
1076                self.state
1077                    .pwr_aux
1078                    .update(self.pwr_aux_base, || format_dbg!())?;
1079                (None, None, None)
1080            }
1081            (CabinOption::LumpedCabin(cab), HVACOption::LumpedCabin(hvac), None) => {
1082                let (pwr_thrml_hvac_to_cabin, pwr_thrml_fc_to_cab) = hvac
1083                    .solve(te_amb_air, te_fc, &cab.state, cab.heat_capacitance, dt)
1084                    .with_context(|| format_dbg!())?;
1085                let te_cab = cab
1086                    .solve(
1087                        te_amb_air,
1088                        &self.state,
1089                        pwr_thrml_hvac_to_cabin,
1090                        Default::default(),
1091                        dt,
1092                    )
1093                    .with_context(|| format_dbg!())?;
1094                self.state.pwr_aux.update(
1095                    self.pwr_aux_base
1096                        + *hvac
1097                            .state
1098                            .pwr_aux_for_hvac
1099                            .get_fresh(|| format_dbg!("hvac.state.pwr_aux_for_hvac"))?,
1100                    || format_dbg!(),
1101                )?;
1102                (Some(pwr_thrml_fc_to_cab), None, Some(te_cab))
1103            }
1104            (
1105                CabinOption::LumpedCabin(cab),
1106                HVACOption::LumpedCabinAndRES(hvac),
1107                Some(res_thrml_state),
1108            ) => {
1109                let (pwr_thrml_hvac_to_cabin, pwr_thrml_fc_to_cab, pwr_thrml_hvac_to_res) = hvac
1110                    .solve(
1111                        te_amb_air,
1112                        te_fc,
1113                        &cab.state,
1114                        cab.heat_capacitance,
1115                        res_thrml_state,
1116                        dt,
1117                    )
1118                    .with_context(|| format_dbg!())?;
1119                let te_cab = cab
1120                    .solve(
1121                        te_amb_air,
1122                        &self.state,
1123                        pwr_thrml_hvac_to_cabin,
1124                        pwr_thrml_cab_to_res,
1125                        dt,
1126                    )
1127                    .with_context(|| format_dbg!())?;
1128                self.state.pwr_aux.update(
1129                    self.pwr_aux_base
1130                        + *hvac
1131                            .state
1132                            .pwr_aux_for_cab_hvac
1133                            .get_fresh(|| format_dbg!())?
1134                        + *hvac
1135                            .state
1136                            .pwr_aux_for_res_hvac
1137                            .get_fresh(|| format_dbg!())?,
1138                    || format_dbg!(),
1139                )?;
1140                ensure!(
1141                    *self.state.pwr_aux.get_fresh(|| format_dbg!())? > si::Power::ZERO,
1142                    format!(
1143                        "{}\n{}\n{}",
1144                        format_dbg!(self.state.pwr_aux),
1145                        format_dbg!(hvac.state.pwr_aux_for_res_hvac),
1146                        format_dbg!(hvac.state.pwr_aux_for_cab_hvac)
1147                    )
1148                );
1149                (
1150                    Some(pwr_thrml_fc_to_cab),
1151                    Some(pwr_thrml_hvac_to_res),
1152                    Some(te_cab),
1153                )
1154            }
1155            (CabinOption::LumpedCabin(cab), HVACOption::LumpedCabin(hvac), Some(_)) => {
1156                let (pwr_thrml_hvac_to_cabin, pwr_thrml_fc_to_cab) = hvac
1157                    .solve(te_amb_air, te_fc, &cab.state, cab.heat_capacitance, dt)
1158                    .with_context(|| format_dbg!())?;
1159                let te_cab = cab
1160                    .solve(
1161                        te_amb_air,
1162                        &self.state,
1163                        pwr_thrml_hvac_to_cabin,
1164                        Default::default(),
1165                        dt,
1166                    )
1167                    .with_context(|| format_dbg!())?;
1168                self.state.pwr_aux.update(
1169                    self.pwr_aux_base
1170                        + *hvac
1171                            .state
1172                            .pwr_aux_for_hvac
1173                            .get_fresh(|| format_dbg!("hvac.state.pwr_aux_for_hvac"))?,
1174                    || format_dbg!(),
1175                )?;
1176                (Some(pwr_thrml_fc_to_cab), None, Some(te_cab))
1177            }
1178            (CabinOption::LumpedCabin(cab), HVACOption::None, Some(_)) => {
1179                let te_cab = cab
1180                    .solve(
1181                        te_amb_air,
1182                        &self.state,
1183                        si::Power::ZERO,
1184                        si::Power::ZERO,
1185                        dt,
1186                    )
1187                    .with_context(|| format_dbg!())?;
1188                self.state
1189                    .pwr_aux
1190                    .update(self.pwr_aux_base, || format_dbg!())?;
1191                (None, None, Some(te_cab))
1192            }
1193            (CabinOption::LumpedCabin(cab), HVACOption::None, None) => {
1194                let te_cab = cab
1195                    .solve(
1196                        te_amb_air,
1197                        &self.state,
1198                        si::Power::ZERO,
1199                        si::Power::ZERO,
1200                        dt,
1201                    )
1202                    .with_context(|| format_dbg!())?;
1203                self.state
1204                    .pwr_aux
1205                    .update(self.pwr_aux_base, || format_dbg!())?;
1206                (None, None, Some(te_cab))
1207            }
1208            (_, _, _) => {
1209                bail!(
1210                    "{}\nCabin, HVAC, and RESThermal configuration is either invalid or not yet implemented.\n{} - {} - {}",
1211                    format_dbg!(),
1212                    format!("{}", self.hvac),
1213                    format!("{}", self.cabin),
1214                    format!(
1215                        "`res.res_thrml_state().is_some()`: {}",
1216                        self.pt_type.res().and_then(|res| res.res_thrml_state()).is_some()
1217                    ),
1218                );
1219            }
1220        };
1221        Ok((pwr_thrml_fc_to_cabin, pwr_thrml_hvac_to_res, te_cab))
1222    }
1223
1224    pub(crate) fn mark_non_thermal_fresh(&mut self) -> Result<(), anyhow::Error> {
1225        self.state.i.mark_stale();
1226        self.state.time.mark_stale();
1227        self.state.pwr_aux.mark_stale();
1228        self.state.mass.mark_stale();
1229        self.state.mark_fresh(|| format_dbg!())?;
1230        self.state.energy_tractive.mark_stale();
1231        self.state.energy_aux.mark_stale();
1232        self.state.energy_drag.mark_stale();
1233        self.state.energy_accel.mark_stale();
1234        self.state.energy_ascent.mark_stale();
1235        self.state.energy_rr.mark_stale();
1236        self.state.energy_whl_inertia.mark_stale();
1237        self.state.energy_brake.mark_stale();
1238        self.state.dist.mark_stale();
1239        if let Some(fc) = self.fc_mut() {
1240            fc.state.i.mark_stale();
1241            fc.state.mark_fresh(|| format_dbg!())?;
1242            fc.state.energy_prop.mark_stale();
1243            fc.state.energy_aux.mark_stale();
1244            fc.state.energy_fuel.mark_stale();
1245            fc.state.energy_loss.mark_stale();
1246        }
1247        if let Some(res) = self.res_mut() {
1248            res.state.i.mark_stale();
1249            res.state.soh.mark_stale();
1250            res.state.mark_fresh(|| format_dbg!())?;
1251            res.state.energy_out_electrical.mark_stale();
1252            res.state.energy_out_prop.mark_stale();
1253            res.state.energy_aux.mark_stale();
1254            res.state.energy_loss.mark_stale();
1255            res.state.energy_out_chemical.mark_stale();
1256        }
1257
1258        if let Some(em) = self.em_mut() {
1259            em.state.i.mark_stale();
1260            em.state.mark_fresh(|| format_dbg!())?;
1261            em.state.energy_out_req.mark_stale();
1262            em.state.energy_elec_prop_in.mark_stale();
1263            em.state.energy_mech_prop_out.mark_stale();
1264            em.state.energy_mech_dyn_brake.mark_stale();
1265            em.state.energy_elec_dyn_brake.mark_stale();
1266            em.state.energy_loss.mark_stale();
1267        }
1268        if let Some(trans) = self.trans_mut() {
1269            trans.state.i.mark_stale();
1270            trans.state.mark_fresh(|| format_dbg!())?;
1271            trans.state.energy_out.mark_stale();
1272            trans.state.energy_in.mark_stale();
1273            trans.state.energy_loss.mark_stale();
1274        }
1275        if let PowertrainType::ConventionalVehicle(conv) = &mut self.pt_type {
1276            match &mut conv.pt_cntrl {
1277                ConvPowertrainControls::StartStop(cntrl) => cntrl.state.i.mark_stale(),
1278                ConvPowertrainControls::Normal => {}
1279            }
1280            conv.pt_cntrl.mark_fresh(|| format_dbg!())?;
1281            conv.dfco_cntrl.state.i.mark_stale();
1282            conv.dfco_cntrl.mark_fresh(|| format_dbg!())?;
1283        }
1284        if let PowertrainType::HybridElectricVehicle(hev) = &mut self.pt_type {
1285            match &mut hev.pt_cntrl {
1286                HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.state.i.mark_stale(),
1287                HEVPowertrainControls::StartStop(cntrl) => cntrl.state.i.mark_stale(),
1288            }
1289            hev.pt_cntrl.mark_fresh(|| format_dbg!())?
1290        }
1291        Ok(())
1292    }
1293}
1294
1295/// Vehicle state for current time step
1296#[serde_api]
1297#[derive(
1298    Clone, Debug, Deserialize, Serialize, PartialEq, HistoryVec, StateMethods, SetCumulative,
1299)]
1300#[non_exhaustive]
1301#[serde(default)]
1302#[serde(deny_unknown_fields)]
1303pub struct VehicleState {
1304    /// time step index
1305    pub i: TrackedState<usize>,
1306
1307    /// elapsed simulation time since start
1308    pub time: TrackedState<si::Time>,
1309
1310    // power and energy fields
1311    /// maximum forward propulsive power vehicle can produce
1312    pub pwr_prop_fwd_max: TrackedState<si::Power>,
1313    /// maximum forward speed achievable given traction (tire grip) limits
1314    pub speed_trac_fwd_max: TrackedState<si::Velocity>,
1315    /// pwr exerted on wheels by powertrain
1316    /// maximum backward propulsive power (e.g. regenerative braking) vehicle can produce
1317    pub pwr_prop_bwd_max: TrackedState<si::Power>,
1318    /// Tractive power for achieved speed
1319    pub pwr_tractive: TrackedState<si::Power>,
1320    /// Tractive power required for prescribed speed
1321    pub pwr_tractive_for_cyc: TrackedState<si::Power>,
1322    /// integral of [Self::pwr_tractive]
1323    pub energy_tractive: TrackedState<si::Energy>,
1324    /// time varying aux load
1325    pub pwr_aux: TrackedState<si::Power>,
1326    /// integral of [Self::pwr_aux]
1327    pub energy_aux: TrackedState<si::Energy>,
1328    /// Power applied to aero drag
1329    pub pwr_drag: TrackedState<si::Power>,
1330    /// integral of [Self::pwr_drag]
1331    pub energy_drag: TrackedState<si::Energy>,
1332    /// Power applied to acceleration (includes deceleration)
1333    pub pwr_accel: TrackedState<si::Power>,
1334    /// integral of [Self::pwr_accel]
1335    pub energy_accel: TrackedState<si::Energy>,
1336    /// Power applied to grade ascent
1337    pub pwr_ascent: TrackedState<si::Power>,
1338    /// integral of [Self::pwr_ascent]
1339    pub energy_ascent: TrackedState<si::Energy>,
1340    /// Power applied to rolling resistance
1341    pub pwr_rr: TrackedState<si::Power>,
1342    /// integral of [Self::pwr_rr]
1343    pub energy_rr: TrackedState<si::Energy>,
1344    /// Power applied to wheel and tire inertia
1345    pub pwr_whl_inertia: TrackedState<si::Power>,
1346    /// integral of [Self::pwr_whl_inertia]
1347    pub energy_whl_inertia: TrackedState<si::Energy>,
1348    /// Total braking power including regen
1349    pub pwr_brake: TrackedState<si::Power>,
1350    /// integral of [Self::pwr_brake]
1351    pub energy_brake: TrackedState<si::Energy>,
1352    /// whether powertrain can achieve power demand to achieve prescribed speed
1353    /// in current time step
1354    // because it should be assumed true in the first time step
1355    pub cyc_met: TrackedState<bool>,
1356    /// whether powertrain can achieve power demand to achieve prescribed speed
1357    /// in entire cycle
1358    pub cyc_met_overall: TrackedState<bool>,
1359    /// actual achieved speed
1360    pub speed_ach: TrackedState<si::Velocity>,
1361    /// cumulative distance traveled, integral of [Self::speed_ach]
1362    pub dist: TrackedState<si::Length>,
1363    /// current grade
1364    pub grade_curr: TrackedState<si::Ratio>,
1365    /// current grade
1366    // will be overridden during simulation anyway
1367    pub elev_curr: TrackedState<si::Length>,
1368    /// current air density
1369    pub air_density: TrackedState<si::MassDensity>,
1370    /// current mass
1371    // TODO: make sure this gets updated appropriately
1372    pub mass: TrackedState<si::Mass>,
1373}
1374
1375impl SerdeAPI for VehicleState {}
1376impl Init for VehicleState {}
1377impl Default for VehicleState {
1378    fn default() -> Self {
1379        Self {
1380            i: TrackedState::new(Default::default()),
1381            time: Default::default(),
1382            pwr_prop_fwd_max: Default::default(),
1383            speed_trac_fwd_max: Default::default(),
1384            pwr_prop_bwd_max: Default::default(),
1385            pwr_tractive: Default::default(),
1386            pwr_tractive_for_cyc: Default::default(),
1387            energy_tractive: Default::default(),
1388            pwr_aux: Default::default(),
1389            energy_aux: Default::default(),
1390            pwr_drag: Default::default(),
1391            energy_drag: Default::default(),
1392            pwr_accel: Default::default(),
1393            energy_accel: Default::default(),
1394            pwr_ascent: Default::default(),
1395            energy_ascent: Default::default(),
1396            pwr_rr: Default::default(),
1397            energy_rr: Default::default(),
1398            pwr_whl_inertia: Default::default(),
1399            energy_whl_inertia: Default::default(),
1400            pwr_brake: Default::default(),
1401            energy_brake: Default::default(),
1402            cyc_met: TrackedState::new(true),
1403            cyc_met_overall: TrackedState::new(true),
1404            speed_ach: Default::default(),
1405            dist: Default::default(),
1406            // note that this value will be overwritten
1407            grade_curr: Default::default(),
1408            // note that this value will be overwritten
1409            elev_curr: Default::default(),
1410            air_density: Default::default(),
1411            mass: TrackedState::new(uc::KG * f64::NAN),
1412        }
1413    }
1414}
1415
1416#[cfg(test)]
1417pub(crate) mod tests {
1418    use crate::vehicle::conv::{ConvPowertrainControls, ConvStartStopControl};
1419    use crate::vehicle::hev::{HEVAuxControls, HEVSimulationParams, HEVStartStopControl};
1420    use crate::vehicle::powertrain::reversible_energy_storage::RESEfficiency;
1421
1422    use super::*;
1423
1424    #[allow(dead_code)]
1425    fn vehicles_dir() -> PathBuf {
1426        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/vehicles")
1427    }
1428
1429    #[test]
1430    #[cfg(feature = "yaml")]
1431    pub(crate) fn test_conv_veh_init() {
1432        assert!(Vehicle::from_resource("2012_Ford_Fusion.yaml", false).is_ok());
1433    }
1434
1435    type StructWithResources = Vehicle;
1436
1437    #[test]
1438    #[cfg(all(feature = "compat", feature = "yaml"))]
1439    fn test_f2_vehicle_assets_load_via_from_reader() {
1440        let vehicle_assets = crate::compat::fastsim_2::ASSETS_DIR
1441            .get_dir("vehicles")
1442            .unwrap();
1443
1444        for file in vehicle_assets.files() {
1445            let mut contents = file.contents();
1446            let result = Vehicle::from_reader(&mut contents, "yaml", false);
1447            assert!(
1448                result.is_ok(),
1449                "from_reader failed for {:?}: {:?}",
1450                file.path(),
1451                result.err()
1452            );
1453        }
1454    }
1455
1456    #[test]
1457    #[cfg(all(feature = "compat", feature = "yaml"))]
1458    fn test_f2_vehicle_assets_load_via_from_yaml() {
1459        let vehicle_assets = crate::compat::fastsim_2::ASSETS_DIR
1460            .get_dir("vehicles")
1461            .unwrap();
1462
1463        for file in vehicle_assets.files() {
1464            let yaml_str = std::str::from_utf8(file.contents()).unwrap();
1465            let result = Vehicle::from_yaml(yaml_str, false);
1466            assert!(
1467                result.is_ok(),
1468                "from_yaml failed for {:?}: {:?}",
1469                file.path(),
1470                result.err()
1471            );
1472        }
1473    }
1474
1475    #[test]
1476    fn test_resources() {
1477        let mut time_to_panic = false;
1478
1479        let resource_list = StructWithResources::list_resources().unwrap();
1480        assert!(!resource_list.is_empty());
1481
1482        // verify that resources can all load
1483        for resource in resource_list {
1484            if let Err(e) = StructWithResources::from_resource(resource.clone(), false) {
1485                time_to_panic = true;
1486                eprintln!("Error loading {resource:?}: {e}\n");
1487            }
1488        }
1489        if time_to_panic {
1490            panic!()
1491        }
1492    }
1493
1494    #[test]
1495    fn test_calibrated_vehicles() {
1496        let mut time_to_panic = false;
1497
1498        // check that calibrated vehicles can load
1499        let paths: Vec<_> = std::fs::read_dir("../cal_and_val/f3-vehicles")
1500            .unwrap()
1501            .collect();
1502        assert!(!paths.is_empty());
1503        for path in paths {
1504            let p = path.unwrap().path();
1505            if let Err(e) = StructWithResources::from_file(p.clone(), false) {
1506                time_to_panic = true;
1507                eprintln!("Error loading {p:?}: {e}\n");
1508            }
1509        }
1510
1511        // check that calibrated thermal-equipped vehicles can load
1512        let paths: Vec<_> = std::fs::read_dir("../cal_and_val/thermal/f3-vehicles")
1513            .unwrap()
1514            .collect();
1515        assert!(!paths.is_empty());
1516        for path in paths {
1517            let p = path.unwrap().path();
1518            if let Err(e) = StructWithResources::from_file(p.clone(), false) {
1519                time_to_panic = true;
1520                eprintln!("Error loading {p:?}: {e}\n");
1521            }
1522        }
1523
1524        if time_to_panic {
1525            panic!()
1526        }
1527    }
1528
1529    fn make_conv_pacifica(with_conv_start_stop: bool, with_dfco: bool) -> anyhow::Result<Vehicle> {
1530        let fs = FuelStorage::new(
1531            2000000.0 * uc::W,
1532            1.1 * uc::S,
1533            Some(FuelType::Gasoline),
1534            2305080000.0 * uc::J,
1535            None,
1536            None,
1537        )?;
1538        let fc = FuelConverter::new(
1539            FuelConverterThermalOption::None, // thrml
1540            None,                             // mass
1541            None,                             // specific_pwr
1542            211088.0 * uc::W,                 // pwr_out_max
1543            34604.59016393443 * uc::W,        // pwr_out_max_init
1544            6.1 * uc::S,                      // pwr_ramp_lag
1545            InterpolatorEnum::new_1d(
1546                vec![
1547                    0.0, 0.005, 0.015, 0.04, 0.06, 0.1, 0.14, 0.2, 0.4, 0.6, 0.8, 1.0,
1548                ]
1549                .into(),
1550                vec![
1551                    0.0,
1552                    0.0875106035,
1553                    0.143482108,
1554                    0.216273855,
1555                    0.252599848,
1556                    0.301508117,
1557                    0.33,
1558                    0.34,
1559                    0.35,
1560                    0.34,
1561                    0.32,
1562                    0.3,
1563                ]
1564                .into(),
1565                strategy::Linear,
1566                Extrapolate::Error,
1567            )?, // eff_interp_from_pwr_out
1568            0.4 * 211088.0 * uc::W,           // pwr_for_peak_eff
1569            0.0 * uc::W,                      // pwr_idle_fuel
1570            None,
1571        )?;
1572        let tx = Transmission::new(
1573            None,                           // mass
1574            InterpolatorEnum::new_0d(0.95), // eff_interp
1575            None,                           // save_interval
1576        )?;
1577        let pt_controls = {
1578            if with_conv_start_stop {
1579                let cntrl = ConvStartStopControl::new(
1580                    None, // fc_min_time_on
1581                    None, // temp_fc_forced_on
1582                    None, // temp_fc_allowed_off
1583                    None, // time_delay_after_stop_until_fc_can_turn_off
1584                    None, // save_interval
1585                )
1586                .map_err(|err| {
1587                    assert!(
1588                        false,
1589                        "Unable to create start-stop control for conv: {}",
1590                        err
1591                    );
1592                });
1593                let cntrl = cntrl.ok().unwrap();
1594                ConvPowertrainControls::StartStop(Box::new(cntrl))
1595            } else {
1596                ConvPowertrainControls::Normal
1597            }
1598        };
1599        let dfco_controls = conv::DfcoControls::new(
1600            with_dfco,       // dfco_enabled
1601            25.0 * uc::MPH,  // minimum_dfco_speed
1602            -0.2 * uc::MPS2, // minimum_dfco_deceleration
1603            None,            // save_interval
1604        )?;
1605        let conv = ConventionalVehicle::new(
1606            fs,            // fs
1607            fc,            // fc
1608            tx,            // transmission
1609            None,          // mass
1610            pt_controls,   // powertrain control
1611            dfco_controls, // dfco_cntrl
1612            1.0 * uc::R,   // alt_eff
1613        )?;
1614        let chassis = Chassis {
1615            drag_coef: 0.3303036837542712 * uc::R,
1616            frontal_area: 3.05124164 * uc::M2,
1617            wheel_rr_coef: 0.0064798953284486704 * uc::R,
1618            wheel_inertia: 0.815 * uc::KGM2,
1619            num_wheels: 4,
1620            wheel_radius: Some(0.36865 * uc::M),
1621            tire_code: None,
1622            cg_height: 0.53 * uc::M,
1623            wheel_fric_coef: 0.8 * uc::R,
1624            drive_type: chassis::DriveTypes::FWD,
1625            drive_axle_weight_frac: 0.61 * uc::R,
1626            wheel_base: 3.08864 * uc::M,
1627            mass: None,
1628            glider_mass: None,
1629            cargo_mass: None,
1630        };
1631        let boxed_conv = Box::new(conv);
1632        let mut veh = Vehicle::new(
1633            String::from("2026 Chrysler Pacifica Select"), // name
1634            None,
1635            None,
1636            None,
1637            Some(String::from("2026")),
1638            Some(String::from("Chrysler")),
1639            Some(String::from("Pacifica")),
1640            Some(String::from("Select")),
1641            PowertrainType::ConventionalVehicle(boxed_conv),
1642            chassis,
1643            CabinOption::None,
1644            HVACOption::None,
1645            Some(2154.564 * uc::KG),
1646            700.0 * uc::W,
1647            None,
1648        )?;
1649        veh.set_save_interval(Option::Some(1))?;
1650        Ok(veh)
1651    }
1652
1653    fn make_microhybrid_pacifica() -> anyhow::Result<Vehicle> {
1654        let res = ReversibleEnergyStorage::new(
1655            RESThermalOption::None,                 // thrml
1656            None,                                   // mass
1657            None,                                   // specific_energy
1658            5.0 * uc::KW,                           // pwr_out_max
1659            1.0 * uc::KWH,                          // energy_capacity
1660            RESEfficiency::Constant(Interp0D(0.9)), // eff_interp
1661            0.0 * uc::R,                            // min_soc
1662            1.0 * uc::R,                            // max_soc
1663            None,
1664        )?;
1665        let fs = FuelStorage::new(
1666            2000000.0 * uc::W,
1667            1.1 * uc::S,
1668            Some(FuelType::Gasoline),
1669            2305080000.0 * uc::J,
1670            None,
1671            None,
1672        )?;
1673        let fc = FuelConverter::new(
1674            FuelConverterThermalOption::None, // thrml
1675            None,                             // mass
1676            None,                             // specific_pwr
1677            211088.0 * uc::W,                 // pwr_out_max
1678            34604.59016393443 * uc::W,        // pwr_out_max_init
1679            6.1 * uc::S,                      // pwr_ramp_lag
1680            InterpolatorEnum::new_1d(
1681                vec![
1682                    0.0, 0.005, 0.015, 0.04, 0.06, 0.1, 0.14, 0.2, 0.4, 0.6, 0.8, 1.0,
1683                ]
1684                .into(),
1685                vec![
1686                    0.0,
1687                    0.0875106035,
1688                    0.143482108,
1689                    0.216273855,
1690                    0.252599848,
1691                    0.301508117,
1692                    0.33,
1693                    0.34,
1694                    0.35,
1695                    0.34,
1696                    0.32,
1697                    0.3,
1698                ]
1699                .into(),
1700                strategy::Linear,
1701                Extrapolate::Error,
1702            )?, // eff_interp_from_pwr_out
1703            0.4 * 211088.0 * uc::W,           // pwr_for_peak_eff
1704            0.0 * uc::W,                      // pwr_idle_fuel
1705            None,
1706        )?;
1707        let em = ElectricMachine::new(
1708            InterpolatorEnum::new_1d(
1709                vec![0.0, 1.0].into(),
1710                vec![0.95, 0.95].into(),
1711                strategy::Linear,
1712                Extrapolate::Error,
1713            )?, // eff_interp_achieved
1714            None,         // eff_interp_at_max_input
1715            5.0 * uc::KW, // pwr_out_max
1716            None,         // specific_pwr
1717            None,         // mass
1718            None,         // save_interval
1719        )?;
1720        let tx = Transmission::new(
1721            None,                           // mass
1722            InterpolatorEnum::new_0d(0.95), // eff_interp
1723            None,                           // save_interval
1724        )?;
1725        let cntrl = HEVStartStopControl::new(
1726            None, // fc_min_time_on
1727            None, // soc_fc_forced_on
1728            None, // frac_of_most_eff_pwr_to_run_fc
1729            None, // temp_fc_forced_on
1730            None, // temp_fc_allowed_off
1731            None, // time_delay_after_stop_until_fc_can_turn_off
1732            None, // em_can_regen
1733            None, // save_interval
1734        )?;
1735        let pt_cntrl = HEVPowertrainControls::StartStop(Box::new(cntrl));
1736        let aux_cntrl = HEVAuxControls::AuxOnResPriority;
1737        let sim_params = HEVSimulationParams::new(
1738            0.05 * uc::R, // res_per_fuel_lim
1739            5,            // soc_balance_iter_err
1740            false,        // balance_soc
1741            false,        // save_soc_bal_iters
1742        )?;
1743        let hev = HybridElectricVehicle::new(
1744            res,        // res
1745            fs,         // fs
1746            fc,         // fc
1747            em,         // em
1748            tx,         // transmission
1749            pt_cntrl,   // pt_cntrl
1750            aux_cntrl,  // aux_cntrl
1751            None,       // mass
1752            sim_params, // sim_params
1753        )?;
1754        let chassis = Chassis {
1755            drag_coef: 0.3303036837542712 * uc::R,
1756            frontal_area: 3.05124164 * uc::M2,
1757            wheel_rr_coef: 0.0064798953284486704 * uc::R,
1758            wheel_inertia: 0.815 * uc::KGM2,
1759            num_wheels: 4,
1760            wheel_radius: Some(0.36865 * uc::M),
1761            tire_code: None,
1762            cg_height: 0.53 * uc::M,
1763            wheel_fric_coef: 0.8 * uc::R,
1764            drive_type: chassis::DriveTypes::FWD,
1765            drive_axle_weight_frac: 0.61 * uc::R,
1766            wheel_base: 3.08864 * uc::M,
1767            mass: None,
1768            glider_mass: None,
1769            cargo_mass: None,
1770        };
1771        let boxed_hev = Box::new(hev);
1772        let mut veh = Vehicle::new(
1773            String::from("2026 Chrysler Pacifica Select (uHEV Test)"),
1774            None,
1775            None,
1776            None,
1777            Some(String::from("2026")),
1778            Some(String::from("Chrysler")),
1779            Some(String::from("Pacifica")),
1780            Some(String::from("Select")),
1781            PowertrainType::HybridElectricVehicle(boxed_hev),
1782            chassis,
1783            CabinOption::None,
1784            HVACOption::None,
1785            Some(2154.564 * uc::KG),
1786            700.0 * uc::W,
1787            None,
1788        )?;
1789        veh.set_save_interval(Option::Some(1))?;
1790        Ok(veh)
1791    }
1792
1793    #[test]
1794    fn we_can_create_and_simulate_a_micro_hybrid_vehicle() {
1795        let veh_result = make_microhybrid_pacifica();
1796        assert!(veh_result.is_ok());
1797        let veh = veh_result.unwrap();
1798        let cyc = crate::drive_cycle::Cycle::from_resource("udds.csv", false).unwrap();
1799        let mut sd = crate::simdrive::SimDrive::new(veh, cyc, Default::default());
1800        let run_result = sd.run();
1801        if let Err(err) = run_result {
1802            panic!("Error: {}", err);
1803        }
1804        assert!(run_result.is_ok());
1805    }
1806
1807    fn accumulate_for_zero_speed(speeds_mps: &[f64], fuels_mj: &[f64]) -> f64 {
1808        let shortest_idx = speeds_mps.len().min(fuels_mj.len());
1809        let mut result_mj = 0.0;
1810        for idx in 0..shortest_idx {
1811            if speeds_mps[idx] == 0.0 {
1812                result_mj += fuels_mj[idx];
1813            }
1814        }
1815        result_mj
1816    }
1817
1818    #[test]
1819    fn micro_hybrid_saves_more_fuel_than_conventional() {
1820        let veh_uhev_result = make_microhybrid_pacifica();
1821        assert!(veh_uhev_result.is_ok());
1822        let veh_uhev = veh_uhev_result.unwrap();
1823        let veh_conv_result = make_conv_pacifica(false, false);
1824        assert!(veh_conv_result.is_ok());
1825        let veh_conv = veh_conv_result.unwrap();
1826        let cyc = crate::drive_cycle::Cycle::from_resource("udds.csv", false).unwrap();
1827        let mut sd_uhev = crate::simdrive::SimDrive::new(veh_uhev, cyc.clone(), Default::default());
1828        let result_uhev = sd_uhev.run();
1829        if let Err(err) = result_uhev {
1830            panic!("Error: {}", err);
1831        }
1832        assert!(result_uhev.is_ok());
1833        let mut sd_conv = crate::simdrive::SimDrive::new(veh_conv, cyc.clone(), Default::default());
1834        let result_conv = sd_conv.run();
1835        assert!(result_conv.is_ok());
1836        let speeds_mps: Vec<f64> = cyc
1837            .speed
1838            .iter()
1839            .map(|spd| spd.get::<si::meter_per_second>())
1840            .collect();
1841        let fc_uhev = sd_uhev.veh.pt_type.fc().unwrap();
1842        let fc_conv = sd_conv.veh.pt_type.fc().unwrap();
1843        let fuels_uhev_mj: Vec<f64> = fc_uhev
1844            .history
1845            .energy_fuel
1846            .iter()
1847            .map(|ef| ef.get_fresh(|| format_dbg!()).unwrap().get::<si::joule>() / 1e6)
1848            .collect();
1849        let fuel_uhev_mj: f64 = fuels_uhev_mj.iter().sum();
1850        let fuels_conv_mj: Vec<f64> = fc_conv
1851            .history
1852            .energy_fuel
1853            .iter()
1854            .map(|ef| ef.get_fresh(|| format_dbg!()).unwrap().get::<si::joule>() / 1e6)
1855            .collect();
1856        let fuel_conv_mj: f64 = fuels_conv_mj.iter().sum();
1857        assert_eq!(speeds_mps.len(), fuels_uhev_mj.len());
1858        assert_eq!(speeds_mps.len(), fuels_conv_mj.len());
1859        eprintln!("fuel_uhev: {} MJ", fuel_uhev_mj);
1860        eprintln!("fuel_conv: {} MJ", fuel_conv_mj);
1861        assert!(
1862            fuel_uhev_mj < fuel_conv_mj,
1863            "Expected uHEV fuel ({fuel_uhev_mj}) to be less than conventional ({fuel_conv_mj})"
1864        );
1865        let fuel_stopped_uhev_mj = accumulate_for_zero_speed(&speeds_mps, &fuels_uhev_mj);
1866        let fuel_stopped_conv_mj = accumulate_for_zero_speed(&speeds_mps, &fuels_conv_mj);
1867        eprintln!("fuel_stopped_uhev: {} MJ", fuel_stopped_uhev_mj);
1868        eprintln!("fuel_stopped_conv: {} MJ", fuel_stopped_conv_mj);
1869        assert!(
1870            fuel_stopped_uhev_mj < fuel_stopped_conv_mj,
1871            "Expected stopped uHEV fuel ({fuel_stopped_uhev_mj}) to be less than stopped conventional ({fuel_stopped_conv_mj})"
1872        );
1873    }
1874
1875    #[test]
1876    fn start_stop_conv_saves_more_fuel_than_normal_conventional() {
1877        let veh_ss_result = make_conv_pacifica(true, false);
1878        assert!(veh_ss_result.is_ok());
1879        let veh_ss = veh_ss_result.unwrap();
1880        let veh_conv_result = make_conv_pacifica(false, false);
1881        assert!(veh_conv_result.is_ok());
1882        let veh_conv = veh_conv_result.unwrap();
1883        let cyc = crate::drive_cycle::Cycle::from_resource("udds.csv", false).unwrap();
1884        let mut sd_ss = crate::simdrive::SimDrive::new(veh_ss, cyc.clone(), Default::default());
1885        let result_ss = sd_ss.run();
1886        if let Err(err) = result_ss {
1887            panic!("Error: {}", err);
1888        }
1889        assert!(result_ss.is_ok());
1890        let mut sd_conv = crate::simdrive::SimDrive::new(veh_conv, cyc.clone(), Default::default());
1891        let result_conv = sd_conv.run();
1892        assert!(result_conv.is_ok());
1893        let speeds_mps: Vec<f64> = cyc
1894            .speed
1895            .iter()
1896            .map(|spd| spd.get::<si::meter_per_second>())
1897            .collect();
1898        let fc_ss = sd_ss.veh.pt_type.fc().unwrap();
1899        let fc_conv = sd_conv.veh.pt_type.fc().unwrap();
1900        let fuels_ss_mj: Vec<f64> = fc_ss
1901            .history
1902            .energy_fuel
1903            .iter()
1904            .map(|ef| ef.get_fresh(|| format_dbg!()).unwrap().get::<si::joule>() / 1e6)
1905            .collect();
1906        let fuel_ss_mj: f64 = fuels_ss_mj.iter().sum();
1907        let fuels_conv_mj: Vec<f64> = fc_conv
1908            .history
1909            .energy_fuel
1910            .iter()
1911            .map(|ef| ef.get_fresh(|| format_dbg!()).unwrap().get::<si::joule>() / 1e6)
1912            .collect();
1913        let fuel_conv_mj: f64 = fuels_conv_mj.iter().sum();
1914        assert_eq!(speeds_mps.len(), fuels_ss_mj.len());
1915        assert_eq!(speeds_mps.len(), fuels_conv_mj.len());
1916        eprintln!("fuel_ss: {} MJ", fuel_ss_mj);
1917        eprintln!("fuel_conv: {} MJ", fuel_conv_mj);
1918        assert!(
1919            fuel_ss_mj < fuel_conv_mj,
1920            "Expected ss fuel ({fuel_ss_mj}) to be less than conventional ({fuel_conv_mj})"
1921        );
1922        let fuel_stopped_ss_mj = accumulate_for_zero_speed(&speeds_mps, &fuels_ss_mj);
1923        let fuel_stopped_conv_mj = accumulate_for_zero_speed(&speeds_mps, &fuels_conv_mj);
1924        eprintln!("fuel_stopped_ss: {} MJ", fuel_stopped_ss_mj);
1925        eprintln!("fuel_stopped_conv: {} MJ", fuel_stopped_conv_mj);
1926        assert!(
1927            fuel_stopped_ss_mj < fuel_stopped_conv_mj,
1928            "Expected stopped ss fuel ({fuel_stopped_ss_mj}) to be less than stopped conventional ({fuel_stopped_conv_mj})"
1929        );
1930    }
1931
1932    #[test]
1933    fn that_use_start_stop_switches_the_conv_controller() {
1934        let veh_result = make_conv_pacifica(false, false);
1935        assert!(veh_result.is_ok());
1936        let mut veh = veh_result.unwrap();
1937        let use_result = veh.use_start_stop_controller();
1938        assert!(use_result.is_ok());
1939        match &veh.pt_type {
1940            PowertrainType::ConventionalVehicle(conv) => match conv.pt_cntrl {
1941                ConvPowertrainControls::Normal => {
1942                    assert!(false, "Powertrain controls didn't change");
1943                }
1944                _ => (),
1945            },
1946            _ => {
1947                assert!(false, "Unexpected powertrain type");
1948            }
1949        }
1950        let normal_result = veh.use_normal_controller();
1951        assert!(normal_result.is_ok());
1952        match &veh.pt_type {
1953            PowertrainType::ConventionalVehicle(conv) => match conv.pt_cntrl {
1954                ConvPowertrainControls::StartStop(_) => {
1955                    assert!(false, "Powertrain controls didn't change");
1956                }
1957                _ => (),
1958            },
1959            _ => {
1960                assert!(false, "Unexpected powertrain type");
1961            }
1962        }
1963    }
1964
1965    #[test]
1966    fn that_use_start_stop_switches_the_hev_controller() {
1967        let veh_result = make_microhybrid_pacifica();
1968        assert!(veh_result.is_ok());
1969        let mut veh = veh_result.unwrap();
1970        let use_result = veh.use_normal_controller();
1971        assert!(use_result.is_ok());
1972        match &veh.pt_type {
1973            PowertrainType::HybridElectricVehicle(hev) => match &hev.pt_cntrl {
1974                HEVPowertrainControls::StartStop(_) => {
1975                    assert!(false, "Powertrain controls didn't change");
1976                }
1977                HEVPowertrainControls::RGWDB(_) => (),
1978            },
1979            _ => {
1980                assert!(false, "Unexpected powertrain type");
1981            }
1982        }
1983        let use_ss_result = veh.use_start_stop_controller();
1984        assert!(use_ss_result.is_ok());
1985        match &veh.pt_type {
1986            PowertrainType::HybridElectricVehicle(hev) => match &hev.pt_cntrl {
1987                HEVPowertrainControls::RGWDB(_) => {
1988                    assert!(
1989                        false,
1990                        "Powertrain controls didn't change: RGWDB => StartStop"
1991                    );
1992                }
1993                HEVPowertrainControls::StartStop(_) => (),
1994            },
1995            _ => {
1996                assert!(false, "Unexpected powertrain type");
1997            }
1998        }
1999    }
2000
2001    fn sum_fuel_in_mj(fc: &FuelConverter) -> f64 {
2002        let fuels_mj: Vec<f64> = fc
2003            .history
2004            .energy_fuel
2005            .iter()
2006            .map(|ef| ef.get_fresh(|| format_dbg!()).unwrap().get::<si::joule>() / 1e6)
2007            .collect();
2008        fuels_mj.iter().sum()
2009    }
2010
2011    #[test]
2012    fn that_a_vehicle_with_dfco_enabled_uses_less_fuel() {
2013        let veh_result = make_conv_pacifica(false, false);
2014        assert!(veh_result.is_ok());
2015        let veh = veh_result.unwrap();
2016        let veh_dfco_result = make_conv_pacifica(false, true);
2017        assert!(veh_dfco_result.is_ok());
2018        let veh_dfco = veh_dfco_result.unwrap();
2019        let cyc = crate::drive_cycle::Cycle::from_resource("udds.csv", false).unwrap();
2020        let mut sd = crate::simdrive::SimDrive::new(veh, cyc.clone(), Default::default());
2021        let sd_result = sd.run();
2022        if let Err(err) = sd_result {
2023            panic!("Error: {}", err);
2024        }
2025        assert!(sd_result.is_ok());
2026        let mut sd_dfco = crate::simdrive::SimDrive::new(veh_dfco, cyc.clone(), Default::default());
2027        let sd_dfco_result = sd_dfco.run();
2028        assert!(sd_dfco_result.is_ok());
2029        let fc = sd.veh.pt_type.fc().unwrap();
2030        let fc_dfco = sd_dfco.veh.pt_type.fc().unwrap();
2031        let fuel_mj: f64 = sum_fuel_in_mj(&fc);
2032        let fuel_dfco_mj: f64 = sum_fuel_in_mj(&fc_dfco);
2033        eprintln!("fuel     : {} MJ", fuel_mj);
2034        eprintln!("fuel_dfco: {} MJ", fuel_dfco_mj);
2035        let percent_reduction = ((fuel_mj - fuel_dfco_mj) * 100.0) / fuel_mj;
2036        eprintln!("percent reduction: {}", percent_reduction);
2037        assert!(
2038            fuel_dfco_mj < fuel_mj,
2039            "Expected DFCO fuel ({fuel_dfco_mj}) to be less than conventional ({fuel_mj})"
2040        );
2041    }
2042}