Skip to main content

fastsim_core/vehicle/
hev.rs

1use super::{vehicle_model::VehicleState, *};
2use crate::{prelude::ElectricMachineState, vehicle::common::*};
3
4#[serde_api]
5#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, StateMethods, SetCumulative)]
6#[non_exhaustive]
7#[serde(deny_unknown_fields)]
8#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
9/// Hybrid vehicle with both engine and reversible energy storage (aka battery)
10/// This type of vehicle is not likely to be widely prevalent due to modularity of consists.
11pub struct HybridElectricVehicle {
12    #[has_state]
13    pub res: ReversibleEnergyStorage,
14    pub fs: FuelStorage,
15    #[has_state]
16    pub fc: FuelConverter,
17    #[has_state]
18    pub em: ElectricMachine,
19    #[has_state]
20    pub transmission: Transmission,
21    /// control strategy for distributing power demand between `fc` and `res`
22    #[has_state]
23    #[serde(default)]
24    pub pt_cntrl: HEVPowertrainControls,
25    /// control strategy for distributing aux power demand between `fc` and `res`
26    #[serde(default)]
27    pub aux_cntrl: HEVAuxControls,
28    /// hybrid powertrain mass
29    pub(crate) mass: Option<si::Mass>,
30    #[serde(default)]
31    pub sim_params: HEVSimulationParams,
32    /// vector of SOC balance iterations
33    #[serde(default)]
34    pub soc_bal_iter_history: Vec<Self>,
35    /// Number of `run` iterations required to achieve SOC balance (i.e. SOC
36    /// ends at same starting value, ensuring no net [ReversibleEnergyStorage] usage)
37    #[serde(default)]
38    pub soc_bal_iters: TrackedState<u32>,
39}
40
41impl HybridElectricVehicle {
42    /// This method should be called after initialization but prior to
43    /// simulation start. It checks that the buffer parameters are reasonable as
44    /// compared to RES capacity and the like. Note: currently, this routine
45    /// doesn't panic -- only writes to stderr if it detects an issue.
46    pub fn check_buffers(&self, veh_mass: si::Mass) -> anyhow::Result<()> {
47        // CHECK BUFFER PARAMETERS ARE REALISTIC
48        let (disch_buffer, chrg_buffer, fc_on_soc) = match &self.pt_cntrl {
49            HEVPowertrainControls::RGWDB(rgwdb) => {
50                let disch_buffer = (0.5
51                    * veh_mass
52                    * rgwdb
53                        .speed_soc_disch_buffer
54                        .with_context(|| format_dbg!())?
55                        .powi(P2::new()))
56                .max(si::Energy::ZERO)
57                    * rgwdb
58                        .speed_soc_disch_buffer_coeff
59                        .with_context(|| format_dbg!())?;
60
61                let chrg_buffer = (0.5
62                    * veh_mass
63                    * ((70.0 * uc::MPH).powi(P2::new())
64                        - rgwdb
65                            .speed_soc_regen_buffer
66                            .with_context(|| format_dbg!())?
67                            .powi(P2::new())))
68                .max(si::Energy::ZERO)
69                    * rgwdb
70                        .speed_soc_regen_buffer_coeff
71                        .with_context(|| format_dbg!())?;
72
73                let fc_on_soc = {
74                    let energy_delta_to_buffer_speed: si::Energy = 0.5
75                        * veh_mass
76                        * rgwdb
77                            .speed_soc_fc_on_buffer
78                            .with_context(|| format_dbg!())?
79                            .powi(P2::new());
80                    energy_delta_to_buffer_speed.max(si::Energy::ZERO)
81                        * rgwdb
82                            .speed_soc_fc_on_buffer_coeff
83                            .with_context(|| format_dbg!())?
84                } / self.res.energy_capacity_usable()
85                    + self.res.min_soc;
86
87                (disch_buffer, chrg_buffer, fc_on_soc)
88            }
89            HEVPowertrainControls::StartStop(_) => {
90                let fc_on_soc = 0.10 * (self.res.max_soc - self.res.min_soc) + self.res.min_soc;
91                let chrg_buffer = self.res.energy_capacity_usable();
92                let disch_buffer = self.res.energy_capacity_usable();
93                (disch_buffer, chrg_buffer, fc_on_soc)
94            }
95        };
96        if fc_on_soc > self.res.max_soc {
97            // eprintln!("fc_on_soc > self.res.max_soc");
98            // eprintln!("fc_on_soc: {:?}", fc_on_soc);
99        }
100        if fc_on_soc < self.res.min_soc {
101            // eprintln!("fc_on_soc < self.res.min_soc");
102            // eprintln!("fc_on_soc: {:?}", fc_on_soc);
103        }
104        if disch_buffer > self.res.energy_capacity_usable() {
105            // eprintln!("disch_buffer > self.res.energy_capacity_usable()");
106            // eprintln!(
107            //     "disch_buffer: {:?} kWh",
108            //     disch_buffer.get::<si::kilowatt_hour>()
109            // );
110            // eprintln!(
111            //     "RES usable energy capacity: {:?} kWh",
112            //     self.res.energy_capacity_usable().get::<si::kilowatt_hour>()
113            // );
114        }
115        if chrg_buffer > self.res.energy_capacity_usable() {
116            // eprintln!("chrg_buffer > self.res.energy_capacity_usable()");
117            // eprintln!(
118            //     "chrg_buffer: {:?} kWh",
119            //     chrg_buffer.get::<si::kilowatt_hour>()
120            // );
121            // eprintln!(
122            //     "RES usable energy capacity: {:?} kWh",
123            //     self.res.energy_capacity_usable().get::<si::kilowatt_hour>()
124            // );
125        }
126        Ok(())
127    }
128}
129
130#[pyo3_api]
131impl HybridElectricVehicle {}
132
133impl HybridElectricVehicle {
134    pub fn new(
135        res: ReversibleEnergyStorage,
136        fs: FuelStorage,
137        fc: FuelConverter,
138        em: ElectricMachine,
139        transmission: Transmission,
140        pt_cntrl: HEVPowertrainControls,
141        aux_cntrl: HEVAuxControls,
142        mass: Option<si::Mass>,
143        sim_params: HEVSimulationParams,
144    ) -> anyhow::Result<Self> {
145        let mut hev = Self {
146            res,
147            fs,
148            fc,
149            em,
150            transmission,
151            pt_cntrl,
152            aux_cntrl,
153            mass,
154            sim_params,
155            soc_bal_iter_history: Default::default(),
156            soc_bal_iters: Default::default(),
157        };
158        hev.init()?;
159        Ok(hev)
160    }
161}
162
163impl HistoryMethods for HybridElectricVehicle {
164    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
165        bail!("`save_interval` is not implemented in HybridElectricVehicle")
166    }
167    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
168        self.res.set_save_interval(save_interval)?;
169        // self.fs.set_save_interval(save_interval)?;
170        self.fc.set_save_interval(save_interval)?;
171        self.em.set_save_interval(save_interval)?;
172        self.transmission.set_save_interval(save_interval)?;
173        self.pt_cntrl.set_save_interval(save_interval)?;
174        Ok(())
175    }
176    fn clear(&mut self) {
177        self.res.clear();
178        // self.fs.clear();
179        self.fc.clear();
180        self.em.clear();
181        self.transmission.clear();
182        self.pt_cntrl.clear();
183    }
184}
185
186impl Init for HybridElectricVehicle {
187    fn init(&mut self) -> Result<(), Error> {
188        self.fc
189            .init()
190            .map_err(|err| Error::InitError(format_dbg!(err)))?;
191        self.res
192            .init()
193            .map_err(|err| Error::InitError(format_dbg!(err)))?;
194        self.em
195            .init()
196            .map_err(|err| Error::InitError(format_dbg!(err)))?;
197        self.transmission
198            .init()
199            .map_err(|err| Error::InitError(format_dbg!(err)))?;
200        self.pt_cntrl
201            .init()
202            .map_err(|err| Error::InitError(format_dbg!(err)))?;
203        Ok(())
204    }
205}
206
207impl SerdeAPI for HybridElectricVehicle {}
208
209impl Powertrain for Box<HybridElectricVehicle> {
210    fn set_curr_pwr_prop_out_max(
211        &mut self,
212        _pwr_upstream: (si::Power, si::Power),
213        pwr_aux: si::Power,
214        dt: si::Time,
215        veh_state: &VehicleState,
216    ) -> anyhow::Result<()> {
217        // TODO: account for transmission efficiency in here
218        let (disch_buffer, chrg_buffer) = match &mut self.pt_cntrl {
219            HEVPowertrainControls::RGWDB(rgwdb) => {
220                rgwdb.handle_fc_on_causes(&self.fc, veh_state, &self.res, &self.em.state)?;
221
222                let disch_buffer = (0.5
223                    * *veh_state.mass.get_fresh(|| format_dbg!())?
224                    * (rgwdb
225                        .speed_soc_disch_buffer
226                        .with_context(|| format_dbg!())?
227                        .powi(P2::new())
228                        - veh_state
229                            .speed_ach
230                            .get_stale(|| format_dbg!())?
231                            .powi(P2::new())))
232                .max(si::Energy::ZERO)
233                    * rgwdb
234                        .speed_soc_disch_buffer_coeff
235                        .with_context(|| format_dbg!())?;
236
237                let chrg_buffer = (0.5
238                    * *veh_state.mass.get_fresh(|| format_dbg!())?
239                    * (veh_state
240                        .speed_ach
241                        .get_stale(|| format_dbg!())?
242                        .powi(P2::new())
243                        - rgwdb
244                            .speed_soc_regen_buffer
245                            .with_context(|| format_dbg!())?
246                            .powi(P2::new())))
247                .max(si::Energy::ZERO)
248                    * rgwdb
249                        .speed_soc_regen_buffer_coeff
250                        .with_context(|| format_dbg!())?;
251
252                (disch_buffer, chrg_buffer)
253            }
254            HEVPowertrainControls::StartStop(cntrl) => {
255                cntrl.handle_fc_on_causes(&self.fc, veh_state, &self.res, dt)?;
256
257                let disch_buffer = 0.0 * uc::J;
258                let chrg_buffer = self.res.energy_capacity_usable();
259                (disch_buffer, chrg_buffer)
260            }
261        };
262        // set total max powers, including aux power
263        self.fc
264            .set_curr_pwr_out_max(dt)
265            .with_context(|| anyhow!(format_dbg!()))?;
266        self.res
267            .set_curr_pwr_out_max(dt, disch_buffer, chrg_buffer)
268            .with_context(|| anyhow!(format_dbg!()))?;
269
270        // determine distribution of aux power between engine and battery
271        let (pwr_aux_res, pwr_aux_fc) = {
272            match self.aux_cntrl {
273                HEVAuxControls::AuxOnResPriority => {
274                    if pwr_aux <= *self.res.state.pwr_disch_max.get_fresh(|| format_dbg!())? {
275                        (pwr_aux, si::Power::ZERO)
276                    } else {
277                        (si::Power::ZERO, pwr_aux)
278                    }
279                }
280                HEVAuxControls::AuxOnFcPriority => (si::Power::ZERO, pwr_aux),
281            }
282        };
283
284        match &mut self.pt_cntrl {
285            HEVPowertrainControls::RGWDB(rgwdb) => {
286                rgwdb
287                    .state
288                    .aux_power_demand
289                    .update(pwr_aux_fc > si::Power::ZERO, || format_dbg!())?;
290            }
291            HEVPowertrainControls::StartStop(cntrl) => {
292                cntrl
293                    .state
294                    .aux_power_demand
295                    .update(pwr_aux_fc > si::Power::ZERO, || format_dbg!())?;
296            }
297        }
298
299        // set max propulsion powers
300        self.fc
301            .set_curr_pwr_prop_max(pwr_aux_fc)
302            .with_context(|| anyhow!(format_dbg!()))?;
303        self.res
304            .set_curr_pwr_prop_max(pwr_aux_res)
305            .with_context(|| anyhow!(format_dbg!()))?;
306        self.em
307            .set_curr_pwr_prop_out_max(
308                // TODO: add means of controlling whether fc can provide power to em and also how much
309                // Try out a 'power out type' enum field on the fuel converter with variants for mechanical and electrical
310                self.res
311                    .get_curr_pwr_prop_out_max()
312                    .with_context(|| format_dbg!())?,
313                pwr_aux,
314                dt,
315                veh_state,
316            )
317            .with_context(|| anyhow!(format_dbg!()))?;
318        let em_pwr_prop_out_maxes = self
319            .em
320            .get_curr_pwr_prop_out_max()
321            .with_context(|| format_dbg!())?;
322        let fc_max = self.fc.state.pwr_prop_max.get_fresh(|| format_dbg!())?;
323        self.transmission
324            .set_curr_pwr_prop_out_max(
325                (em_pwr_prop_out_maxes.0 + *fc_max, em_pwr_prop_out_maxes.1),
326                f64::NAN * uc::W,
327                dt,
328                veh_state,
329            )
330            .with_context(|| format_dbg!())?;
331        Ok(())
332    }
333
334    fn get_curr_pwr_prop_out_max(&self) -> anyhow::Result<(si::Power, si::Power)> {
335        self.transmission
336            .get_curr_pwr_prop_out_max()
337            .with_context(|| format_dbg!())
338    }
339
340    fn solve(
341        &mut self,
342        pwr_out_req: si::Power,
343        _enabled: bool,
344        dt: si::Time,
345    ) -> anyhow::Result<Option<si::Power>> {
346        // TODO: address these concerns
347        // - what happens when the fc is on and producing more power than the
348        //   transmission requires? It seems like the excess goes straight to the battery,
349        //   but it should probably go through the em somehow.
350        let pwr_in_transmission = self
351            .transmission
352            .solve(pwr_out_req, true, dt)
353            .with_context(|| format_dbg!())?
354            .with_context(|| format!("{}\nExpected `Some`", format_dbg!()))?;
355
356        // TODO: use an enum with a match here to determine whether power is shared by
357        // - fc and em (e.g. for ICE HEV)
358        //   or
359        // - fc and res (e.g. for H2FC HEV)
360
361        let (fc_pwr_out_req, em_pwr_out_req) = self
362            .pt_cntrl
363            .get_pwr_fc_and_em(pwr_in_transmission, &self.fc, &self.em.state, &self.res)
364            .with_context(|| format_dbg!())?;
365        let fc_on: bool = self.pt_cntrl.fc_on().map_err(|err| {
366            anyhow::anyhow!(
367                "self.pt_cntrl.fc_on() failed at line {} with \
368                originating error [{}]",
369                format_dbg!(),
370                err
371            )
372        })?;
373
374        self.fc.solve(fc_pwr_out_req, fc_on, dt).map_err(|err| {
375            anyhow::anyhow!(
376                "self.fc.solve(fc_pwr_out_req, fc_on, dt) with values: \
377                    fc_pwr_out_req={:?}, fc_on={}, dt={:?} \
378                    failed at line {} \
379                    with originating error [{}]",
380                fc_pwr_out_req,
381                fc_on,
382                dt,
383                format_dbg!(),
384                err
385            )
386        })?;
387
388        let res_pwr_out_req = self
389            .em
390            .solve(em_pwr_out_req, true, dt)
391            .map_err(|err| {
392                anyhow!(format!(
393                    "em.solve failed at line {} with originating error [{}]",
394                    format_dbg!(),
395                    err
396                ))
397            })?
398            .with_context(|| format!("{}\nExpected `Some`", format_dbg!()))?;
399        // TODO: `res_pwr_out_req` probably does not include charging from the engine
400        self.res.solve(res_pwr_out_req, dt).map_err(|err| {
401            anyhow!(format!(
402                "res.solve failed at line {} with originating error [{}]",
403                format_dbg!(),
404                err
405            ))
406        })?;
407        Ok(None)
408    }
409
410    /// Regen braking power, positive means braking is happening
411    fn pwr_regen(&self) -> anyhow::Result<si::Power> {
412        // When `pwr_mech_prop_out` is negative, regen is happening.  First, clip it at 0, and then negate it.
413        // see https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=e8f7af5a6e436dd1163fa3c70931d18d
414        // for example
415        self.transmission.pwr_regen().with_context(|| format_dbg!())
416    }
417}
418
419impl HybridElectricVehicle {
420    /// # Arguments
421    /// - `te_amb`: ambient temperature
422    /// - `pwr_thrml_fc_to_cab`: thermal power flow from [FuelConverter::thrml]
423    ///   to [Vehicle::cabin], if cabin is equipped
424    /// - `veh_state`: current [VehicleState]
425    /// - `pwr_thrml_hvac_to_res`: thermal power flow from [Vehicle::hvac] --
426    ///   zero if `None` is passed
427    /// - `te_cab`: cabin temperature, required if [ReversibleEnergyStorage::thrml] is `Some`
428    /// - `dt`: simulation time step size
429    pub fn solve_thermal(
430        &mut self,
431        te_amb: si::Temperature,
432        pwr_thrml_fc_to_cab: Option<si::Power>,
433        veh_state: &mut VehicleState,
434        pwr_thrml_hvac_to_res: Option<si::Power>,
435        te_cab: Option<si::Temperature>,
436        dt: si::Time,
437    ) -> anyhow::Result<()> {
438        self.fc
439            .solve_thermal(te_amb, pwr_thrml_fc_to_cab, veh_state, dt)
440            .with_context(|| format_dbg!())?;
441        self.res
442            .solve_thermal(
443                te_amb,
444                pwr_thrml_hvac_to_res.unwrap_or_default(),
445                te_cab,
446                dt,
447            )
448            .with_context(|| format_dbg!())?;
449        Ok(())
450    }
451}
452
453impl Mass for HybridElectricVehicle {
454    fn mass(&self) -> anyhow::Result<Option<si::Mass>> {
455        let derived_mass = self
456            .derived_mass()
457            .with_context(|| anyhow!(format_dbg!()))?;
458        match (derived_mass, self.mass) {
459            (Some(derived_mass), Some(set_mass)) => {
460                ensure!(
461                    utils::almost_eq_uom(&set_mass, &derived_mass, None),
462                    format!(
463                        "{}",
464                        format_dbg!(utils::almost_eq_uom(&set_mass, &derived_mass, None)),
465                    )
466                );
467                Ok(Some(set_mass))
468            }
469            _ => Ok(self.mass.or(derived_mass)),
470        }
471    }
472
473    fn set_mass(
474        &mut self,
475        new_mass: Option<si::Mass>,
476        side_effect: MassSideEffect,
477    ) -> anyhow::Result<()> {
478        ensure!(
479            side_effect == MassSideEffect::None,
480            "At the powertrain level, only `MassSideEffect::None` is allowed"
481        );
482        let derived_mass = self
483            .derived_mass()
484            .with_context(|| anyhow!(format_dbg!()))?;
485        self.mass = match (new_mass, derived_mass) {
486            // Set using provided `new_mass`, setting constituent mass fields to `None` to match if inconsistent
487            (Some(new_mass), Some(dm)) => {
488                if dm != new_mass {
489                    self.expunge_mass_fields();
490                }
491                Some(new_mass)
492            }
493            (Some(new_mass), None) => Some(new_mass),
494            (None, Some(dm)) => Some(dm),
495            (None, None) => bail!(
496                "Not all mass fields in `{}` are set and no mass was provided.",
497                stringify!(HybridElectricVehicle)
498            ),
499        };
500        ensure!(
501            self.mass > Some(0.0 * uc::KG),
502            "{} mass must be positive",
503            stringify!(HybridElectricVehicle)
504        );
505        Ok(())
506    }
507
508    fn derived_mass(&self) -> anyhow::Result<Option<si::Mass>> {
509        let fc_mass = self.fc.mass().with_context(|| anyhow!(format_dbg!()))?;
510        let fs_mass = self.fs.mass().with_context(|| anyhow!(format_dbg!()))?;
511        let res_mass = self.res.mass().with_context(|| anyhow!(format_dbg!()))?;
512        let em_mass = self.em.mass().with_context(|| anyhow!(format_dbg!()))?;
513        let transmission_mass = self
514            .transmission
515            .mass()
516            .with_context(|| anyhow!(format_dbg!()))?;
517        match (fc_mass, fs_mass, res_mass, em_mass, transmission_mass) {
518            (
519                Some(fc_mass),
520                Some(fs_mass),
521                Some(res_mass),
522                Some(em_mass),
523                Some(transmission_mass),
524            ) => Ok(Some(
525                fc_mass + fs_mass + res_mass + em_mass + transmission_mass,
526            )),
527            (None, None, None, None, None) => Ok(None),
528            _ => bail!(
529                "`{}` field masses are not consistently set to `Some` or `None`",
530                stringify!(HybridElectricVehicle)
531            ),
532        }
533    }
534
535    fn expunge_mass_fields(&mut self) {
536        self.fc.expunge_mass_fields();
537        self.fs.expunge_mass_fields();
538        self.res.expunge_mass_fields();
539        self.em.expunge_mass_fields();
540        self.transmission.expunge_mass_fields();
541        self.mass = None;
542    }
543}
544
545#[serde_api]
546#[derive(
547    Clone,
548    Debug,
549    Default,
550    Deserialize,
551    Serialize,
552    PartialEq,
553    HistoryVec,
554    StateMethods,
555    SetCumulative,
556)]
557#[non_exhaustive]
558#[serde(deny_unknown_fields)]
559pub struct RGWDBState {
560    /// time step index
561    pub i: TrackedState<usize>,
562    /// Engine must be on to self heat if thermal model is enabled
563    pub fc_temperature_too_low: TrackedState<bool>,
564    /// Engine must be on for high vehicle speed to ensure powertrain can meet
565    /// any spikes in power demand
566    pub vehicle_speed_too_high: TrackedState<bool>,
567    /// Engine has not been on long enough (usually 30 s)
568    pub on_time_too_short: TrackedState<bool>,
569    /// Powertrain power demand exceeds motor and/or battery capabilities
570    pub propulsion_power_demand: TrackedState<bool>,
571    /// Powertrain power demand exceeds optimal motor and/or battery output
572    pub propulsion_power_demand_soft: TrackedState<bool>,
573    /// Aux power demand exceeds battery capability
574    pub aux_power_demand: TrackedState<bool>,
575    /// SOC is below min buffer so FC is charging RES
576    pub charging_for_low_soc: TrackedState<bool>,
577    /// buffer at which FC is forced on
578    pub soc_fc_on_buffer: TrackedState<si::Ratio>,
579}
580impl SerdeAPI for RGWDBState {}
581impl Init for RGWDBState {}
582
583impl RGWDBState {
584    /// If any of the causes are true, engine must be on
585    fn fc_on(&self) -> anyhow::Result<bool> {
586        Ok(*self.fc_temperature_too_low.get_fresh(|| format_dbg!())?
587            || *self.vehicle_speed_too_high.get_fresh(|| format_dbg!())?
588            || *self.on_time_too_short.get_fresh(|| format_dbg!())?
589            || *self.propulsion_power_demand.get_fresh(|| format_dbg!())?
590            || *self
591                .propulsion_power_demand_soft
592                .get_fresh(|| format_dbg!())?
593            || *self.aux_power_demand.get_fresh(|| format_dbg!())?
594            || *self.charging_for_low_soc.get_fresh(|| format_dbg!())?)
595    }
596}
597
598/// Options for controlling simulation behavior
599#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
600#[non_exhaustive]
601#[serde(deny_unknown_fields)]
602pub struct HEVSimulationParams {
603    /// [ReversibleEnergyStorage] per [FuelConverter]
604    pub res_per_fuel_lim: si::Ratio,
605    /// Threshold of SOC balancing iteration for triggering error
606    pub soc_balance_iter_err: u32,
607    /// Whether to allow iteration to achieve SOC balance
608    pub balance_soc: bool,
609    /// Whether to save each SOC balance iteration    
610    pub save_soc_bal_iters: bool,
611}
612
613impl HEVSimulationParams {
614    pub fn new(
615        res_per_fuel_lim: si::Ratio,
616        soc_balance_iter_err: u32,
617        balance_soc: bool,
618        save_soc_bal_iters: bool,
619    ) -> anyhow::Result<Self> {
620        Ok(Self {
621            res_per_fuel_lim,
622            soc_balance_iter_err,
623            balance_soc,
624            save_soc_bal_iters,
625        })
626    }
627}
628
629impl Default for HEVSimulationParams {
630    fn default() -> Self {
631        Self {
632            res_per_fuel_lim: uc::R * 0.005,
633            soc_balance_iter_err: 5,
634            balance_soc: true,
635            save_soc_bal_iters: false,
636        }
637    }
638}
639
640#[derive(
641    Clone, Debug, PartialEq, Deserialize, Serialize, Default, IsVariant, derive_more::From, TryInto,
642)]
643pub enum HEVAuxControls {
644    /// If feasible, use [ReversibleEnergyStorage] to handle aux power demand
645    #[default]
646    AuxOnResPriority,
647    /// If feasible, use [FuelConverter] to handle aux power demand
648    AuxOnFcPriority,
649}
650
651#[derive(
652    Clone, Debug, PartialEq, Deserialize, Serialize, IsVariant, derive_more::From, TryInto,
653)]
654pub enum HEVPowertrainControls {
655    /// Greedily uses [ReversibleEnergyStorage] with buffers that derate charge
656    /// and discharge power inside of static min and max SOC range.  Also, includes
657    /// buffer for forcing [FuelConverter] to be active/on.
658    RGWDB(Box<RESGreedyWithDynamicBuffers>),
659    /// Uses the [ReversibleEnergyStorage] only for supplying auxiliary power.
660    /// Also, includes logic for when the [FuelConverter] must be on.
661    #[serde(alias = "StopStart")]
662    StartStop(Box<HEVStartStopControl>),
663}
664
665impl Default for HEVPowertrainControls {
666    fn default() -> Self {
667        Self::RGWDB(Default::default())
668    }
669}
670
671impl SetCumulative for HEVPowertrainControls {
672    fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
673        match self {
674            Self::RGWDB(rgwdb) => {
675                rgwdb.set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?
676            }
677            Self::StartStop(cntrl) => {
678                cntrl.set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?
679            }
680        }
681        Ok(())
682    }
683
684    fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
685        match self {
686            Self::RGWDB(rgwdb) => {
687                rgwdb.reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?
688            }
689            Self::StartStop(cntrl) => {
690                cntrl.reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?
691            }
692        }
693        Ok(())
694    }
695}
696impl Step for HEVPowertrainControls {
697    fn step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
698        match self {
699            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.step(loc)?,
700            HEVPowertrainControls::StartStop(cntrls) => cntrls.step(loc)?,
701        }
702        Ok(())
703    }
704
705    fn reset_step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
706        match self {
707            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.reset_step(loc)?,
708            HEVPowertrainControls::StartStop(cntrls) => cntrls.reset_step(loc)?,
709        }
710        Ok(())
711    }
712}
713
714impl StateMethods for HEVPowertrainControls {}
715
716impl SaveState for HEVPowertrainControls {
717    fn save_state<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
718        match self {
719            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.save_state(loc)?,
720            HEVPowertrainControls::StartStop(cntrl) => cntrl.save_state(loc)?,
721        }
722        Ok(())
723    }
724}
725impl TrackedStateMethods for HEVPowertrainControls {
726    fn check_and_reset<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
727        match self {
728            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.check_and_reset(loc)?,
729            HEVPowertrainControls::StartStop(cntrl) => cntrl.check_and_reset(loc)?,
730        }
731        Ok(())
732    }
733
734    fn mark_fresh<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
735        match self {
736            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.mark_fresh(loc)?,
737            HEVPowertrainControls::StartStop(cntrl) => cntrl.mark_fresh(loc)?,
738        }
739        Ok(())
740    }
741}
742impl HistoryMethods for HEVPowertrainControls {
743    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
744        match self {
745            HEVPowertrainControls::RGWDB(rgwdb) => Ok(rgwdb.set_save_interval(save_interval)?),
746            HEVPowertrainControls::StartStop(cntrl) => Ok(cntrl.set_save_interval(save_interval)?),
747        }
748    }
749
750    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
751        match self {
752            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.save_interval(),
753            HEVPowertrainControls::StartStop(cntrl) => cntrl.save_interval(),
754        }
755    }
756    fn clear(&mut self) {
757        match self {
758            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.clear(),
759            HEVPowertrainControls::StartStop(cntrl) => cntrl.clear(),
760        }
761    }
762}
763
764impl Init for HEVPowertrainControls {
765    fn init(&mut self) -> Result<(), Error> {
766        match self {
767            Self::RGWDB(rgwb) => rgwb.init()?,
768            Self::StartStop(cntrl) => cntrl.init()?,
769        }
770        Ok(())
771    }
772}
773
774impl HEVPowertrainControls {
775    /// Determines power split between engine and electric machine
776    ///
777    /// # Arguments
778    /// - `pwr_prop_req`: tractive power required
779    /// - `veh_state`: vehicle state
780    /// - `hev_state`: HEV powertrain state
781    /// - `fc`: fuel converter
782    /// - `em_state`: electric machine state
783    /// - `res`: reversible energy storage (e.g. high voltage battery)
784    fn get_pwr_fc_and_em(
785        &mut self,
786        pwr_prop_req: si::Power,
787        fc: &FuelConverter,
788        em_state: &ElectricMachineState,
789        res: &ReversibleEnergyStorage,
790    ) -> anyhow::Result<(si::Power, si::Power)> {
791        let fc_state = &fc.state;
792        ensure!(
793            // `almost` is in case of negligible numerical precision discrepancies
794            almost_le_uom(
795                &pwr_prop_req,
796                &(*em_state.pwr_mech_fwd_out_max.get_fresh(|| format_dbg!())?
797                    + *fc_state.pwr_prop_max.get_fresh(|| format_dbg!())?),
798                None
799            ),
800            "{}
801`pwr_out_req`: {} kW
802`em_state.pwr_mech_fwd_out_max`: {} kW
803`fc_state.pwr_prop_max`: {} kW
804`res.state.soc`: {}",
805            format_dbg!(),
806            pwr_prop_req.get::<si::kilowatt>(),
807            em_state
808                .pwr_mech_fwd_out_max
809                .get_fresh(|| format_dbg!())?
810                .get::<si::kilowatt>(),
811            fc_state
812                .pwr_prop_max
813                .get_fresh(|| format_dbg!())?
814                .get::<si::kilowatt>(),
815            res.state
816                .soc
817                .get_fresh(|| format_dbg!())?
818                .get::<si::ratio>()
819        );
820
821        // # Brain dump for thermal stuff
822        // TODO: engine on/off w.r.t. thermal stuff should not come into play
823        // if there is no component (e.g. cabin) demanding heat from the engine.  My 2019
824        // Hyundai Ioniq will turn the engine off if there is no heat demand regardless of
825        // the coolant temperature
826        // TODO: make sure idle fuel gets converted to heat correctly
827
828        match self {
829            Self::RGWDB(rgwdb) => rgwdb.get_pwr_fc_and_em(fc, pwr_prop_req, em_state),
830            Self::StartStop(cntrl) => cntrl.get_pwr_fc_and_em(fc, pwr_prop_req, em_state),
831        }
832    }
833
834    pub fn fc_on(&self) -> anyhow::Result<bool> {
835        match self {
836            Self::RGWDB(rgwdb) => rgwdb.state.fc_on(),
837            Self::StartStop(cntrl) => cntrl.state.fc_on(),
838        }
839    }
840
841    pub fn handle_fc_on_causes_for_speed(&mut self, speed: si::Velocity) -> anyhow::Result<()> {
842        match self {
843            Self::StartStop(cntrl) => HEVStartStopControl::handle_fc_on_causes_for_speed(
844                &mut cntrl.state.vehicle_not_stopped,
845                speed,
846                cntrl.stopped_speed_threshold,
847            )?,
848            _ => (),
849        }
850        Ok(())
851    }
852}
853
854/// Greedily uses [ReversibleEnergyStorage] with buffers that derate charge
855/// and discharge power inside of static min and max SOC range.  Also, includes
856/// buffer for forcing [FuelConverter] to be active/on. See [Self::init] for
857/// default values.
858#[serde_api]
859#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default, StateMethods, SetCumulative)]
860#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
861#[non_exhaustive]
862#[serde(deny_unknown_fields)]
863pub struct RESGreedyWithDynamicBuffers {
864    /// RES energy delta from minimum SOC corresponding to kinetic energy of
865    /// vehicle at this speed that triggers ramp down in RES discharge.
866    pub speed_soc_disch_buffer: Option<si::Velocity>,
867    /// Coefficient for modifying amount of accel buffer
868    pub speed_soc_disch_buffer_coeff: Option<si::Ratio>,
869    /// RES energy delta from minimum SOC corresponding to kinetic energy of
870    /// vehicle at this speed that triggers FC to be forced on.
871    pub speed_soc_fc_on_buffer: Option<si::Velocity>,
872    /// Coefficient for modifying amount of [Self::speed_soc_fc_on_buffer]
873    pub speed_soc_fc_on_buffer_coeff: Option<si::Ratio>,
874    /// RES energy delta from maximum SOC corresponding to kinetic energy of
875    /// vehicle at current speed minus kinetic energy of vehicle at this speed
876    /// triggers ramp down in RES discharge
877    pub speed_soc_regen_buffer: Option<si::Velocity>,
878    /// Coefficient for modifying amount of regen buffer
879    pub speed_soc_regen_buffer_coeff: Option<si::Ratio>,
880    /// Minimum time engine must remain on if it was on during the previous
881    /// simulation time step.
882    pub fc_min_time_on: Option<si::Time>,
883    /// Speed at which [FuelConverter] is forced on.
884    pub speed_fc_forced_on: Option<si::Velocity>,
885    /// Fraction of total aux and powertrain rated power at which
886    /// [FuelConverter] is forced on.
887    pub frac_pwr_demand_fc_forced_on: Option<si::Ratio>,
888    /// Force engine, if on, to run at this fraction of power at which peak
889    /// efficiency occurs or the required power, whichever is greater. If SOC is
890    /// below min buffer or engine is otherwise forced on and battery has room
891    /// to receive charge, engine will run at this level and charge.
892    pub frac_of_most_eff_pwr_to_run_fc: Option<si::Ratio>,
893    /// Fraction of available charging capacity to use toward running the engine
894    /// efficiently.
895    /// Time step interval between saves. 1 is a good option. If None, no saving occurs.
896    pub save_interval: Option<usize>,
897    /// temperature at which engine is forced on to warm up
898    #[serde(default)]
899    pub temp_fc_forced_on: Option<si::Temperature>,
900    /// temperature at which engine is allowed to turn off due to being sufficiently warm
901    #[serde(default)]
902    pub temp_fc_allowed_off: Option<si::Temperature>,
903    /// current state of control variables
904    #[serde(default)]
905    pub state: RGWDBState,
906    /// history of current state
907    #[serde(default, skip_serializing_if = "RGWDBStateHistoryVec::is_empty")]
908    pub history: RGWDBStateHistoryVec,
909}
910
911#[pyo3_api]
912impl RESGreedyWithDynamicBuffers {}
913
914impl RESGreedyWithDynamicBuffers {
915    pub fn new(
916        speed_soc_disch_buffer: Option<si::Velocity>,
917        speed_soc_disch_buffer_coeff: Option<si::Ratio>,
918        speed_soc_fc_on_buffer: Option<si::Velocity>,
919        speed_soc_fc_on_buffer_coeff: Option<si::Ratio>,
920        speed_soc_regen_buffer: Option<si::Velocity>,
921        speed_soc_regen_buffer_coeff: Option<si::Ratio>,
922        fc_min_time_on: Option<si::Time>,
923        speed_fc_forced_on: Option<si::Velocity>,
924        frac_pwr_demand_fc_forced_on: Option<si::Ratio>,
925        frac_of_most_eff_pwr_to_run_fc: Option<si::Ratio>,
926        temp_fc_forced_on: Option<si::Temperature>,
927        temp_fc_allowed_off: Option<si::Temperature>,
928        save_interval: Option<usize>,
929    ) -> anyhow::Result<Self> {
930        let mut res_greedy_w_dynamic_buffers = Self {
931            speed_soc_disch_buffer,
932            speed_soc_disch_buffer_coeff,
933            speed_soc_fc_on_buffer,
934            speed_soc_fc_on_buffer_coeff,
935            speed_soc_regen_buffer,
936            speed_soc_regen_buffer_coeff,
937            fc_min_time_on,
938            speed_fc_forced_on,
939            frac_pwr_demand_fc_forced_on,
940            frac_of_most_eff_pwr_to_run_fc,
941            temp_fc_forced_on,
942            temp_fc_allowed_off,
943            state: RGWDBState::default(),
944            history: RGWDBStateHistoryVec::default(),
945            save_interval,
946        };
947        res_greedy_w_dynamic_buffers.init()?;
948        Ok(res_greedy_w_dynamic_buffers)
949    }
950}
951
952impl HistoryMethods for RESGreedyWithDynamicBuffers {
953    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
954        self.save_interval = save_interval;
955        Ok(())
956    }
957
958    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
959        Ok(self.save_interval)
960    }
961
962    fn clear(&mut self) {
963        self.history.clear();
964    }
965}
966
967impl Init for RESGreedyWithDynamicBuffers {
968    fn init(&mut self) -> Result<(), Error> {
969        // TODO: make sure these values propagate to the documented defaults above
970        init_opt_default!(self, speed_soc_disch_buffer, 50.0 * uc::MPH);
971        init_opt_default!(self, speed_soc_disch_buffer_coeff, 1.0 * uc::R);
972        init_opt_default!(
973            self,
974            speed_soc_fc_on_buffer,
975            self.speed_soc_disch_buffer.unwrap() * 1.2
976        );
977        init_opt_default!(self, speed_soc_fc_on_buffer_coeff, 1.0 * uc::R);
978        init_opt_default!(self, speed_soc_regen_buffer, 30. * uc::MPH);
979        init_opt_default!(self, speed_soc_regen_buffer_coeff, 1.0 * uc::R);
980        init_opt_default!(self, fc_min_time_on, uc::S * 5.0);
981        init_opt_default!(self, speed_fc_forced_on, uc::MPH * 75.);
982        init_opt_default!(self, frac_pwr_demand_fc_forced_on, uc::R * 0.75);
983        init_opt_default!(self, frac_of_most_eff_pwr_to_run_fc, 1.0 * uc::R);
984        Ok(())
985    }
986}
987impl SerdeAPI for RESGreedyWithDynamicBuffers {}
988
989impl RESGreedyWithDynamicBuffers {
990    fn get_pwr_fc_and_em(
991        &mut self,
992        fc: &FuelConverter,
993        pwr_prop_req: si::Power,
994        em_state: &ElectricMachineState,
995    ) -> anyhow::Result<(si::Power, si::Power)> {
996        // Tractive power `em` must provide before deciding power
997        // split, cannot exceed ElectricMachine max output power.
998        // Excess demand will be handled by `fc`.  Favors drawing power from
999        // `em` before engine
1000        let em_pwr = pwr_prop_req
1001            .min(*em_state.pwr_mech_fwd_out_max.get_fresh(|| format_dbg!())?)
1002            .max(-*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?);
1003        // tractive power handled by fc
1004        let (fc_pwr, em_pwr) = if !self.state.fc_on()? {
1005            // engine is off, and `em_pwr` has already been limited within bounds
1006            (si::Power::ZERO, em_pwr)
1007        } else {
1008            // engine has been forced on
1009            let frac_of_pwr_for_peak_eff: si::Ratio = self
1010                .frac_of_most_eff_pwr_to_run_fc
1011                .with_context(|| format_dbg!())?;
1012            let fc_pwr = if pwr_prop_req < si::Power::ZERO {
1013                // negative tractive power
1014                // max power system can receive from engine during negative traction
1015                (*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())? + pwr_prop_req)
1016                    // or peak efficiency power if it's lower than above
1017                    .min(fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff)
1018                    // but not negative
1019                    .max(si::Power::ZERO)
1020            } else {
1021                // positive tractive power
1022                if pwr_prop_req - em_pwr > fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff {
1023                    // engine needs to run higher than peak efficiency point
1024                    pwr_prop_req - em_pwr
1025                } else {
1026                    // engine does not need to run higher than peak
1027                    // efficiency point to make tractive demand
1028
1029                    // fc handles all power not covered by em
1030                    (pwr_prop_req - em_pwr)
1031                        // and if that's less than the
1032                        // efficiency-focused value, then operate at
1033                        // that value
1034                        .max(fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff)
1035                        // but don't exceed what what the battery can
1036                        // absorb + tractive demand
1037                        .min(
1038                            pwr_prop_req
1039                                + *em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?,
1040                        )
1041                }
1042            }
1043            // and don't exceed what the fc can do
1044            .min(*fc.state.pwr_prop_max.get_fresh(|| format_dbg!())?);
1045
1046            // recalculate `em_pwr` based on `fc_pwr`
1047            let em_pwr_corrected = (pwr_prop_req - fc_pwr)
1048                .max(-*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?);
1049            (fc_pwr, em_pwr_corrected)
1050        };
1051        Ok((fc_pwr, em_pwr))
1052    }
1053
1054    fn handle_fc_on_causes(
1055        &mut self,
1056        fc: &FuelConverter,
1057        veh_state: &VehicleState,
1058        res: &ReversibleEnergyStorage,
1059        em_state: &ElectricMachineState,
1060    ) -> Result<(), anyhow::Error> {
1061        self.handle_fc_on_causes_for_temp(fc)?;
1062        self.handle_fc_on_causes_for_speed(veh_state)?;
1063        self.handle_fc_on_causes_for_low_soc(res, veh_state)?;
1064        self.handle_fc_on_causes_for_pwr_demand(
1065            *veh_state
1066                .pwr_tractive
1067                .get_stale(|| format_dbg!(veh_state.pwr_tractive))?,
1068            em_state,
1069            &fc.state,
1070        )
1071        .with_context(|| format_dbg!())?;
1072        self.handle_fc_on_causes_for_on_time(fc)?;
1073        Ok(())
1074    }
1075
1076    fn handle_fc_on_causes_for_on_time(&mut self, fc: &FuelConverter) -> Result<(), anyhow::Error> {
1077        self.state.on_time_too_short.update(*fc.state.fc_on.get_stale(|| format_dbg!())? && *fc.state.time_on.get_stale(|| format_dbg!())?
1078                    < self.fc_min_time_on.with_context(|| {
1079                    anyhow!(
1080                        "{}\n Expected `ResGreedyWithBuffers::init` to have been called beforehand.",
1081                        format_dbg!()
1082                    )
1083                })?, || format_dbg!())?;
1084        Ok(())
1085    }
1086
1087    /// Determines whether power demand requires engine to be on.  Not needed during
1088    /// negative traction.
1089    fn handle_fc_on_causes_for_pwr_demand(
1090        &mut self,
1091        pwr_out_req_for_cyc: si::Power,
1092        em_state: &ElectricMachineState,
1093        fc_state: &FuelConverterState,
1094    ) -> Result<(), anyhow::Error> {
1095        let frac_pwr_demand_fc_forced_on: si::Ratio = self
1096            .frac_pwr_demand_fc_forced_on
1097            .with_context(|| format_dbg!())?;
1098        self.state.propulsion_power_demand_soft.update(
1099            pwr_out_req_for_cyc
1100                > frac_pwr_demand_fc_forced_on
1101                    * (*em_state.pwr_mech_fwd_out_max.get_stale(|| format_dbg!())?
1102                        + *fc_state.pwr_out_max.get_stale(|| format_dbg!())?),
1103            || format_dbg!(),
1104        )?;
1105        self.state.propulsion_power_demand.update(
1106            pwr_out_req_for_cyc - *em_state.pwr_mech_fwd_out_max.get_stale(|| format_dbg!())?
1107                >= si::Power::ZERO,
1108            || format_dbg!(),
1109        )?;
1110        Ok(())
1111    }
1112
1113    /// Detemrines whether engine must be on to charge battery
1114    fn handle_fc_on_causes_for_low_soc(
1115        &mut self,
1116        res: &ReversibleEnergyStorage,
1117        veh_state: &VehicleState,
1118    ) -> anyhow::Result<()> {
1119        self.state.soc_fc_on_buffer.update(
1120            {
1121                let energy_delta_to_buffer_speed: si::Energy = 0.5
1122                    * *veh_state.mass.get_fresh(|| format_dbg!())?
1123                    * (self
1124                        .speed_soc_fc_on_buffer
1125                        .with_context(|| format_dbg!())?
1126                        .powi(P2::new())
1127                        - veh_state
1128                            .speed_ach
1129                            .get_stale(|| format_dbg!())?
1130                            .powi(P2::new()));
1131                energy_delta_to_buffer_speed.max(si::Energy::ZERO)
1132                    * self
1133                        .speed_soc_fc_on_buffer_coeff
1134                        .with_context(|| format_dbg!())?
1135            } / res.energy_capacity_usable()
1136                + res.min_soc,
1137            || format_dbg!(),
1138        )?;
1139        self.state.charging_for_low_soc.update(
1140            *res.state.soc.get_stale(|| format_dbg!())?
1141                < *self.state.soc_fc_on_buffer.get_fresh(|| format_dbg!())?,
1142            || format_dbg!(),
1143        )?;
1144        Ok(())
1145    }
1146
1147    /// Determines whether enigne must be on for high speed
1148    fn handle_fc_on_causes_for_speed(&mut self, veh_state: &VehicleState) -> anyhow::Result<()> {
1149        self.state.vehicle_speed_too_high.update(
1150            *veh_state.speed_ach.get_stale(|| format_dbg!())?
1151                > self.speed_fc_forced_on.with_context(|| format_dbg!())?,
1152            || format_dbg!(),
1153        )?;
1154        Ok(())
1155    }
1156
1157    /// Determines whether engine needs to be on due to low temperature and pushes
1158    /// appropriate variant to `fc_on_causes`
1159    fn handle_fc_on_causes_for_temp(&mut self, fc: &FuelConverter) -> anyhow::Result<()> {
1160        match (
1161            match fc.temperature() {
1162                Some(fct) => Some(*fct.get_fresh(|| format_dbg!())?),
1163                None => None,
1164            },
1165            match fc.temperature() {
1166                Some(fct) => Some(*fct.get_fresh(|| format_dbg!())?),
1167                None => None,
1168            },
1169            self.temp_fc_forced_on,
1170            self.temp_fc_allowed_off,
1171        ) {
1172            (None, None, None, None) => {
1173                self.state
1174                    .fc_temperature_too_low
1175                    .update(false, || format_dbg!())?;
1176            }
1177            (
1178                Some(temperature),
1179                Some(temp_prev),
1180                Some(temp_fc_forced_on),
1181                Some(temp_fc_allowed_off),
1182            ) => {
1183                self.state.fc_temperature_too_low.update(
1184                    // temperature is currently below forced on threshold
1185                    temperature < temp_fc_forced_on ||
1186            // temperature was below forced on threshold and still has not exceeded allowed off threshold
1187            (temp_prev < temp_fc_forced_on && temperature < temp_fc_allowed_off),
1188                    || format_dbg!(),
1189                )?;
1190            }
1191            _ => {
1192                bail!(
1193                    "{}\n`fc.temperature()`, `fc.temp_prev()`, `self.temp_fc_forced_on`, and 
1194`self.temp_fc_allowed_off` must all be `None` or `Some` because these controls are necessary
1195for an HEV equipped with thermal models or superfluous otherwise",
1196                    format_dbg!((
1197                        fc.temperature(),
1198                        self.temp_fc_forced_on,
1199                        self.temp_fc_allowed_off
1200                    ))
1201                );
1202            }
1203        }
1204        Ok(())
1205    }
1206}
1207
1208#[serde_api]
1209#[derive(
1210    Clone,
1211    Debug,
1212    Default,
1213    Deserialize,
1214    Serialize,
1215    PartialEq,
1216    HistoryVec,
1217    StateMethods,
1218    SetCumulative,
1219)]
1220#[non_exhaustive]
1221#[serde(deny_unknown_fields)]
1222pub struct StartStopState {
1223    /// time step index
1224    pub i: TrackedState<usize>,
1225    /// Engine must be on to self heat if thermal model is enabled
1226    pub fc_temperature_too_low: TrackedState<bool>,
1227    /// Engine start-stop can only happen while vehicle is stopped
1228    pub vehicle_not_stopped: TrackedState<bool>,
1229    /// Engine has not been on long enough (usually 30 s)
1230    pub on_time_too_short: TrackedState<bool>,
1231    /// Aux power demand exceeds battery capability
1232    pub aux_power_demand: TrackedState<bool>,
1233    /// SOC is below min buffer so FC is charging RES
1234    pub charging_for_low_soc: TrackedState<bool>,
1235    /// The total time vehicle has been stopped
1236    pub time_vehicle_stopped: TrackedState<si::Time>,
1237    /// Vehicle stopped time
1238    pub vehicle_not_stopped_long_enough: TrackedState<bool>,
1239    /// Vehicle has a request for traction power for the current timestep
1240    pub has_traction_power_request: TrackedState<bool>,
1241}
1242
1243impl StartStopState {
1244    /// If any of the causes are true, engine must be on
1245    fn fc_on(&self) -> anyhow::Result<bool> {
1246        Ok(*self.fc_temperature_too_low.get_fresh(|| format_dbg!())?
1247            || *self.vehicle_not_stopped.get_fresh(|| format_dbg!())?
1248            || *self.on_time_too_short.get_fresh(|| format_dbg!())?
1249            || *self.aux_power_demand.get_fresh(|| format_dbg!())?
1250            || *self.charging_for_low_soc.get_fresh(|| format_dbg!())?
1251            || *self
1252                .vehicle_not_stopped_long_enough
1253                .get_fresh(|| format_dbg!())?
1254            || *self
1255                .has_traction_power_request
1256                .get_fresh(|| format_dbg!())?)
1257    }
1258}
1259
1260#[serde_api]
1261#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, StateMethods, SetCumulative)]
1262#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
1263#[non_exhaustive]
1264#[serde(deny_unknown_fields)]
1265pub struct HEVStartStopControl {
1266    /// Minimum time engine must remain on if it was on during the previous
1267    /// simulation time step.
1268    pub fc_min_time_on: Option<si::Time>,
1269    /// The range of usable SOC of the storage system below which the
1270    /// [FuelConverter] is forced on.
1271    pub soc_fc_forced_on: Option<si::Ratio>,
1272    /// Force engine, if on, to run at this fraction of power at which peak
1273    /// efficiency occurs or the required power, whichever is greater. If SOC is
1274    /// below min buffer or engine is otherwise forced on and battery has room
1275    /// to receive charge, engine will run at this level and charge.
1276    pub frac_of_most_eff_pwr_to_run_fc: Option<si::Ratio>,
1277    /// temperature at which engine is forced on to warm up
1278    #[serde(default)]
1279    pub temp_fc_forced_on: Option<si::Temperature>,
1280    /// temperature at which engine is allowed to turn off due to being sufficiently warm
1281    #[serde(default)]
1282    pub temp_fc_allowed_off: Option<si::Temperature>,
1283    /// Time delay after the vehicle reaches a stop before the engine is allowed
1284    /// to turn off. This is to try to prevent engine stopping when the vehicle
1285    /// stop is only momentary.
1286    #[serde(default)]
1287    pub time_delay_after_stop_until_fc_can_turn_off: Option<si::Time>,
1288    /// Speed threshold at or below which vehicle is considered stopped for start-stop logic.
1289    #[serde(default = "HEVStartStopControl::def_stopped_speed_threshold")]
1290    pub stopped_speed_threshold: si::Velocity,
1291    /// If true, the electric machine can recharge from regenerative braking
1292    pub em_can_regen: Option<bool>,
1293    #[serde(default)]
1294    /// Time step interval between saves. 1 is a good option. If None, no saving occurs.
1295    pub save_interval: Option<usize>,
1296    /// current state of control variables
1297    #[serde(default)]
1298    pub state: StartStopState,
1299    /// history of current state
1300    #[serde(default, skip_serializing_if = "StartStopStateHistoryVec::is_empty")]
1301    pub history: StartStopStateHistoryVec,
1302}
1303
1304#[pyo3_api]
1305impl HEVStartStopControl {}
1306
1307impl HistoryMethods for HEVStartStopControl {
1308    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
1309        self.save_interval = save_interval;
1310        Ok(())
1311    }
1312
1313    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
1314        Ok(self.save_interval)
1315    }
1316
1317    fn clear(&mut self) {
1318        self.history.clear();
1319    }
1320}
1321
1322impl Init for HEVStartStopControl {
1323    fn init(&mut self) -> Result<(), Error> {
1324        init_opt_default!(self, fc_min_time_on, 5.0 * uc::S);
1325        init_opt_default!(self, soc_fc_forced_on, 0.1 * uc::R);
1326        init_opt_default!(self, frac_of_most_eff_pwr_to_run_fc, 1.0 * uc::R);
1327        init_opt_default!(
1328            self,
1329            time_delay_after_stop_until_fc_can_turn_off,
1330            0.0 * uc::S
1331        );
1332        Ok(())
1333    }
1334}
1335
1336impl SerdeAPI for HEVStartStopControl {}
1337
1338impl Default for HEVStartStopControl {
1339    fn default() -> Self {
1340        Self {
1341            fc_min_time_on: Option::default(),
1342            soc_fc_forced_on: Option::default(),
1343            frac_of_most_eff_pwr_to_run_fc: Option::default(),
1344            temp_fc_forced_on: Option::default(),
1345            temp_fc_allowed_off: Option::default(),
1346            time_delay_after_stop_until_fc_can_turn_off: Option::default(),
1347            stopped_speed_threshold: Self::def_stopped_speed_threshold(),
1348            em_can_regen: Option::default(),
1349            save_interval: Option::default(),
1350            state: StartStopState::default(),
1351            history: StartStopStateHistoryVec::default(),
1352        }
1353    }
1354}
1355
1356impl StartStopControl for HEVStartStopControl {}
1357
1358impl HEVStartStopControl {
1359    fn def_stopped_speed_threshold() -> si::Velocity {
1360        0.05 * uc::MPS
1361    }
1362
1363    pub fn new(
1364        fc_min_time_on: Option<si::Time>,
1365        soc_fc_forced_on: Option<si::Ratio>,
1366        frac_of_most_eff_pwr_to_run_fc: Option<si::Ratio>,
1367        temp_fc_forced_on: Option<si::Temperature>,
1368        temp_fc_allowed_off: Option<si::Temperature>,
1369        time_delay_after_stop_until_fc_can_turn_off: Option<si::Time>,
1370        em_can_regen: Option<bool>,
1371        save_interval: Option<usize>,
1372    ) -> anyhow::Result<Self> {
1373        let mut result = Self {
1374            fc_min_time_on,
1375            soc_fc_forced_on,
1376            frac_of_most_eff_pwr_to_run_fc,
1377            temp_fc_forced_on,
1378            temp_fc_allowed_off,
1379            time_delay_after_stop_until_fc_can_turn_off,
1380            stopped_speed_threshold: Self::def_stopped_speed_threshold(),
1381            em_can_regen,
1382            save_interval,
1383            state: StartStopState::default(),
1384            history: StartStopStateHistoryVec::default(),
1385        };
1386        result.init()?;
1387        Ok(result)
1388    }
1389
1390    fn get_pwr_fc_and_em(
1391        &mut self,
1392        fc: &FuelConverter,
1393        pwr_prop_req: si::Power,
1394        em_state: &ElectricMachineState,
1395    ) -> anyhow::Result<(si::Power, si::Power)> {
1396        let no_prop_pwr_demand = pwr_prop_req == si::Power::ZERO;
1397        let em_can_regen = self.em_can_regen.unwrap_or(true);
1398        let em_pwr = pwr_prop_req.min(si::Power::ZERO).max(if em_can_regen {
1399            -*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?
1400        } else {
1401            si::Power::ZERO
1402        });
1403        let (fc_pwr, em_pwr) = {
1404            // engine is on or forced on if tractive effort is required
1405            let frac_of_pwr_for_peak_eff: si::Ratio = self
1406                .frac_of_most_eff_pwr_to_run_fc
1407                .with_context(|| format_dbg!())?;
1408            let fc_pwr = if pwr_prop_req < si::Power::ZERO {
1409                // negative tractive power
1410                // max power system can receive from engine during negative traction
1411                (*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())? + pwr_prop_req)
1412                    // or peak efficiency power if it's lower than above
1413                    .min(fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff)
1414                    // but not negative
1415                    .max(si::Power::ZERO)
1416            } else if no_prop_pwr_demand {
1417                // no propulsion power needed. Allow for engine-off
1418                // as much as possible.
1419                // TODO: take into consideration RESS SOC and aux loads?
1420                0.0 * uc::W
1421            } else {
1422                // positive tractive power
1423                if pwr_prop_req - em_pwr > fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff {
1424                    // engine needs to run higher than peak efficiency point
1425                    pwr_prop_req - em_pwr
1426                } else {
1427                    // engine does not need to run higher than peak
1428                    // efficiency point to make tractive demand
1429
1430                    // fc handles all power not covered by em
1431                    (pwr_prop_req - em_pwr)
1432                        // and if that's less than the
1433                        // efficiency-focused value, then operate at
1434                        // that value
1435                        .max(fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff)
1436                        // but don't exceed what the battery can
1437                        // absorb + tractive demand
1438                        .min(
1439                            pwr_prop_req
1440                                + *em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?,
1441                        )
1442                }
1443            }
1444            // and don't exceed what the fc can do
1445            .min(*fc.state.pwr_prop_max.get_fresh(|| format_dbg!())?);
1446
1447            // recalculate `em_pwr` based on `fc_pwr`
1448            let em_pwr_corrected = (pwr_prop_req - fc_pwr).max(if em_can_regen {
1449                -*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?
1450            } else {
1451                si::Power::ZERO
1452            });
1453            (fc_pwr, em_pwr_corrected)
1454        };
1455        Self::handle_fc_on_causes_for_propulsion_request(
1456            &mut self.state.has_traction_power_request,
1457            fc_pwr,
1458        )?;
1459        Ok((fc_pwr, em_pwr))
1460    }
1461
1462    pub fn handle_fc_on_causes(
1463        &mut self,
1464        fc: &FuelConverter,
1465        veh_state: &VehicleState,
1466        res: &ReversibleEnergyStorage,
1467        dt: si::Time,
1468    ) -> Result<(), anyhow::Error> {
1469        // NOTE: handle_fc_on_causes_for_propulsion_request called elsewhere
1470        Self::handle_fc_on_causes_for_stopped_time(
1471            &mut self.state.time_vehicle_stopped,
1472            &mut self.state.vehicle_not_stopped_long_enough,
1473            veh_state,
1474            dt,
1475            self.time_delay_after_stop_until_fc_can_turn_off,
1476            self.stopped_speed_threshold,
1477        )?;
1478        Self::handle_fc_on_causes_for_temp(
1479            fc,
1480            self.temp_fc_forced_on,
1481            self.temp_fc_allowed_off,
1482            &mut self.state.fc_temperature_too_low,
1483        )?;
1484        // NOTE: handle_fc_on_causes_for_speed(speed) called elsewhere
1485        self.handle_fc_on_causes_for_low_soc(res)?;
1486        Self::handle_fc_on_causes_for_on_time(
1487            fc,
1488            self.fc_min_time_on,
1489            &mut self.state.on_time_too_short,
1490        )?;
1491        Ok(())
1492    }
1493
1494    fn handle_fc_on_causes_for_low_soc(
1495        &mut self,
1496        res: &ReversibleEnergyStorage,
1497    ) -> anyhow::Result<()> {
1498        let soc_fc_forced_on = if let Some(soc_frac) = self.soc_fc_forced_on {
1499            soc_frac * (res.max_soc - res.min_soc) + res.min_soc
1500        } else {
1501            0.1 * (res.max_soc - res.min_soc) + res.min_soc
1502        };
1503        self.state.charging_for_low_soc.update(
1504            *res.state.soc.get_stale(|| format_dbg!())? < soc_fc_forced_on,
1505            || format_dbg!(),
1506        )?;
1507        Ok(())
1508    }
1509}