Skip to main content

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