fastsim_core/vehicle/
hev.rs

1use super::{vehicle_model::VehicleState, *};
2use crate::prelude::ElectricMachineState;
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 `walk` 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        };
90        if fc_on_soc > self.res.max_soc {
91            eprintln!("fc_on_soc > self.res.max_soc");
92            eprintln!("fc_on_soc: {:?}", fc_on_soc);
93        }
94        if fc_on_soc < self.res.min_soc {
95            eprintln!("fc_on_soc < self.res.min_soc");
96            eprintln!("fc_on_soc: {:?}", fc_on_soc);
97        }
98        if disch_buffer > self.res.energy_capacity_usable() {
99            eprintln!("disch_buffer < self.res.energy_capacity_usable()");
100            eprintln!(
101                "disch_buffer: {:?} kWh",
102                disch_buffer.get::<si::kilowatt_hour>()
103            );
104            eprintln!(
105                "RES usable energy capacity: {:?} kWh",
106                self.res.energy_capacity_usable().get::<si::kilowatt_hour>()
107            );
108        }
109        if chrg_buffer > self.res.energy_capacity_usable() {
110            eprintln!("disch_buffer < self.res.energy_capacity_usable()");
111            eprintln!(
112                "chrg_buffer: {:?} kWh",
113                chrg_buffer.get::<si::kilowatt_hour>()
114            );
115            eprintln!(
116                "RES usable energy capacity: {:?} kWh",
117                self.res.energy_capacity_usable().get::<si::kilowatt_hour>()
118            );
119        }
120        Ok(())
121    }
122}
123
124#[pyo3_api]
125impl HybridElectricVehicle {}
126
127impl HistoryMethods for HybridElectricVehicle {
128    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
129        bail!("`save_interval` is not implemented in HybridElectricVehicle")
130    }
131    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
132        self.res.set_save_interval(save_interval)?;
133        // self.fs.set_save_interval(save_interval)?;
134        self.fc.set_save_interval(save_interval)?;
135        self.em.set_save_interval(save_interval)?;
136        self.transmission.set_save_interval(save_interval)?;
137        self.pt_cntrl.set_save_interval(save_interval)?;
138        Ok(())
139    }
140    fn clear(&mut self) {
141        self.res.clear();
142        // self.fs.clear();
143        self.fc.clear();
144        self.em.clear();
145        self.transmission.clear();
146        self.pt_cntrl.clear();
147    }
148}
149
150impl Init for HybridElectricVehicle {
151    fn init(&mut self) -> Result<(), Error> {
152        self.fc
153            .init()
154            .map_err(|err| Error::InitError(format_dbg!(err)))?;
155        self.res
156            .init()
157            .map_err(|err| Error::InitError(format_dbg!(err)))?;
158        self.em
159            .init()
160            .map_err(|err| Error::InitError(format_dbg!(err)))?;
161        self.transmission
162            .init()
163            .map_err(|err| Error::InitError(format_dbg!(err)))?;
164        self.pt_cntrl
165            .init()
166            .map_err(|err| Error::InitError(format_dbg!(err)))?;
167        Ok(())
168    }
169}
170
171impl SerdeAPI for HybridElectricVehicle {}
172
173impl Powertrain for Box<HybridElectricVehicle> {
174    fn set_curr_pwr_prop_out_max(
175        &mut self,
176        _pwr_upstream: (si::Power, si::Power),
177        pwr_aux: si::Power,
178        dt: si::Time,
179        veh_state: &VehicleState,
180    ) -> anyhow::Result<()> {
181        // TODO: account for transmission efficiency in here
182        let (disch_buffer, chrg_buffer) = match &mut self.pt_cntrl {
183            HEVPowertrainControls::RGWDB(rgwdb) => {
184                rgwdb.handle_fc_on_causes(&self.fc, veh_state, &self.res, &self.em.state)?;
185
186                let disch_buffer = (0.5
187                    * *veh_state.mass.get_fresh(|| format_dbg!())?
188                    * (rgwdb
189                        .speed_soc_disch_buffer
190                        .with_context(|| format_dbg!())?
191                        .powi(P2::new())
192                        - veh_state
193                            .speed_ach
194                            .get_stale(|| format_dbg!())?
195                            .powi(P2::new())))
196                .max(si::Energy::ZERO)
197                    * rgwdb
198                        .speed_soc_disch_buffer_coeff
199                        .with_context(|| format_dbg!())?;
200
201                let chrg_buffer = (0.5
202                    * *veh_state.mass.get_fresh(|| format_dbg!())?
203                    * (veh_state
204                        .speed_ach
205                        .get_stale(|| format_dbg!())?
206                        .powi(P2::new())
207                        - rgwdb
208                            .speed_soc_regen_buffer
209                            .with_context(|| format_dbg!())?
210                            .powi(P2::new())))
211                .max(si::Energy::ZERO)
212                    * rgwdb
213                        .speed_soc_regen_buffer_coeff
214                        .with_context(|| format_dbg!())?;
215
216                (disch_buffer, chrg_buffer)
217            }
218        };
219        // set total max powers, including aux power
220        self.fc
221            .set_curr_pwr_out_max(dt)
222            .with_context(|| anyhow!(format_dbg!()))?;
223        self.res
224            .set_curr_pwr_out_max(dt, disch_buffer, chrg_buffer)
225            .with_context(|| anyhow!(format_dbg!()))?;
226
227        // determine distribution of aux power between engine and battery
228        let (pwr_aux_res, pwr_aux_fc) = {
229            match self.aux_cntrl {
230                HEVAuxControls::AuxOnResPriority => {
231                    if pwr_aux <= *self.res.state.pwr_disch_max.get_fresh(|| format_dbg!())? {
232                        (pwr_aux, si::Power::ZERO)
233                    } else {
234                        (si::Power::ZERO, pwr_aux)
235                    }
236                }
237                HEVAuxControls::AuxOnFcPriority => (si::Power::ZERO, pwr_aux),
238            }
239        };
240
241        match &mut self.pt_cntrl {
242            HEVPowertrainControls::RGWDB(rgwdb) => {
243                rgwdb
244                    .state
245                    .aux_power_demand
246                    .update(pwr_aux_fc > si::Power::ZERO, || format_dbg!())?;
247            }
248        }
249
250        // set max propulsion powers
251        self.fc
252            .set_curr_pwr_prop_max(pwr_aux_fc)
253            .with_context(|| anyhow!(format_dbg!()))?;
254        self.res
255            .set_curr_pwr_prop_max(pwr_aux_res)
256            .with_context(|| anyhow!(format_dbg!()))?;
257        self.em
258            .set_curr_pwr_prop_out_max(
259                // TODO: add means of controlling whether fc can provide power to em and also how much
260                // Try out a 'power out type' enum field on the fuel converter with variants for mechanical and electrical
261                self.res
262                    .get_curr_pwr_prop_out_max()
263                    .with_context(|| format_dbg!())?,
264                pwr_aux,
265                dt,
266                veh_state,
267            )
268            .with_context(|| anyhow!(format_dbg!()))?;
269        let em_pwr_prop_out_maxes = self
270            .em
271            .get_curr_pwr_prop_out_max()
272            .with_context(|| format_dbg!())?;
273        let fc_max = self.fc.state.pwr_prop_max.get_fresh(|| format_dbg!())?;
274        self.transmission
275            .set_curr_pwr_prop_out_max(
276                (em_pwr_prop_out_maxes.0 + *fc_max, em_pwr_prop_out_maxes.1),
277                f64::NAN * uc::W,
278                dt,
279                veh_state,
280            )
281            .with_context(|| format_dbg!())?;
282        Ok(())
283    }
284
285    fn get_curr_pwr_prop_out_max(&self) -> anyhow::Result<(si::Power, si::Power)> {
286        self.transmission
287            .get_curr_pwr_prop_out_max()
288            .with_context(|| format_dbg!())
289    }
290
291    fn solve(
292        &mut self,
293        pwr_out_req: si::Power,
294        _enabled: bool,
295        dt: si::Time,
296    ) -> anyhow::Result<Option<si::Power>> {
297        // TODO: address these concerns
298        // - what happens when the fc is on and producing more power than the
299        //   transmission requires? It seems like the excess goes straight to the battery,
300        //   but it should probably go thourgh the em somehow.
301        let pwr_in_transmission = self
302            .transmission
303            .solve(pwr_out_req, true, dt)
304            .with_context(|| format_dbg!())?
305            .with_context(|| format!("{}\nExpected `Some`", format_dbg!()))?;
306
307        // TODO: use an enum with a match here to determine whether power is shared by
308        // - fc and em (e.g. for ICE HEV)
309        //   or
310        // - fc and res (e.g. for H2FC HEV)
311
312        let (fc_pwr_out_req, em_pwr_out_req) = self
313            .pt_cntrl
314            .get_pwr_fc_and_em(pwr_in_transmission, &self.fc, &self.em.state, &self.res)
315            .with_context(|| format_dbg!())?;
316        let fc_on: bool = self.pt_cntrl.engine_on()?;
317
318        self.fc
319            .solve(fc_pwr_out_req, fc_on, dt)
320            .with_context(|| format_dbg!())?;
321        let res_pwr_out_req = self
322            .em
323            .solve(em_pwr_out_req, true, dt)
324            .with_context(|| format_dbg!())?
325            .with_context(|| format!("{}\nExpected `Some`", format_dbg!()))?;
326        // TODO: `res_pwr_out_req` probably does not include charging from the engine
327        self.res
328            .solve(res_pwr_out_req, dt)
329            .with_context(|| format_dbg!())?;
330        Ok(None)
331    }
332
333    /// Regen braking power, positive means braking is happening
334    fn pwr_regen(&self) -> anyhow::Result<si::Power> {
335        // When `pwr_mech_prop_out` is negative, regen is happening.  First, clip it at 0, and then negate it.
336        // see https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=e8f7af5a6e436dd1163fa3c70931d18d
337        // for example
338        self.transmission.pwr_regen().with_context(|| format_dbg!())
339    }
340}
341
342impl HybridElectricVehicle {
343    /// # Arguments
344    /// - `te_amb`: ambient temperature
345    /// - `pwr_thrml_fc_to_cab`: thermal power flow from [FuelConverter::thrml]
346    ///   to [Vehicle::cabin], if cabin is equipped
347    /// - `veh_state`: current [VehicleState]
348    /// - `pwr_thrml_hvac_to_res`: thermal power flow from [Vehicle::hvac] --
349    ///   zero if `None` is passed
350    /// - `te_cab`: cabin temperature, required if [ReversibleEnergyStorage::thrml] is `Some`
351    /// - `dt`: simulation time step size
352    pub fn solve_thermal(
353        &mut self,
354        te_amb: si::Temperature,
355        pwr_thrml_fc_to_cab: Option<si::Power>,
356        veh_state: &mut VehicleState,
357        pwr_thrml_hvac_to_res: Option<si::Power>,
358        te_cab: Option<si::Temperature>,
359        dt: si::Time,
360    ) -> anyhow::Result<()> {
361        self.fc
362            .solve_thermal(te_amb, pwr_thrml_fc_to_cab, veh_state, dt)
363            .with_context(|| format_dbg!())?;
364        self.res
365            .solve_thermal(
366                te_amb,
367                pwr_thrml_hvac_to_res.unwrap_or_default(),
368                te_cab,
369                dt,
370            )
371            .with_context(|| format_dbg!())?;
372        Ok(())
373    }
374}
375
376impl TryFrom<&fastsim_2::vehicle::RustVehicle> for HybridElectricVehicle {
377    type Error = anyhow::Error;
378    fn try_from(f2veh: &fastsim_2::vehicle::RustVehicle) -> anyhow::Result<HybridElectricVehicle> {
379        let pt_cntrl = HEVPowertrainControls::RGWDB(Box::new(hev::RESGreedyWithDynamicBuffers {
380            speed_soc_fc_on_buffer: None,
381            speed_soc_fc_on_buffer_coeff: None,
382            speed_soc_disch_buffer: None,
383            speed_soc_disch_buffer_coeff: None,
384            speed_soc_regen_buffer: None,
385            speed_soc_regen_buffer_coeff: None,
386            // note that this exists in `fastsim-2` but has no apparent effect!
387            fc_min_time_on: None,
388            speed_fc_forced_on: Some(f2veh.mph_fc_on * uc::MPH),
389            frac_pwr_demand_fc_forced_on: Some(
390                f2veh.kw_demand_fc_on / (f2veh.fc_max_kw + f2veh.ess_max_kw.min(f2veh.mc_max_kw))
391                    * uc::R,
392            ),
393            frac_of_most_eff_pwr_to_run_fc: None,
394            temp_fc_forced_on: None,
395            temp_fc_allowed_off: None,
396            save_interval: Some(1),
397            state: Default::default(),
398            history: Default::default(),
399        }));
400        let mut hev = HybridElectricVehicle {
401            fs: {
402                let mut fs = FuelStorage {
403                    pwr_out_max: f2veh.fs_max_kw * uc::KW,
404                    pwr_ramp_lag: f2veh.fs_secs_to_peak_pwr * uc::S,
405                    energy_capacity: f2veh.fs_kwh * 3.6 * uc::MJ,
406                    specific_energy: None,
407                    mass: None,
408                };
409                fs.set_mass(None, MassSideEffect::None)
410                    .with_context(|| anyhow!(format_dbg!()))?;
411                fs
412            },
413            fc: FuelConverter::try_from(f2veh.clone())?,
414            res: ReversibleEnergyStorage::try_from(f2veh.clone()).with_context(|| format_dbg!())?,
415            em: ElectricMachine::try_from(f2veh.clone())?,
416            transmission: Transmission::try_from(f2veh.clone())?,
417            pt_cntrl,
418            mass: None,
419            sim_params: Default::default(),
420            aux_cntrl: Default::default(),
421            soc_bal_iter_history: Default::default(),
422            soc_bal_iters: Default::default(),
423        };
424        hev.init()?;
425        Ok(hev)
426    }
427}
428impl Mass for HybridElectricVehicle {
429    fn mass(&self) -> anyhow::Result<Option<si::Mass>> {
430        let derived_mass = self
431            .derived_mass()
432            .with_context(|| anyhow!(format_dbg!()))?;
433        match (derived_mass, self.mass) {
434            (Some(derived_mass), Some(set_mass)) => {
435                ensure!(
436                    utils::almost_eq_uom(&set_mass, &derived_mass, None),
437                    format!(
438                        "{}",
439                        format_dbg!(utils::almost_eq_uom(&set_mass, &derived_mass, None)),
440                    )
441                );
442                Ok(Some(set_mass))
443            }
444            _ => Ok(self.mass.or(derived_mass)),
445        }
446    }
447
448    fn set_mass(
449        &mut self,
450        new_mass: Option<si::Mass>,
451        side_effect: MassSideEffect,
452    ) -> anyhow::Result<()> {
453        ensure!(
454            side_effect == MassSideEffect::None,
455            "At the powertrain level, only `MassSideEffect::None` is allowed"
456        );
457        let derived_mass = self
458            .derived_mass()
459            .with_context(|| anyhow!(format_dbg!()))?;
460        self.mass = match new_mass {
461            // Set using provided `new_mass`, setting constituent mass fields to `None` to match if inconsistent
462            Some(new_mass) => {
463                if let Some(dm) = derived_mass {
464                    if dm != new_mass {
465                        self.expunge_mass_fields();
466                    }
467                }
468                Some(new_mass)
469            }
470            // Set using `derived_mass()`, failing if it returns `None`
471            None => Some(derived_mass.with_context(|| {
472                format!(
473                    "Not all mass fields in `{}` are set and no mass was provided.",
474                    stringify!(HybridElectricVehicle)
475                )
476            })?),
477        };
478        Ok(())
479    }
480
481    fn derived_mass(&self) -> anyhow::Result<Option<si::Mass>> {
482        let fc_mass = self.fc.mass().with_context(|| anyhow!(format_dbg!()))?;
483        let fs_mass = self.fs.mass().with_context(|| anyhow!(format_dbg!()))?;
484        let res_mass = self.res.mass().with_context(|| anyhow!(format_dbg!()))?;
485        let em_mass = self.em.mass().with_context(|| anyhow!(format_dbg!()))?;
486        let transmission_mass = self
487            .transmission
488            .mass()
489            .with_context(|| anyhow!(format_dbg!()))?;
490        match (fc_mass, fs_mass, res_mass, em_mass, transmission_mass) {
491            (
492                Some(fc_mass),
493                Some(fs_mass),
494                Some(res_mass),
495                Some(em_mass),
496                Some(transmission_mass),
497            ) => Ok(Some(
498                fc_mass + fs_mass + res_mass + em_mass + transmission_mass,
499            )),
500            (None, None, None, None, None) => Ok(None),
501            _ => bail!(
502                "`{}` field masses are not consistently set to `Some` or `None`",
503                stringify!(HybridElectricVehicle)
504            ),
505        }
506    }
507
508    fn expunge_mass_fields(&mut self) {
509        self.fc.expunge_mass_fields();
510        self.fs.expunge_mass_fields();
511        self.res.expunge_mass_fields();
512        self.em.expunge_mass_fields();
513        self.transmission.expunge_mass_fields();
514        self.mass = None;
515    }
516}
517
518#[serde_api]
519#[derive(
520    Clone,
521    Debug,
522    Default,
523    Deserialize,
524    Serialize,
525    PartialEq,
526    HistoryVec,
527    StateMethods,
528    SetCumulative,
529)]
530#[non_exhaustive]
531#[serde(deny_unknown_fields)]
532pub struct RGWDBState {
533    /// time step index
534    pub i: TrackedState<usize>,
535    /// Engine must be on to self heat if thermal model is enabled
536    pub fc_temperature_too_low: TrackedState<bool>,
537    /// Engine must be on for high vehicle speed to ensure powertrain can meet
538    /// any spikes in power demand
539    pub vehicle_speed_too_high: TrackedState<bool>,
540    /// Engine has not been on long enough (usually 30 s)
541    pub on_time_too_short: TrackedState<bool>,
542    /// Powertrain power demand exceeds motor and/or battery capabilities
543    pub propulsion_power_demand: TrackedState<bool>,
544    /// Powertrain power demand exceeds optimal motor and/or battery output
545    pub propulsion_power_demand_soft: TrackedState<bool>,
546    /// Aux power demand exceeds battery capability
547    pub aux_power_demand: TrackedState<bool>,
548    /// SOC is below min buffer so FC is charging RES
549    pub charging_for_low_soc: TrackedState<bool>,
550    /// buffer at which FC is forced on
551    pub soc_fc_on_buffer: TrackedState<si::Ratio>,
552}
553impl SerdeAPI for RGWDBState {}
554impl Init for RGWDBState {}
555
556impl RGWDBState {
557    /// If any of the causes are true, engine must be on
558    fn engine_on(&self) -> anyhow::Result<bool> {
559        Ok(*self.fc_temperature_too_low.get_fresh(|| format_dbg!())?
560            || *self.vehicle_speed_too_high.get_fresh(|| format_dbg!())?
561            || *self.on_time_too_short.get_fresh(|| format_dbg!())?
562            || *self.propulsion_power_demand.get_fresh(|| format_dbg!())?
563            || *self
564                .propulsion_power_demand_soft
565                .get_fresh(|| format_dbg!())?
566            || *self.aux_power_demand.get_fresh(|| format_dbg!())?
567            || *self.charging_for_low_soc.get_fresh(|| format_dbg!())?)
568    }
569}
570
571/// Options for controlling simulation behavior
572#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
573#[non_exhaustive]
574#[serde(deny_unknown_fields)]
575pub struct HEVSimulationParams {
576    /// [ReversibleEnergyStorage] per [FuelConverter]
577    pub res_per_fuel_lim: si::Ratio,
578    /// Threshold of SOC balancing iteration for triggering error
579    pub soc_balance_iter_err: u32,
580    /// Whether to allow iteration to achieve SOC balance
581    pub balance_soc: bool,
582    /// Whether to save each SOC balance iteration    
583    pub save_soc_bal_iters: bool,
584}
585
586impl Default for HEVSimulationParams {
587    fn default() -> Self {
588        Self {
589            res_per_fuel_lim: uc::R * 0.005,
590            soc_balance_iter_err: 5,
591            balance_soc: true,
592            save_soc_bal_iters: false,
593        }
594    }
595}
596
597#[derive(
598    Clone, Debug, PartialEq, Deserialize, Serialize, Default, IsVariant, derive_more::From, TryInto,
599)]
600pub enum HEVAuxControls {
601    /// If feasible, use [ReversibleEnergyStorage] to handle aux power demand
602    #[default]
603    AuxOnResPriority,
604    /// If feasible, use [FuelConverter] to handle aux power demand
605    AuxOnFcPriority,
606}
607
608#[derive(
609    Clone, Debug, PartialEq, Deserialize, Serialize, IsVariant, derive_more::From, TryInto,
610)]
611pub enum HEVPowertrainControls {
612    /// Greedily uses [ReversibleEnergyStorage] with buffers that derate charge
613    /// and discharge power inside of static min and max SOC range.  Also, includes
614    /// buffer for forcing [FuelConverter] to be active/on.
615    RGWDB(Box<RESGreedyWithDynamicBuffers>),
616}
617
618impl Default for HEVPowertrainControls {
619    fn default() -> Self {
620        Self::RGWDB(Default::default())
621    }
622}
623
624impl SetCumulative for HEVPowertrainControls {
625    fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
626        match self {
627            Self::RGWDB(rgwdb) => {
628                rgwdb.set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?
629            }
630        }
631        Ok(())
632    }
633
634    fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
635        match self {
636            Self::RGWDB(rgwdb) => {
637                rgwdb.reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?
638            }
639        }
640        Ok(())
641    }
642}
643impl Step for HEVPowertrainControls {
644    fn step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
645        match self {
646            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.step(loc)?,
647        }
648        Ok(())
649    }
650
651    fn reset_step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
652        match self {
653            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.reset_step(loc)?,
654        }
655        Ok(())
656    }
657}
658
659impl StateMethods for HEVPowertrainControls {}
660
661impl SaveState for HEVPowertrainControls {
662    fn save_state<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
663        match self {
664            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.save_state(loc)?,
665        }
666        Ok(())
667    }
668}
669impl TrackedStateMethods for HEVPowertrainControls {
670    fn check_and_reset<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
671        match self {
672            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.check_and_reset(loc)?,
673        }
674        Ok(())
675    }
676
677    fn mark_fresh<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
678        match self {
679            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.mark_fresh(loc)?,
680        }
681        Ok(())
682    }
683}
684impl HistoryMethods for HEVPowertrainControls {
685    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
686        match self {
687            HEVPowertrainControls::RGWDB(rgwdb) => Ok(rgwdb.set_save_interval(save_interval)?),
688        }
689    }
690
691    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
692        match self {
693            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.save_interval(),
694        }
695    }
696    fn clear(&mut self) {
697        match self {
698            HEVPowertrainControls::RGWDB(rgwdb) => rgwdb.clear(),
699        }
700    }
701}
702
703impl Init for HEVPowertrainControls {
704    fn init(&mut self) -> Result<(), Error> {
705        match self {
706            Self::RGWDB(rgwb) => rgwb.init()?,
707        }
708        Ok(())
709    }
710}
711
712impl HEVPowertrainControls {
713    /// Determines power split between engine and electric machine
714    ///
715    /// # Arguments
716    /// - `pwr_prop_req`: tractive power required
717    /// - `veh_state`: vehicle state
718    /// - `hev_state`: HEV powertrain state
719    /// - `fc`: fuel converter
720    /// - `em_state`: electric machine state
721    /// - `res`: reversible energy storage (e.g. high voltage battery)
722    fn get_pwr_fc_and_em(
723        &mut self,
724        pwr_prop_req: si::Power,
725        fc: &FuelConverter,
726        em_state: &ElectricMachineState,
727        res: &ReversibleEnergyStorage,
728    ) -> anyhow::Result<(si::Power, si::Power)> {
729        let fc_state = &fc.state;
730        ensure!(
731            // `almost` is in case of negligible numerical precision discrepancies
732            almost_le_uom(
733                &pwr_prop_req,
734                &(*em_state.pwr_mech_fwd_out_max.get_fresh(|| format_dbg!())?
735                    + *fc_state.pwr_prop_max.get_fresh(|| format_dbg!())?),
736                None
737            ),
738            "{}
739`pwr_out_req`: {} kW
740`em_state.pwr_mech_fwd_out_max`: {} kW
741`fc_state.pwr_prop_max`: {} kW
742`res.state.soc`: {}",
743            format_dbg!(),
744            pwr_prop_req.get::<si::kilowatt>(),
745            em_state
746                .pwr_mech_fwd_out_max
747                .get_fresh(|| format_dbg!())?
748                .get::<si::kilowatt>(),
749            fc_state
750                .pwr_prop_max
751                .get_fresh(|| format_dbg!())?
752                .get::<si::kilowatt>(),
753            res.state
754                .soc
755                .get_fresh(|| format_dbg!())?
756                .get::<si::ratio>()
757        );
758
759        // # Brain dump for thermal stuff
760        // TODO: engine on/off w.r.t. thermal stuff should not come into play
761        // if there is no component (e.g. cabin) demanding heat from the engine.  My 2019
762        // Hyundai Ioniq will turn the engine off if there is no heat demand regardless of
763        // the coolant temperature
764        // TODO: make sure idle fuel gets converted to heat correctly
765
766        match self {
767            Self::RGWDB(rgwdb) => rgwdb.get_pwr_fc_and_em(fc, pwr_prop_req, em_state),
768        }
769    }
770
771    pub fn engine_on(&self) -> anyhow::Result<bool> {
772        match self {
773            Self::RGWDB(rgwdb) => rgwdb.state.engine_on(),
774        }
775    }
776}
777
778/// Greedily uses [ReversibleEnergyStorage] with buffers that derate charge
779/// and discharge power inside of static min and max SOC range.  Also, includes
780/// buffer for forcing [FuelConverter] to be active/on. See [Self::init] for
781/// default values.
782#[serde_api]
783#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default, StateMethods, SetCumulative)]
784#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
785#[non_exhaustive]
786#[serde(deny_unknown_fields)]
787pub struct RESGreedyWithDynamicBuffers {
788    /// RES energy delta from minimum SOC corresponding to kinetic energy of
789    /// vehicle at this speed that triggers ramp down in RES discharge.
790    pub speed_soc_disch_buffer: Option<si::Velocity>,
791    /// Coefficient for modifying amount of accel buffer
792    pub speed_soc_disch_buffer_coeff: Option<si::Ratio>,
793    /// RES energy delta from minimum SOC corresponding to kinetic energy of
794    /// vehicle at this speed that triggers FC to be forced on.
795    pub speed_soc_fc_on_buffer: Option<si::Velocity>,
796    /// Coefficient for modifying amount of [Self::speed_soc_fc_on_buffer]
797    pub speed_soc_fc_on_buffer_coeff: Option<si::Ratio>,
798    /// RES energy delta from maximum SOC corresponding to kinetic energy of
799    /// vehicle at current speed minus kinetic energy of vehicle at this speed
800    /// triggers ramp down in RES discharge
801    pub speed_soc_regen_buffer: Option<si::Velocity>,
802    /// Coefficient for modifying amount of regen buffer
803    pub speed_soc_regen_buffer_coeff: Option<si::Ratio>,
804    /// Minimum time engine must remain on if it was on during the previous
805    /// simulation time step.
806    pub fc_min_time_on: Option<si::Time>,
807    /// Speed at which [FuelConverter] is forced on.
808    pub speed_fc_forced_on: Option<si::Velocity>,
809    /// Fraction of total aux and powertrain rated power at which
810    /// [FuelConverter] is forced on.
811    pub frac_pwr_demand_fc_forced_on: Option<si::Ratio>,
812    /// Force engine, if on, to run at this fraction of power at which peak
813    /// efficiency occurs or the required power, whichever is greater. If SOC is
814    /// below min buffer or engine is otherwise forced on and battery has room
815    /// to receive charge, engine will run at this level and charge.
816    pub frac_of_most_eff_pwr_to_run_fc: Option<si::Ratio>,
817    /// Fraction of available charging capacity to use toward running the engine
818    /// efficiently.
819    /// Time step interval between saves. 1 is a good option. If None, no saving occurs.
820    pub save_interval: Option<usize>,
821    /// temperature at which engine is forced on to warm up
822    #[serde(default)]
823    pub temp_fc_forced_on: Option<si::Temperature>,
824    /// temperature at which engine is allowed to turn off due to being sufficiently warm
825    #[serde(default)]
826    pub temp_fc_allowed_off: Option<si::Temperature>,
827    /// current state of control variables
828    #[serde(default)]
829    pub state: RGWDBState,
830    #[serde(default)]
831    /// history of current state
832    pub history: RGWDBStateHistoryVec,
833}
834
835#[pyo3_api]
836impl RESGreedyWithDynamicBuffers {}
837
838impl HistoryMethods for RESGreedyWithDynamicBuffers {
839    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
840        self.save_interval = save_interval;
841        Ok(())
842    }
843
844    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
845        Ok(self.save_interval)
846    }
847
848    fn clear(&mut self) {
849        self.history.clear();
850    }
851}
852
853impl Init for RESGreedyWithDynamicBuffers {
854    fn init(&mut self) -> Result<(), Error> {
855        // TODO: make sure these values propagate to the documented defaults above
856        init_opt_default!(self, speed_soc_disch_buffer, 50.0 * uc::MPH);
857        init_opt_default!(self, speed_soc_disch_buffer_coeff, 1.0 * uc::R);
858        init_opt_default!(
859            self,
860            speed_soc_fc_on_buffer,
861            self.speed_soc_disch_buffer.unwrap() * 1.2
862        );
863        init_opt_default!(self, speed_soc_fc_on_buffer_coeff, 1.0 * uc::R);
864        init_opt_default!(self, speed_soc_regen_buffer, 30. * uc::MPH);
865        init_opt_default!(self, speed_soc_regen_buffer_coeff, 1.0 * uc::R);
866        init_opt_default!(self, fc_min_time_on, uc::S * 5.0);
867        init_opt_default!(self, speed_fc_forced_on, uc::MPH * 75.);
868        init_opt_default!(self, frac_pwr_demand_fc_forced_on, uc::R * 0.75);
869        init_opt_default!(self, frac_of_most_eff_pwr_to_run_fc, 1.0 * uc::R);
870        Ok(())
871    }
872}
873impl SerdeAPI for RESGreedyWithDynamicBuffers {}
874
875impl RESGreedyWithDynamicBuffers {
876    fn get_pwr_fc_and_em(
877        &mut self,
878        fc: &FuelConverter,
879        pwr_prop_req: si::Power,
880        em_state: &ElectricMachineState,
881    ) -> anyhow::Result<(si::Power, si::Power)> {
882        // Tractive power `em` must provide before deciding power
883        // split, cannot exceed ElectricMachine max output power.
884        // Excess demand will be handled by `fc`.  Favors drawing power from
885        // `em` before engine
886        let em_pwr = pwr_prop_req
887            .min(*em_state.pwr_mech_fwd_out_max.get_fresh(|| format_dbg!())?)
888            .max(-*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?);
889        // tractive power handled by fc
890        let (fc_pwr, em_pwr) = if !self.state.engine_on()? {
891            // engine is off, and `em_pwr` has already been limited within bounds
892            (si::Power::ZERO, em_pwr)
893        } else {
894            // engine has been forced on
895            let frac_of_pwr_for_peak_eff: si::Ratio = self
896                .frac_of_most_eff_pwr_to_run_fc
897                .with_context(|| format_dbg!())?;
898            let fc_pwr = if pwr_prop_req < si::Power::ZERO {
899                // negative tractive power
900                // max power system can receive from engine during negative traction
901                (*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())? + pwr_prop_req)
902                    // or peak efficiency power if it's lower than above
903                    .min(fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff)
904                    // but not negative
905                    .max(si::Power::ZERO)
906            } else {
907                // positive tractive power
908                if pwr_prop_req - em_pwr > fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff {
909                    // engine needs to run higher than peak efficiency point
910                    pwr_prop_req - em_pwr
911                } else {
912                    // engine does not need to run higher than peak
913                    // efficiency point to make tractive demand
914
915                    // fc handles all power not covered by em
916                    (pwr_prop_req - em_pwr)
917                        // and if that's less than the
918                        // efficiency-focused value, then operate at
919                        // that value
920                        .max(fc.pwr_for_peak_eff * frac_of_pwr_for_peak_eff)
921                        // but don't exceed what what the battery can
922                        // absorb + tractive demand
923                        .min(
924                            pwr_prop_req
925                                + *em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?,
926                        )
927                }
928            }
929            // and don't exceed what the fc can do
930            .min(*fc.state.pwr_prop_max.get_fresh(|| format_dbg!())?);
931
932            // recalculate `em_pwr` based on `fc_pwr`
933            let em_pwr_corrected = (pwr_prop_req - fc_pwr)
934                .max(-*em_state.pwr_mech_regen_max.get_fresh(|| format_dbg!())?);
935            (fc_pwr, em_pwr_corrected)
936        };
937        Ok((fc_pwr, em_pwr))
938    }
939
940    fn handle_fc_on_causes(
941        &mut self,
942        fc: &FuelConverter,
943        veh_state: &VehicleState,
944        res: &ReversibleEnergyStorage,
945        em_state: &ElectricMachineState,
946    ) -> Result<(), anyhow::Error> {
947        self.handle_fc_on_causes_for_temp(fc)?;
948        self.handle_fc_on_causes_for_speed(veh_state)?;
949        self.handle_fc_on_causes_for_low_soc(res, veh_state)?;
950        self.handle_fc_on_causes_for_pwr_demand(
951            *veh_state
952                .pwr_tractive
953                .get_stale(|| format_dbg!(veh_state.pwr_tractive))?,
954            em_state,
955            &fc.state,
956        )
957        .with_context(|| format_dbg!())?;
958        self.handle_fc_on_causes_for_on_time(fc)?;
959        Ok(())
960    }
961
962    fn handle_fc_on_causes_for_on_time(&mut self, fc: &FuelConverter) -> Result<(), anyhow::Error> {
963        self.state.on_time_too_short.update(*fc.state.fc_on.get_stale(|| format_dbg!())? && *fc.state.time_on.get_stale(|| format_dbg!())?
964                    < self.fc_min_time_on.with_context(|| {
965                    anyhow!(
966                        "{}\n Expected `ResGreedyWithBuffers::init` to have been called beforehand.",
967                        format_dbg!()
968                    )
969                })?, || format_dbg!())?;
970        Ok(())
971    }
972
973    /// Determines whether power demand requires engine to be on.  Not needed during
974    /// negative traction.
975    fn handle_fc_on_causes_for_pwr_demand(
976        &mut self,
977        pwr_out_req_for_cyc: si::Power,
978        em_state: &ElectricMachineState,
979        fc_state: &FuelConverterState,
980    ) -> Result<(), anyhow::Error> {
981        let frac_pwr_demand_fc_forced_on: si::Ratio = self
982            .frac_pwr_demand_fc_forced_on
983            .with_context(|| format_dbg!())?;
984        self.state.propulsion_power_demand_soft.update(
985            pwr_out_req_for_cyc
986                > frac_pwr_demand_fc_forced_on
987                    * (*em_state.pwr_mech_fwd_out_max.get_stale(|| format_dbg!())?
988                        + *fc_state.pwr_out_max.get_stale(|| format_dbg!())?),
989            || format_dbg!(),
990        )?;
991        self.state.propulsion_power_demand.update(
992            pwr_out_req_for_cyc - *em_state.pwr_mech_fwd_out_max.get_stale(|| format_dbg!())?
993                >= si::Power::ZERO,
994            || format_dbg!(),
995        )?;
996        Ok(())
997    }
998
999    /// Detemrines whether engine must be on to charge battery
1000    fn handle_fc_on_causes_for_low_soc(
1001        &mut self,
1002        res: &ReversibleEnergyStorage,
1003        veh_state: &VehicleState,
1004    ) -> anyhow::Result<()> {
1005        self.state.soc_fc_on_buffer.update(
1006            {
1007                let energy_delta_to_buffer_speed: si::Energy = 0.5
1008                    * *veh_state.mass.get_fresh(|| format_dbg!())?
1009                    * (self
1010                        .speed_soc_fc_on_buffer
1011                        .with_context(|| format_dbg!())?
1012                        .powi(P2::new())
1013                        - veh_state
1014                            .speed_ach
1015                            .get_stale(|| format_dbg!())?
1016                            .powi(P2::new()));
1017                energy_delta_to_buffer_speed.max(si::Energy::ZERO)
1018                    * self
1019                        .speed_soc_fc_on_buffer_coeff
1020                        .with_context(|| format_dbg!())?
1021            } / res.energy_capacity_usable()
1022                + res.min_soc,
1023            || format_dbg!(),
1024        )?;
1025        self.state.charging_for_low_soc.update(
1026            *res.state.soc.get_stale(|| format_dbg!())?
1027                < *self.state.soc_fc_on_buffer.get_fresh(|| format_dbg!())?,
1028            || format_dbg!(),
1029        )?;
1030        Ok(())
1031    }
1032
1033    /// Determines whether enigne must be on for high speed
1034    fn handle_fc_on_causes_for_speed(&mut self, veh_state: &VehicleState) -> anyhow::Result<()> {
1035        self.state.vehicle_speed_too_high.update(
1036            *veh_state.speed_ach.get_stale(|| format_dbg!())?
1037                > self.speed_fc_forced_on.with_context(|| format_dbg!())?,
1038            || format_dbg!(),
1039        )?;
1040        Ok(())
1041    }
1042
1043    /// Determines whether engine needs to be on due to low temperature and pushes
1044    /// appropriate variant to `fc_on_causes`
1045    fn handle_fc_on_causes_for_temp(&mut self, fc: &FuelConverter) -> anyhow::Result<()> {
1046        match (
1047            match fc.temperature() {
1048                Some(fct) => Some(*fct.get_fresh(|| format_dbg!())?),
1049                None => None,
1050            },
1051            match fc.temperature() {
1052                Some(fct) => Some(*fct.get_fresh(|| format_dbg!())?),
1053                None => None,
1054            },
1055            self.temp_fc_forced_on,
1056            self.temp_fc_allowed_off,
1057        ) {
1058            (None, None, None, None) => {
1059                self.state
1060                    .fc_temperature_too_low
1061                    .update(false, || format_dbg!())?;
1062            }
1063            (
1064                Some(temperature),
1065                Some(temp_prev),
1066                Some(temp_fc_forced_on),
1067                Some(temp_fc_allowed_off),
1068            ) => {
1069                self.state.fc_temperature_too_low.update(
1070                    // temperature is currently below forced on threshold
1071                    temperature < temp_fc_forced_on ||
1072            // temperature was below forced on threshold and still has not exceeded allowed off threshold
1073            (temp_prev < temp_fc_forced_on && temperature < temp_fc_allowed_off),
1074                    || format_dbg!(),
1075                )?;
1076            }
1077            _ => {
1078                bail!(
1079                    "{}\n`fc.temperature()`, `fc.temp_prev()`, `self.temp_fc_forced_on`, and 
1080`self.temp_fc_allowed_off` must all be `None` or `Some` because these controls are necessary
1081for an HEV equipped with thermal models or superfluous otherwise",
1082                    format_dbg!((
1083                        fc.temperature(),
1084                        self.temp_fc_forced_on,
1085                        self.temp_fc_allowed_off
1086                    ))
1087                );
1088            }
1089        }
1090        Ok(())
1091    }
1092}