1use super::utils::ScalingMethods;
2use super::*;
3use crate::prelude::*;
4use crate::utils::interp::InterpolatorMutMethods;
5use std::f64::consts::PI;
6
7#[serde_api]
10#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, StateMethods, SetCumulative)]
11#[non_exhaustive]
13#[serde(deny_unknown_fields)]
14#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
15pub struct FuelConverter {
16 #[serde(default, skip_serializing_if = "FuelConverterThermalOption::is_none")]
18 #[has_state]
19 pub thrml: FuelConverterThermalOption,
20 #[serde(default)]
22 pub(in super::super) mass: Option<si::Mass>,
23 pub(in super::super) specific_pwr: Option<si::SpecificPower>,
25 pub pwr_out_max: si::Power,
27 #[serde(default)]
29 pub pwr_out_max_init: si::Power,
30 pub pwr_ramp_lag: si::Time,
33 pub eff_interp_from_pwr_out: InterpolatorEnumOwned<f64>,
35 #[serde(skip)]
37 pub(crate) pwr_for_peak_eff: si::Power,
38 pub pwr_idle_fuel: si::Power,
40 pub save_interval: Option<usize>,
42 #[serde(default)]
44 pub state: FuelConverterState,
45 #[serde(
47 default,
48 skip_serializing_if = "FuelConverterStateHistoryVec::is_empty"
49 )]
50 pub history: FuelConverterStateHistoryVec,
51}
52
53#[pyo3_api]
54impl FuelConverter {
55 #[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 #[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
212impl FuelConverter {
214 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 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 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 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 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 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 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 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 pub fn get_eff_max(&self) -> anyhow::Result<&f64> {
382 self.eff_interp_from_pwr_out.max()
383 }
384
385 pub fn get_eff_min(&self) -> anyhow::Result<&f64> {
387 self.eff_interp_from_pwr_out.min()
388 }
389
390 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 self.init().map_err(|err| anyhow!("{:?}", err))?;
407 Ok(())
408 }
409
410 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 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 pub i: TrackedState<usize>,
461 pub pwr_out_max: TrackedState<si::Power>,
463 pub pwr_prop_max: TrackedState<si::Power>,
465 pub eff: TrackedState<si::Ratio>,
467 pub pwr_prop: TrackedState<si::Power>,
469 pub energy_prop: TrackedState<si::Energy>,
471 pub pwr_aux: TrackedState<si::Power>,
473 pub energy_aux: TrackedState<si::Energy>,
475 pub pwr_fuel: TrackedState<si::Power>,
477 pub energy_fuel: TrackedState<si::Energy>,
479 pub pwr_loss: TrackedState<si::Power>,
481 pub energy_loss: TrackedState<si::Energy>,
483 pub fc_on: TrackedState<bool>,
485 pub time_on: TrackedState<si::Time>,
487}
488
489#[pyo3_api]
490impl FuelConverterState {}
491impl SerdeAPI for FuelConverterState {}
492impl Init for FuelConverterState {}
493
494#[derive(
496 Clone, Default, Debug, Serialize, Deserialize, PartialEq, IsVariant, derive_more::From, TryInto,
497)]
498pub enum FuelConverterThermalOption {
499 FuelConverterThermal(Box<FuelConverterThermal>),
501 #[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 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 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)]
651pub struct FuelConverterThermal {
653 pub heat_capacitance: si::HeatCapacity,
655 pub length_for_convection: si::Length,
657 pub htc_to_amb_stop: si::HeatTransferCoeff,
659
660 pub conductance_from_comb: si::ThermalConductance,
662 pub max_frac_from_comb: si::Ratio,
665 pub tstat_te_sto: Option<si::Temperature>,
667 pub tstat_te_delta: Option<si::TemperatureInterval>,
669 #[serde(default = "tstat_interp_default")]
670 pub tstat_interp: Interp1DOwned<f64, strategy::Linear>,
671 pub radiator_effectiveness: si::Ratio,
674 pub fc_eff_model: FCTempEffModel,
676 #[serde(default)]
678 pub state: FuelConverterThermalState,
679 #[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
710fn 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 pub static ref AFR_STOICH_GASOLINE: si::Ratio = uc::R * 14.7;
724 pub static ref GASOLINE_DENSITY: si::MassDensity = 0.75 * uc::KG / uc::L;
727 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 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 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 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 self.state.htc_to_amb.update(
774 if veh_speed < 1.0 * uc::MPS {
775 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 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 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 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 + *GASOLINE_LHV)
844 / *AFR_STOICH_GASOLINE,
845 )
846 .with_context(|| format_dbg!())?,
847 || format_dbg!(),
848 )?;
849 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 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 pub i: TrackedState<usize>,
988 pub te_adiabatic: TrackedState<si::Temperature>,
991 pub temperature: TrackedState<si::Temperature>,
993 pub tstat_open_frac: TrackedState<f64>,
995 pub htc_to_amb: TrackedState<si::HeatTransferCoeff>,
997 pub pwr_thrml_to_amb: TrackedState<si::Power>,
999 pub energy_thrml_to_amb: TrackedState<si::Energy>,
1001 pub eff_coeff: TrackedState<si::Ratio>,
1003 pub pwr_thrml_fc_to_cab: TrackedState<si::Power>,
1005 pub energy_thrml_fc_to_cab: TrackedState<si::Energy>,
1007 pub pwr_fuel_as_heat: TrackedState<si::Power>,
1009 pub energy_fuel_as_heat: TrackedState<si::Energy>,
1011 pub pwr_thrml_to_tm: TrackedState<si::Power>,
1013 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#[derive(
1044 Debug, Clone, Deserialize, Serialize, PartialEq, IsVariant, derive_more::From, TryInto,
1045)]
1046pub enum FCTempEffModel {
1047 Linear(FCTempEffModelLinear),
1049 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 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 pub offset: si::Temperature,
1083 pub lag: si::TemperatureInterval,
1085 pub minimum: si::Ratio,
1087}
1088
1089impl Default for FCTempEffModelExponential {
1090 fn default() -> Self {
1091 Self {
1092 offset: 0.0 * uc::KELVIN,
1094 lag: 25.0 * uc::KELVIN_INT,
1095 minimum: 0.2 * uc::R,
1096 }
1097 }
1098}