fastsim_core/vehicle/powertrain/
fuel_converter.rs

1use super::utils::ScalingMethods;
2use super::*;
3use crate::prelude::*;
4use crate::utils::interp::InterpolatorMutMethods;
5use std::f64::consts::PI;
6
7// TODO: think about how to incorporate life modeling for Fuel Cells and other tech
8
9#[serde_api]
10#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, StateMethods, SetCumulative)]
11/// Struct for modeling [FuelConverter] (e.g. engine, fuel cell.) thermal plant
12#[non_exhaustive]
13#[serde(deny_unknown_fields)]
14#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
15pub struct FuelConverter {
16    /// [Self] Thermal plant, including thermal management controls
17    #[serde(default)]
18    #[has_state]
19    pub thrml: FuelConverterThermalOption,
20    /// [Self] mass
21    #[serde(default)]
22    pub(in super::super) mass: Option<si::Mass>,
23    /// FuelConverter specific power
24    pub(in super::super) specific_pwr: Option<si::SpecificPower>,
25    /// max rated brake output power
26    pub pwr_out_max: si::Power,
27    /// starting/baseline transient power limit
28    #[serde(default)]
29    pub pwr_out_max_init: si::Power,
30    // TODO: consider a ramp down rate, which may be needed for fuel cells
31    /// lag time for ramp up
32    pub pwr_ramp_lag: si::Time,
33    /// interpolator for calculating [Self] efficiency as a function of output power
34    pub eff_interp_from_pwr_out: InterpolatorEnumOwned<f64>,
35    /// power at which peak efficiency occurs
36    #[serde(skip)]
37    pub(crate) pwr_for_peak_eff: si::Power,
38    /// idle fuel power to overcome internal friction (not including aux load) \[W\]
39    pub pwr_idle_fuel: si::Power,
40    /// time step interval between saves. 1 is a good option. If None, no saving occurs.
41    pub save_interval: Option<usize>,
42    /// struct for tracking current state
43    #[serde(default)]
44    pub state: FuelConverterState,
45    /// Custom vector of [Self::state]
46    #[serde(default)]
47    pub history: FuelConverterStateHistoryVec,
48}
49
50#[pyo3_api]
51impl FuelConverter {
52    // optional, custom, struct-specific pymethods
53    #[getter("eff_max")]
54    fn get_eff_max_py(&self) -> PyResult<f64> {
55        Ok(*self.get_eff_max()?)
56    }
57
58    #[setter("__eff_max")]
59    fn set_eff_max_py(&mut self, eff_max: f64) -> PyResult<()> {
60        Ok(self.set_eff_max(eff_max, None)?)
61    }
62
63    #[getter("eff_min")]
64    fn get_eff_min_py(&self) -> PyResult<f64> {
65        Ok(*self.get_eff_min()?)
66    }
67
68    #[setter("__eff_min")]
69    fn set_eff_min_py(&mut self, eff_min: f64) -> PyResult<()> {
70        Ok(self.set_eff_min(eff_min, None)?)
71    }
72
73    #[setter("__eff_range")]
74    fn set_eff_range_py(&mut self, eff_range: f64) -> PyResult<()> {
75        self.set_eff_range(eff_range)?;
76        Ok(())
77    }
78
79    // TODO: handle `side_effects` and uncomment
80    // #[setter("__mass_kg")]
81    // fn set_mass_py(&mut self, mass_kg: Option<f64>) -> anyhow::Result<()> {
82    //     self.set_mass(mass_kg.map(|m| m * uc::KG))?;
83    //     Ok(())
84    // }
85
86    #[getter("mass_kg")]
87    fn get_mass_py(&self) -> PyResult<Option<f64>> {
88        Ok(self.mass()?.map(|m| m.get::<si::kilogram>()))
89    }
90
91    #[getter]
92    fn get_specific_pwr_kw_per_kg(&self) -> Option<f64> {
93        self.specific_pwr
94            .map(|x| x.get::<si::kilowatt_per_kilogram>())
95    }
96}
97
98impl SerdeAPI for FuelConverter {}
99impl Init for FuelConverter {
100    fn init(&mut self) -> Result<(), Error> {
101        let _ = self
102            .mass()
103            .map_err(|err| Error::InitError(format_dbg!(err)))?;
104        self.thrml.init()?;
105        self.state
106            .init()
107            .map_err(|err| Error::InitError(format_dbg!(err)))?;
108        let eff_max = self
109            .get_eff_max()
110            .map_err(|err| Error::InitError(format_dbg!(err)))?;
111        self.pwr_for_peak_eff = match &self.eff_interp_from_pwr_out {
112            InterpolatorEnum::Interp1D(interp) => *interp.data.grid[0]
113                .get(
114                    interp
115                        .data
116                        .values
117                        .iter()
118                        .position(|eff| eff == eff_max)
119                        .ok_or_else(|| Error::InitError(format_dbg!()))?,
120                )
121                .ok_or_else(|| Error::InitError(format_dbg!()))?,
122            _ => {
123                return Err(Error::InitError(format_dbg!(
124                    "Only 1-D interpolators are supported"
125                )))
126            }
127        } * self.pwr_out_max;
128        Ok(())
129    }
130}
131impl HistoryMethods for FuelConverter {
132    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
133        Ok(self.save_interval)
134    }
135    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
136        self.save_interval = save_interval;
137        self.thrml.set_save_interval(save_interval)?;
138        Ok(())
139    }
140    fn clear(&mut self) {
141        self.history.clear();
142        self.thrml.clear();
143    }
144}
145
146impl Mass for FuelConverter {
147    fn mass(&self) -> anyhow::Result<Option<si::Mass>> {
148        let derived_mass = self
149            .derived_mass()
150            .with_context(|| anyhow!(format_dbg!()))?;
151        if let (Some(derived_mass), Some(set_mass)) = (derived_mass, self.mass) {
152            ensure!(
153                utils::almost_eq_uom(&set_mass, &derived_mass, None),
154                format!(
155                    "{}",
156                    format_dbg!(utils::almost_eq_uom(&set_mass, &derived_mass, None)),
157                )
158            );
159        }
160        Ok(self.mass)
161    }
162
163    fn set_mass(
164        &mut self,
165        new_mass: Option<si::Mass>,
166        side_effect: MassSideEffect,
167    ) -> anyhow::Result<()> {
168        let derived_mass = self
169            .derived_mass()
170            .with_context(|| anyhow!(format_dbg!()))?;
171        if let (Some(derived_mass), Some(new_mass)) = (derived_mass, new_mass) {
172            if derived_mass != new_mass {
173                match side_effect {
174                    MassSideEffect::Extensive => {
175                        self.pwr_out_max = self.specific_pwr.ok_or_else(|| {
176                            anyhow!(
177                                "{}\nExpected `self.specific_pwr` to be `Some`.",
178                                format_dbg!()
179                            )
180                        })? * new_mass;
181                    }
182                    MassSideEffect::Intensive => {
183                        self.specific_pwr = Some(self.pwr_out_max / new_mass);
184                    }
185                    MassSideEffect::None => {
186                        self.specific_pwr = None;
187                    }
188                }
189            }
190        } else if new_mass.is_none() {
191            self.specific_pwr = None;
192        }
193        self.mass = new_mass;
194        Ok(())
195    }
196
197    fn derived_mass(&self) -> anyhow::Result<Option<si::Mass>> {
198        Ok(self
199            .specific_pwr
200            .map(|specific_pwr| self.pwr_out_max / specific_pwr))
201    }
202
203    fn expunge_mass_fields(&mut self) {
204        self.mass = None;
205        self.specific_pwr = None;
206    }
207}
208
209// non-py methods
210impl FuelConverter {
211    /// Sets maximum possible total power [FuelConverter]
212    /// can produce.
213    /// # Arguments
214    /// - `dt`: simulation time step size
215    pub fn set_curr_pwr_out_max(&mut self, dt: si::Time) -> anyhow::Result<()> {
216        if self.pwr_out_max_init == si::Power::ZERO {
217            // TODO: think about how to initialize power
218            self.pwr_out_max_init = self.pwr_out_max / 10.
219        };
220        let pwr_out_max = (*self.state.pwr_prop.get_stale(|| format_dbg!())?
221            + *self.state.pwr_aux.get_stale(|| format_dbg!())?
222            + self.pwr_out_max / self.pwr_ramp_lag * dt)
223            .min(self.pwr_out_max)
224            .max(self.pwr_out_max_init);
225        self.state
226            .pwr_out_max
227            .update(pwr_out_max, || format_dbg!())?;
228        Ok(())
229    }
230
231    /// Sets maximum possible propulsion-related power [FuelConverter]
232    /// can produce, accounting for any aux-related power required.
233    /// # Arguments
234    /// - `pwr_aux`: aux-related power required from this component
235    pub fn set_curr_pwr_prop_max(&mut self, pwr_aux: si::Power) -> anyhow::Result<()> {
236        ensure!(
237            pwr_aux >= si::Power::ZERO,
238            format!(
239                "{}\n`pwr_aux` must be >= 0",
240                format_dbg!(pwr_aux >= si::Power::ZERO),
241            )
242        );
243        self.state.pwr_aux.update(pwr_aux, || format_dbg!())?;
244        self.state.pwr_prop_max.update(
245            *self.state.pwr_out_max.get_fresh(|| format_dbg!())? - pwr_aux,
246            || format_dbg!(),
247        )?;
248        Ok(())
249    }
250
251    /// Solves for this powertrain system/component efficiency and sets/returns power output values.
252    /// # Arguments
253    /// - `pwr_out_req`: tractive power output required to achieve presribed speed
254    /// - `fc_on`: whether component is actively running
255    /// - `dt`: simulation time step size
256    pub fn solve(
257        &mut self,
258        pwr_out_req: si::Power,
259        fc_on: bool,
260        dt: si::Time,
261    ) -> anyhow::Result<()> {
262        self.state.fc_on.update(fc_on, || format_dbg!())?;
263        if fc_on {
264            self.state.time_on.increment(dt, || format_dbg!())?;
265        } else {
266            self.state
267                .time_on
268                .update(si::Time::ZERO, || format_dbg!())?;
269        }
270        // NOTE: think about the possibility of engine braking, not urgent
271        ensure!(
272            pwr_out_req >= si::Power::ZERO,
273            format!(
274                "{}\n`pwr_out_req` must be >= 0",
275                format_dbg!(pwr_out_req >= si::Power::ZERO),
276            )
277        );
278        ensure!(
279            pwr_out_req <= *self.state.pwr_prop_max.get_fresh(|| format_dbg!())?,
280            format!(
281                "{}\n`pwr_out_req` ({} W) must be < `self.state.pwr_prop_max` ({} W)",
282                format_dbg!(),
283                pwr_out_req.get::<si::watt>().format_eng(Some(5)),
284                self.state
285                    .pwr_prop_max
286                    .get_fresh(|| format_dbg!())?
287                    .get::<si::watt>()
288                    .format_eng(Some(5))
289            )
290        );
291        // if the engine is not on, `pwr_out_req` should be 0.0
292        ensure!(
293            fc_on || (pwr_out_req == si::Power::ZERO && *self.state.pwr_aux.get_fresh(|| format_dbg!())? == si::Power::ZERO),
294            format!(
295                "{}\nEngine is off but pwr_out_req + pwr_aux is non-zero\n`pwr_out_req`: {} kW\n`self.state.pwr_aux`: {} kW",
296                format_dbg!(
297                    fc_on
298                        || (pwr_out_req == si::Power::ZERO
299                            && *self.state.pwr_aux.get_fresh(|| format_dbg!())? == si::Power::ZERO)
300                ),
301               pwr_out_req.get::<si::kilowatt>(),
302               self.state.pwr_aux.get_fresh(|| format_dbg!())?.get::<si::kilowatt>()
303            )
304        );
305        self.state.pwr_prop.update(pwr_out_req, || format_dbg!())?;
306        self.state.eff.update(
307            if fc_on {
308                uc::R
309                    * self
310                        .eff_interp_from_pwr_out
311                        .interpolate(&[((pwr_out_req
312                            + *self.state.pwr_aux.get_fresh(|| format_dbg!())?)
313                            / self.pwr_out_max)
314                            .get::<si::ratio>()])
315                        .with_context(|| {
316                            anyhow!(
317                                "{}\n failed to calculate {}",
318                                format_dbg!(),
319                                stringify!(self.state.eff)
320                            )
321                        })?
322            } else {
323                si::Ratio::ZERO
324            } * match self.thrml.temp_eff_coeff() {
325                Some(tec) => *tec.get_fresh(|| format_dbg!())?,
326                None => 1.0 * uc::R,
327            },
328            || format_dbg!(),
329        )?;
330        ensure!(
331            (*self.state.eff.get_fresh(|| format_dbg!())? >= 0.0 * uc::R
332                && *self.state.eff.get_fresh(|| format_dbg!())? <= 1.0 * uc::R),
333            format!(
334                "fc efficiency ({}) must be either between 0 and 1",
335                self.state
336                    .eff
337                    .get_fresh(|| format_dbg!())?
338                    .get::<si::ratio>()
339            )
340        );
341
342        self.state.pwr_fuel.update(
343            if *self.state.fc_on.get_fresh(|| format_dbg!())? {
344                ((pwr_out_req + *self.state.pwr_aux.get_fresh(|| format_dbg!())?)
345                    / *self.state.eff.get_fresh(|| format_dbg!())?)
346                .max(self.pwr_idle_fuel)
347            } else {
348                si::Power::ZERO
349            },
350            || format_dbg!(),
351        )?;
352        self.state.pwr_loss.update(
353            *self.state.pwr_fuel.get_fresh(|| format_dbg!())?
354                - *self.state.pwr_prop.get_fresh(|| format_dbg!())?,
355            || format_dbg!(),
356        )?;
357
358        // TODO: put this in `SetCumulative::set_custom_cumulative`
359        // ensure!(
360        //     self.state.energy_loss.get::<si::joule>() >= 0.0,
361        //     format!(
362        //         "{}\nEnergy loss must be non-negative",
363        //         format_dbg!(self.state.energy_loss.get::<si::joule>() >= 0.0)
364        //     )
365        // );
366        Ok(())
367    }
368
369    pub fn solve_thermal(
370        &mut self,
371        te_amb: si::Temperature,
372        pwr_thrml_fc_to_cab: Option<si::Power>,
373        veh_state: &mut VehicleState,
374        dt: si::Time,
375    ) -> anyhow::Result<()> {
376        let veh_speed = *veh_state.speed_ach.get_stale(|| format_dbg!())?;
377        self.thrml
378            .solve_thermal(&self.state, te_amb, pwr_thrml_fc_to_cab, veh_speed, dt)
379            .with_context(|| format_dbg!())
380    }
381
382    /// If thermal model is appropriately configured, returns current lumped [Self] temperature
383    pub fn temperature(&self) -> Option<&TrackedState<si::Temperature>> {
384        match &self.thrml {
385            FuelConverterThermalOption::FuelConverterThermal(fct) => Some(&fct.state.temperature),
386            FuelConverterThermalOption::None => None,
387        }
388    }
389
390    /// Returns max value of [Self::eff_interp_from_pwr_out]
391    pub fn get_eff_max(&self) -> anyhow::Result<&f64> {
392        self.eff_interp_from_pwr_out.max()
393    }
394
395    /// Returns min value of [Self::eff_interp_from_pwr_out]
396    pub fn get_eff_min(&self) -> anyhow::Result<&f64> {
397        self.eff_interp_from_pwr_out.min()
398    }
399
400    /// Scales eff_interp_fwd and eff_interp_bwd by ratio of new `eff_max` per
401    /// current calculated max (Note: this may change eff_min)
402    pub fn set_eff_max(
403        &mut self,
404        eff_max: f64,
405        scaling: Option<ScalingMethods>,
406    ) -> anyhow::Result<()> {
407        if (0.0..=1.0).contains(&eff_max) {
408            self.eff_interp_from_pwr_out.set_max(eff_max, scaling)?;
409        } else {
410            return Err(anyhow!(
411                "`eff_max` ({:.3}) must be between 0.0 and 1.0",
412                eff_max,
413            ));
414        }
415        // to update any dependent fields
416        self.init().map_err(|err| anyhow!("{:?}", err))?;
417        Ok(())
418    }
419
420    /// Scales eff_interp_fwd and eff_interp_bwd by ratio of new `eff_min` per
421    /// current calculated min (Note: this may change eff_max)
422    pub fn set_eff_min(
423        &mut self,
424        eff_min: f64,
425        scaling: Option<ScalingMethods>,
426    ) -> anyhow::Result<()> {
427        self.eff_interp_from_pwr_out.set_min(eff_min, scaling)
428    }
429
430    /// Scales values of `eff_interp_fwd.f_x` and `eff_interp_bwd.f_x` without
431    /// changing max such that max - min is equal to new range.  Will change max
432    /// if needed to ensure no values are less than zero.
433    pub fn set_eff_range(&mut self, eff_range: f64) -> anyhow::Result<()> {
434        if (0. ..=1.0).contains(&eff_range) {
435            self.eff_interp_from_pwr_out.set_range(eff_range)
436        } else {
437            Err(anyhow!(format!(
438                "`eff_range` ({:.3}) must be between 0.0 and 1.0",
439                eff_range,
440            )))
441        }
442    }
443
444    pub fn fc_thrml_state_mut(&mut self) -> Option<&mut FuelConverterThermalState> {
445        match &mut self.thrml {
446            FuelConverterThermalOption::FuelConverterThermal(fct) => Some(&mut fct.state),
447            FuelConverterThermalOption::None => None,
448        }
449    }
450}
451
452impl TryFrom<fastsim_2::vehicle::RustVehicle> for FuelConverter {
453    type Error = anyhow::Error;
454    fn try_from(f2veh: fastsim_2::vehicle::RustVehicle) -> Result<FuelConverter, anyhow::Error> {
455        let mut fc: FuelConverter = FCBuilder {
456            pwr_out_max: f2veh.fc_max_kw * uc::KW,
457            pwr_ramp_lag: f2veh.fc_sec_to_peak_pwr * uc::S,
458            eff_interp_from_pwr_out: InterpolatorEnum::new_1d(
459                // hard-coded vec from fastsim-2
460                vec![
461                    0.0, 0.005, 0.015, 0.04, 0.06, 0.1, 0.14, 0.2, 0.4, 0.6, 0.8, 1.0,
462                ]
463                .into(),
464                f2veh.fc_eff_map.clone().into(),
465                strategy::Linear,
466                Extrapolate::Error,
467            )
468            .with_context(|| format_dbg!())?,
469            pwr_for_peak_eff: uc::KW * f64::NAN, // this gets updated in `init`
470            // this means that aux power must include idle fuel
471            pwr_idle_fuel: si::Power::ZERO,
472            save_interval: Some(1),
473        }
474        .try_into()
475        .with_context(|| format_dbg!())?;
476        fc.init()?;
477        fc.set_mass(None, MassSideEffect::None)
478            .with_context(|| anyhow!(format_dbg!()))?;
479        Ok(fc)
480    }
481}
482
483impl TryFrom<FCBuilder> for FuelConverter {
484    type Error = anyhow::Error;
485    fn try_from(fcbuilder: FCBuilder) -> Result<FuelConverter, anyhow::Error> {
486        let mut fc = FuelConverter {
487            state: Default::default(),
488            thrml: Default::default(),
489            mass: None,
490            specific_pwr: None,
491            pwr_out_max: fcbuilder.pwr_out_max,
492            // assumes 1 s time step
493            pwr_out_max_init: fcbuilder.pwr_out_max / fcbuilder.pwr_ramp_lag.get::<si::second>(),
494            pwr_ramp_lag: fcbuilder.pwr_ramp_lag,
495            eff_interp_from_pwr_out: fcbuilder.eff_interp_from_pwr_out,
496            pwr_for_peak_eff: uc::KW * f64::NAN, // this gets updated in `init`
497            // TODO: make a function for setting this according with below line
498            // this means that aux power must include idle fuel
499            pwr_idle_fuel: si::Power::ZERO,
500            save_interval: Some(1),
501            history: Default::default(),
502        };
503        fc.init()?;
504        fc.set_mass(None, MassSideEffect::None)
505            .with_context(|| anyhow!(format_dbg!()))?;
506        Ok(fc)
507    }
508}
509
510#[serde_api]
511#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
512#[serde(deny_unknown_fields)]
513#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
514/// Builder for [FuelConverter].  Use this to instantiate EM with minimal parameterization
515pub struct FCBuilder {
516    pub pwr_out_max: si::Power,
517    // TODO: consider a ramp down rate, which may be needed for fuel cells
518    /// lag time for ramp up
519    pub pwr_ramp_lag: si::Time,
520    /// interpolator for calculating [Self] efficiency as a function of output power
521    pub eff_interp_from_pwr_out: InterpolatorEnumOwned<f64>,
522    /// power at which peak efficiency occurs
523    #[serde(skip)]
524    pub(crate) pwr_for_peak_eff: si::Power,
525    /// idle fuel power to overcome internal friction (not including aux load) \[W\]
526    pub pwr_idle_fuel: si::Power,
527    /// time step interval between saves. 1 is a good option. If None, no saving occurs.
528    pub save_interval: Option<usize>,
529}
530
531#[serde_api]
532#[derive(
533    Clone,
534    Debug,
535    Default,
536    Deserialize,
537    Serialize,
538    PartialEq,
539    HistoryVec,
540    StateMethods,
541    SetCumulative,
542)]
543#[non_exhaustive]
544#[serde(default)]
545#[serde(deny_unknown_fields)]
546#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
547pub struct FuelConverterState {
548    /// time step index
549    pub i: TrackedState<usize>,
550    /// max total output power fc can produce at current time
551    pub pwr_out_max: TrackedState<si::Power>,
552    /// max propulsion power fc can produce at current time
553    pub pwr_prop_max: TrackedState<si::Power>,
554    /// efficiency evaluated at current demand
555    pub eff: TrackedState<si::Ratio>,
556    /// instantaneous power going to drivetrain, not including aux
557    pub pwr_prop: TrackedState<si::Power>,
558    /// integral of [Self::pwr_prop]
559    pub energy_prop: TrackedState<si::Energy>,
560    /// power going to auxiliaries
561    pub pwr_aux: TrackedState<si::Power>,
562    /// Integral of [Self::pwr_aux]
563    pub energy_aux: TrackedState<si::Energy>,
564    /// instantaneous fuel power flow
565    pub pwr_fuel: TrackedState<si::Power>,
566    /// Integral of [Self::pwr_fuel]
567    pub energy_fuel: TrackedState<si::Energy>,
568    /// loss power, including idle
569    pub pwr_loss: TrackedState<si::Power>,
570    /// Integral of [Self::pwr_loss]
571    pub energy_loss: TrackedState<si::Energy>,
572    /// If true, engine is on, and if false, off (no idle)
573    pub fc_on: TrackedState<bool>,
574    /// Time the engine has been on
575    pub time_on: TrackedState<si::Time>,
576}
577
578#[pyo3_api]
579impl FuelConverterState {}
580impl SerdeAPI for FuelConverterState {}
581impl Init for FuelConverterState {}
582
583/// Options for handling [FuelConverter] thermal model
584#[derive(
585    Clone, Default, Debug, Serialize, Deserialize, PartialEq, IsVariant, derive_more::From, TryInto,
586)]
587pub enum FuelConverterThermalOption {
588    /// Basic thermal plant for [FuelConverter]
589    FuelConverterThermal(Box<FuelConverterThermal>),
590    /// no thermal plant for [FuelConverter]
591    #[default]
592    None,
593}
594
595impl StateMethods for FuelConverterThermalOption {}
596
597impl SaveState for FuelConverterThermalOption {
598    fn save_state<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
599        match self {
600            Self::FuelConverterThermal(fct) => fct.save_state(loc)?,
601            Self::None => {}
602        }
603        Ok(())
604    }
605}
606impl TrackedStateMethods for FuelConverterThermalOption {
607    fn check_and_reset<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
608        match self {
609            Self::FuelConverterThermal(fct) => {
610                fct.check_and_reset(|| format!("{}\n{}", loc(), format_dbg!()))?
611            }
612            Self::None => {}
613        }
614        Ok(())
615    }
616
617    fn mark_fresh<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
618        match self {
619            Self::FuelConverterThermal(fct) => {
620                fct.mark_fresh(|| format!("{}\n{}", loc(), format_dbg!()))?
621            }
622            Self::None => {}
623        }
624        Ok(())
625    }
626}
627impl Step for FuelConverterThermalOption {
628    fn step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
629        match self {
630            Self::FuelConverterThermal(fct) => fct.step(|| format!("{}\n{}", loc(), format_dbg!())),
631            Self::None => Ok(()),
632        }
633    }
634
635    fn reset_step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
636        match self {
637            Self::FuelConverterThermal(fct) => {
638                fct.reset_step(|| format!("{}\n{}", loc(), format_dbg!()))
639            }
640            Self::None => Ok(()),
641        }
642    }
643}
644impl Init for FuelConverterThermalOption {
645    fn init(&mut self) -> Result<(), Error> {
646        match self {
647            Self::FuelConverterThermal(fct) => fct.init()?,
648            Self::None => {}
649        }
650        Ok(())
651    }
652}
653impl SerdeAPI for FuelConverterThermalOption {}
654impl SetCumulative for FuelConverterThermalOption {
655    fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
656        match self {
657            Self::FuelConverterThermal(fct) => {
658                fct.set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?
659            }
660            Self::None => {}
661        }
662        Ok(())
663    }
664
665    fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
666        match self {
667            Self::FuelConverterThermal(fct) => {
668                fct.reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?
669            }
670            Self::None => {}
671        }
672        Ok(())
673    }
674}
675impl HistoryMethods for FuelConverterThermalOption {
676    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
677        match self {
678            FuelConverterThermalOption::FuelConverterThermal(fct) => fct.save_interval(),
679            FuelConverterThermalOption::None => Ok(None),
680        }
681    }
682    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
683        match self {
684            FuelConverterThermalOption::FuelConverterThermal(fct) => {
685                fct.set_save_interval(save_interval)
686            }
687            FuelConverterThermalOption::None => Ok(()),
688        }
689    }
690    fn clear(&mut self) {
691        match self {
692            FuelConverterThermalOption::FuelConverterThermal(fct) => {
693                fct.clear();
694            }
695            FuelConverterThermalOption::None => {}
696        }
697    }
698}
699impl FuelConverterThermalOption {
700    /// Solve change in temperature and other thermal effects
701    /// # Arguments
702    /// - `fc_state`: [FuelConverter] state
703    /// - `te_amb`: ambient temperature
704    /// - `pwr_thrml_fc_to_cab`: heat demand from [Vehicle::hvac] system -- zero if `None` is passed
705    /// - `veh_speed`: current achieved speed
706    fn solve_thermal(
707        &mut self,
708        fc_state: &FuelConverterState,
709        te_amb: si::Temperature,
710        pwr_thrml_fc_to_cab: Option<si::Power>,
711        veh_speed: si::Velocity,
712        dt: si::Time,
713    ) -> anyhow::Result<()> {
714        match self {
715            Self::FuelConverterThermal(fct) => fct
716                .solve(
717                    fc_state,
718                    te_amb,
719                    pwr_thrml_fc_to_cab.unwrap_or_default(),
720                    veh_speed,
721                    dt,
722                )
723                .with_context(|| format_dbg!())?,
724            Self::None => {
725                ensure!(
726                    pwr_thrml_fc_to_cab.is_none(),
727                    format_dbg!(
728                        "`FuelConverterThermal needs to be configured to provide heat demand`"
729                    )
730                );
731            }
732        }
733        Ok(())
734    }
735
736    /// If appropriately configured, returns temperature-dependent efficiency coefficient
737    fn temp_eff_coeff(&self) -> Option<&TrackedState<si::Ratio>> {
738        match self {
739            Self::FuelConverterThermal(fct) => Some(&fct.state.eff_coeff),
740            Self::None => None,
741        }
742    }
743}
744
745#[serde_api]
746#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, StateMethods)]
747#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
748#[non_exhaustive]
749#[serde(deny_unknown_fields)]
750/// Struct for modeling Fuel Converter (e.g. engine, fuel cell.)
751pub struct FuelConverterThermal {
752    /// [FuelConverter] thermal capacitance
753    pub heat_capacitance: si::HeatCapacity,
754    /// parameter for engine characteristic length for heat transfer calcs
755    pub length_for_convection: si::Length,
756    /// parameter for heat transfer coeff from [FuelConverter] to ambient during vehicle stop
757    pub htc_to_amb_stop: si::HeatTransferCoeff,
758
759    /// Heat transfer coefficient between adiabatic flame temperature and [FuelConverterThermal] temperature
760    pub conductance_from_comb: si::ThermalConductance,
761    /// Max coefficient for fraction of combustion heat that goes to [FuelConverter]
762    /// (engine) thermal mass. Remainder goes to environment (e.g. via tailpipe).
763    pub max_frac_from_comb: si::Ratio,
764    /// parameter for temperature at which thermostat starts to open
765    pub tstat_te_sto: Option<si::Temperature>,
766    /// temperature delta over which thermostat is partially open
767    pub tstat_te_delta: Option<si::TemperatureInterval>,
768    #[serde(default = "tstat_interp_default")]
769    pub tstat_interp: Interp1DOwned<f64, strategy::Linear>,
770    /// Radiator effectiveness -- ratio of active heat rejection from
771    /// radiator to passive heat rejection, always greater than 1
772    pub radiator_effectiveness: si::Ratio,
773    /// Model for [FuelConverter] dependence on efficiency
774    pub fc_eff_model: FCTempEffModel,
775    /// struct for tracking current state
776    #[serde(default)]
777    pub state: FuelConverterThermalState,
778    /// Custom vector of [Self::state]
779    #[serde(default)]
780    pub history: FuelConverterThermalStateHistoryVec,
781    pub save_interval: Option<usize>,
782}
783
784#[pyo3_api]
785impl FuelConverterThermal {
786    #[staticmethod]
787    #[pyo3(name = "default")]
788    fn default_py() -> Self {
789        Default::default()
790    }
791}
792
793impl HistoryMethods for FuelConverterThermal {
794    fn save_interval(&self) -> anyhow::Result<Option<usize>> {
795        Ok(self.save_interval)
796    }
797    fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
798        self.save_interval = save_interval;
799        Ok(())
800    }
801    fn clear(&mut self) {
802        self.history.clear();
803    }
804}
805
806/// Dummy interpolator that will be overridden in [FuelConverterThermal::init]
807fn tstat_interp_default() -> Interp1DOwned<f64, strategy::Linear> {
808    Interp1D::new(
809        array![85.0, 90.0],
810        array![0.0, 1.0],
811        strategy::Linear,
812        Extrapolate::Clamp,
813    )
814    .unwrap()
815}
816
817lazy_static! {
818    /// gasoline stoichiometric air-fuel ratio https://en.wikipedia.org/wiki/Air%E2%80%93fuel_ratio
819    pub static ref AFR_STOICH_GASOLINE: si::Ratio = uc::R * 14.7;
820    /// gasoline density in https://inchem.org/documents/icsc/icsc/eics1400.htm
821    /// This is reasonably constant with respect to temperature and pressure
822    pub static ref GASOLINE_DENSITY: si::MassDensity = 0.75 * uc::KG / uc::L;
823    /// TODO: find a source for this value
824    pub static ref GASOLINE_LHV: si::SpecificEnergy = 33.7 * uc::KWH / uc::GALLON / *GASOLINE_DENSITY;
825    pub static ref TE_ADIABATIC_STD: si::Temperature = Air::get_te_from_u(
826            Air::get_specific_energy(*TE_STD_AIR).with_context(|| format_dbg!()).unwrap()
827                + (Octane::get_specific_energy(*TE_STD_AIR).with_context(|| format_dbg!()).unwrap()
828                    + *GASOLINE_LHV)
829                    / *AFR_STOICH_GASOLINE,
830        )
831        .with_context(|| format_dbg!()).unwrap_or_else(|_| panic!("{}\nFailed to calculate adiabatic flame temp for gasoline", format_dbg!()));
832}
833
834impl FuelConverterThermal {
835    /// Solve change in temperature and other thermal effects
836    /// # Arguments
837    /// - `fc_state`: [FuelConverter] state
838    /// - `te_amb`: ambient temperature
839    /// - `pwr_thrml_fc_to_cab`: heat demand from [Vehicle::hvac] system
840    /// - `veh_speed`: current achieved speed
841    /// - `dt`: simulation time step size
842    fn solve(
843        &mut self,
844        fc_state: &FuelConverterState,
845        te_amb: si::Temperature,
846        pwr_thrml_fc_to_cab: si::Power,
847        veh_speed: si::Velocity,
848        dt: si::Time,
849    ) -> anyhow::Result<()> {
850        self.state
851            .pwr_thrml_fc_to_cab
852            .update(pwr_thrml_fc_to_cab, || format_dbg!())?;
853        // film temperature for external convection calculations
854        let te_air_film: si::Temperature = 0.5
855            * (self
856                .state
857                .temperature
858                .get_stale(|| format_dbg!())?
859                .get::<si::kelvin_abs>()
860                + te_amb.get::<si::kelvin_abs>())
861            * uc::KELVIN;
862        // Reynolds number = density * speed * diameter / dynamic viscosity
863        // NOTE: might be good to pipe in elevation
864        let fc_air_film_re =
865            Air::get_density(Some(te_air_film), None) * veh_speed * self.length_for_convection
866                / Air::get_dyn_visc(te_air_film).with_context(|| format_dbg!())?;
867
868        // calculate heat transfer coeff. from engine to ambient [W / (m ** 2 * K)]
869        self.state.htc_to_amb.update(
870            if veh_speed < 1.0 * uc::MPS {
871                // if stopped, scale based on thermostat opening and constant convection
872                self.state.tstat_open_frac.update(
873                    self.tstat_interp
874                        .interpolate(&[self
875                            .state
876                            .temperature
877                            .get_stale(|| format_dbg!())?
878                            .get::<si::degree_celsius>()])
879                        .with_context(|| format_dbg!())?,
880                    || format_dbg!(),
881                )?;
882                (uc::R
883                    + *self.state.tstat_open_frac.get_fresh(|| format_dbg!())?
884                        * self.radiator_effectiveness)
885                    * self.htc_to_amb_stop
886            } else {
887                // Calculate heat transfer coefficient for sphere,
888                // from Incropera's Intro to Heat Transfer, 5th Ed., eq. 7.44
889                let sphere_conv_params = get_sphere_conv_params(fc_air_film_re.get::<si::ratio>());
890                let htc_to_amb_sphere: si::HeatTransferCoeff = sphere_conv_params.0
891                    * fc_air_film_re.get::<si::ratio>().powf(sphere_conv_params.1)
892                    * Air::get_pr(te_air_film)
893                        .with_context(|| format_dbg!())?
894                        .get::<si::ratio>()
895                        .powf(1.0 / 3.0)
896                    * Air::get_therm_cond(te_air_film).with_context(|| format_dbg!())?
897                    / self.length_for_convection;
898                // if stopped, scale based on thermostat opening and constant convection
899                self.state.tstat_open_frac.update(
900                    self.tstat_interp
901                        .interpolate(&[self
902                            .state
903                            .temperature
904                            .get_stale(|| format_dbg!())?
905                            .get::<si::degree_celsius>()])
906                        .with_context(|| format_dbg!())?,
907                    || format_dbg!(),
908                )?;
909                *self.state.tstat_open_frac.get_fresh(|| format_dbg!())? * htc_to_amb_sphere
910            },
911            || format_dbg!(),
912        )?;
913
914        self.state.pwr_thrml_to_amb.update(
915            *self.state.htc_to_amb.get_fresh(|| format_dbg!())?
916                * PI
917                * self.length_for_convection.powi(P2::new())
918                / 4.0
919                * (self
920                    .state
921                    .temperature
922                    .get_stale(|| format_dbg!())?
923                    .get::<si::degree_celsius>()
924                    - te_amb.get::<si::degree_celsius>())
925                * uc::KELVIN_INT,
926            || format_dbg!(),
927        )?;
928
929        // let heat_to_amb = ;
930        // assumes fuel/air mixture is entering combustion chamber at block temperature
931        // assumes stoichiometric combustion
932        self.state.te_adiabatic.update(
933            Air::get_te_from_u(
934                Air::get_specific_energy(*self.state.temperature.get_stale(|| format_dbg!())?)
935                    .with_context(|| format_dbg!())?
936                    + (Octane::get_specific_energy(*self.state.temperature.get_stale(|| format_dbg!())?)
937                    .with_context(|| format_dbg!())?
938                    // TODO: make config. for other fuels -- e.g. with enum for specific fuels and/or fuel properties
939                    + *GASOLINE_LHV)
940                        / *AFR_STOICH_GASOLINE,
941            )
942            .with_context(|| format_dbg!())?,
943            || format_dbg!(),
944        )?;
945        // heat that will go both to the block and out the exhaust port
946        self.state.pwr_fuel_as_heat.update(
947            *fc_state.pwr_fuel.get_stale(|| format_dbg!())?
948                - (*fc_state.pwr_prop.get_stale(|| format_dbg!())?
949                    + *fc_state.pwr_aux.get_stale(|| format_dbg!())?),
950            || format_dbg!(),
951        )?;
952        self.state.pwr_thrml_to_tm.update(
953            (self.conductance_from_comb
954                * (self
955                    .state
956                    .te_adiabatic
957                    .get_fresh(|| format_dbg!())?
958                    .get::<si::degree_celsius>()
959                    - self
960                        .state
961                        .temperature
962                        .get_stale(|| format_dbg!())?
963                        .get::<si::degree_celsius>())
964                * uc::KELVIN_INT)
965                .min(
966                    self.max_frac_from_comb
967                        * *self.state.pwr_fuel_as_heat.get_fresh(|| format_dbg!())?,
968                ),
969            || format_dbg!(),
970        )?;
971        let delta_temp: si::TemperatureInterval =
972            ((*self.state.pwr_thrml_to_tm.get_fresh(|| format_dbg!())?
973                - *self.state.pwr_thrml_fc_to_cab.get_fresh(|| format_dbg!())?
974                - *self.state.pwr_thrml_to_amb.get_fresh(|| format_dbg!())?)
975                * dt)
976                / self.heat_capacitance;
977        // Interestingly, it seems to be ok to add a `TemperatureInterval` to a `Temperature` here
978        self.state.temperature.update(
979            *self.state.temperature.get_stale(|| format_dbg!())? + delta_temp,
980            || format_dbg!(),
981        )?;
982
983        self.state.eff_coeff.update(
984            match self.fc_eff_model {
985                FCTempEffModel::Linear(FCTempEffModelLinear {
986                    offset,
987                    slope_per_kelvin: slope,
988                    minimum,
989                }) => minimum.max(
990                    {
991                        let calc_unbound: si::Ratio = offset
992                            + slope * uc::R / uc::KELVIN
993                                * *self.state.temperature.get_fresh(|| format_dbg!())?;
994                        calc_unbound
995                    }
996                    .min(1.0 * uc::R),
997                ),
998                FCTempEffModel::Exponential(FCTempEffModelExponential {
999                    offset,
1000                    lag,
1001                    minimum,
1002                }) => {
1003                    let dte: si::TemperatureInterval = (self
1004                        .state
1005                        .temperature
1006                        .get_fresh(|| format_dbg!())?
1007                        .get::<si::kelvin_abs>()
1008                        - offset.get::<si::kelvin_abs>())
1009                        * uc::KELVIN_INT;
1010                    ((1.0 - f64::exp((-dte / lag).get::<si::ratio>())) * uc::R).max(minimum)
1011                }
1012            },
1013            || format_dbg!(),
1014        )?;
1015        Ok(())
1016    }
1017}
1018impl SerdeAPI for FuelConverterThermal {}
1019impl SetCumulative for FuelConverterThermal {
1020    fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
1021        self.state
1022            .set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))
1023    }
1024
1025    fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
1026        self.state
1027            .reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))
1028    }
1029}
1030impl Init for FuelConverterThermal {
1031    fn init(&mut self) -> Result<(), Error> {
1032        self.tstat_te_sto = self
1033            .tstat_te_sto
1034            .or(Some((85. + uc::CELSIUS_TO_KELVIN) * uc::KELVIN));
1035        self.tstat_te_delta = self.tstat_te_delta.or(Some(5. * uc::KELVIN_INT));
1036        self.tstat_interp = Interp1D::new(
1037            array![
1038                self.tstat_te_sto.unwrap().get::<si::degree_celsius>(),
1039                self.tstat_te_sto.unwrap().get::<si::degree_celsius>()
1040                    + self.tstat_te_delta.unwrap().get::<si::kelvin>(),
1041            ],
1042            array![0.0, 1.0],
1043            strategy::Linear,
1044            Extrapolate::Clamp,
1045        )
1046        .map_err(|err| {
1047            Error::InitError(format!(
1048                "{}\n{}\n{}",
1049                err,
1050                format_dbg!(self.tstat_te_sto),
1051                format_dbg!(self.tstat_te_delta)
1052            ))
1053        })?;
1054        Ok(())
1055    }
1056}
1057impl Default for FuelConverterThermal {
1058    fn default() -> Self {
1059        let mut fct = Self {
1060            heat_capacitance: Default::default(),
1061            length_for_convection: Default::default(),
1062            htc_to_amb_stop: Default::default(),
1063            conductance_from_comb: Default::default(),
1064            max_frac_from_comb: Default::default(),
1065            tstat_te_sto: None,
1066            tstat_te_delta: None,
1067            tstat_interp: tstat_interp_default(),
1068            radiator_effectiveness: Default::default(),
1069            fc_eff_model: Default::default(),
1070            state: Default::default(),
1071            history: Default::default(),
1072            save_interval: Some(1),
1073        };
1074        fct.init().unwrap();
1075        fct
1076    }
1077}
1078
1079#[serde_api]
1080#[derive(
1081    Clone, Debug, Deserialize, Serialize, PartialEq, HistoryVec, StateMethods, SetCumulative,
1082)]
1083#[serde(default)]
1084#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
1085#[serde(deny_unknown_fields)]
1086pub struct FuelConverterThermalState {
1087    /// time step index
1088    pub i: TrackedState<usize>,
1089    /// Adiabatic flame temperature assuming complete (i.e. all fuel is consumed
1090    /// if fuel lean or stoich or all air is consumed if fuel rich) combustion
1091    pub te_adiabatic: TrackedState<si::Temperature>,
1092    /// Current engine thermal mass temperature (lumped engine block and coolant)
1093    pub temperature: TrackedState<si::Temperature>,
1094    /// thermostat open fraction (1 = fully open, 0 = fully closed)
1095    pub tstat_open_frac: TrackedState<f64>,
1096    /// Current heat transfer coefficient from [FuelConverter] to ambient
1097    pub htc_to_amb: TrackedState<si::HeatTransferCoeff>,
1098    /// Current heat transfer power to ambient
1099    pub pwr_thrml_to_amb: TrackedState<si::Power>,
1100    /// Cumulative heat transfer energy to ambient
1101    pub energy_thrml_to_amb: TrackedState<si::Energy>,
1102    /// Efficency coefficient, used to modify [FuelConverter] effciency based on temperature
1103    pub eff_coeff: TrackedState<si::Ratio>,
1104    /// Thermal power flowing from fuel converter to cabin
1105    pub pwr_thrml_fc_to_cab: TrackedState<si::Power>,
1106    /// Cumulative thermal energy flowing from fuel converter to cabin
1107    pub energy_thrml_fc_to_cab: TrackedState<si::Energy>,
1108    /// Fuel power that is not converted to mechanical work
1109    pub pwr_fuel_as_heat: TrackedState<si::Power>,
1110    /// Cumulative fuel energy that is not converted to mechanical work
1111    pub energy_fuel_as_heat: TrackedState<si::Energy>,
1112    /// Thermal power flowing from combustion to [FuelConverter] thermal mass
1113    pub pwr_thrml_to_tm: TrackedState<si::Power>,
1114    /// Cumulative thermal energy flowing from combustion to [FuelConverter] thermal mass
1115    pub energy_thrml_to_tm: TrackedState<si::Energy>,
1116}
1117#[pyo3_api]
1118impl FuelConverterThermalState {}
1119
1120impl Init for FuelConverterThermalState {}
1121impl SerdeAPI for FuelConverterThermalState {}
1122impl Default for FuelConverterThermalState {
1123    fn default() -> Self {
1124        Self {
1125            i: Default::default(),
1126            te_adiabatic: TrackedState::new(*TE_ADIABATIC_STD),
1127            temperature: TrackedState::new(*TE_STD_AIR),
1128            tstat_open_frac: Default::default(),
1129            htc_to_amb: Default::default(),
1130            eff_coeff: TrackedState::new(uc::R),
1131            pwr_thrml_fc_to_cab: Default::default(),
1132            energy_thrml_fc_to_cab: Default::default(),
1133            pwr_thrml_to_amb: Default::default(),
1134            energy_thrml_to_amb: Default::default(),
1135            pwr_fuel_as_heat: Default::default(),
1136            energy_fuel_as_heat: Default::default(),
1137            pwr_thrml_to_tm: Default::default(),
1138            energy_thrml_to_tm: Default::default(),
1139        }
1140    }
1141}
1142
1143/// Model variants for how FC efficiency depends on temperature
1144#[derive(
1145    Debug, Clone, Deserialize, Serialize, PartialEq, IsVariant, derive_more::From, TryInto,
1146)]
1147pub enum FCTempEffModel {
1148    /// Linear temperature dependence
1149    Linear(FCTempEffModelLinear),
1150    /// Exponential temperature dependence
1151    Exponential(FCTempEffModelExponential),
1152}
1153
1154impl Default for FCTempEffModel {
1155    fn default() -> Self {
1156        FCTempEffModel::Exponential(FCTempEffModelExponential::default())
1157    }
1158}
1159
1160#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
1161#[serde(deny_unknown_fields)]
1162pub struct FCTempEffModelLinear {
1163    pub offset: si::Ratio,
1164    /// Change in efficiency factor per change in temperature /[K/]
1165    pub slope_per_kelvin: f64,
1166    pub minimum: si::Ratio,
1167}
1168
1169impl Default for FCTempEffModelLinear {
1170    fn default() -> Self {
1171        Self {
1172            offset: 0.0 * uc::R,
1173            slope_per_kelvin: 25.0,
1174            minimum: 0.2 * uc::R,
1175        }
1176    }
1177}
1178
1179#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
1180#[serde(deny_unknown_fields)]
1181pub struct FCTempEffModelExponential {
1182    /// temperature at which `fc_eta_temp_coeff` begins to grow
1183    pub offset: si::Temperature,
1184    /// exponential lag parameter [K^-1]
1185    pub lag: si::TemperatureInterval,
1186    /// minimum value that `fc_eta_temp_coeff` can take
1187    pub minimum: si::Ratio,
1188}
1189
1190impl Default for FCTempEffModelExponential {
1191    fn default() -> Self {
1192        Self {
1193            // TODO: update after reasonable calibration
1194            offset: 0.0 * uc::KELVIN,
1195            lag: 25.0 * uc::KELVIN_INT,
1196            minimum: 0.2 * uc::R,
1197        }
1198    }
1199}