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