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)]
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(default)]
47 pub history: FuelConverterStateHistoryVec,
48}
49
50#[pyo3_api]
51impl FuelConverter {
52 #[getter("eff_max")]
54 fn get_eff_max_py(&self) -> PyResult<f64> {
55 Ok(*self.get_eff_max()?)
56 }
57
58 #[setter("__eff_max")]
59 fn set_eff_max_py(&mut self, eff_max: f64) -> PyResult<()> {
60 Ok(self.set_eff_max(eff_max, None)?)
61 }
62
63 #[getter("eff_min")]
64 fn get_eff_min_py(&self) -> PyResult<f64> {
65 Ok(*self.get_eff_min()?)
66 }
67
68 #[setter("__eff_min")]
69 fn set_eff_min_py(&mut self, eff_min: f64) -> PyResult<()> {
70 Ok(self.set_eff_min(eff_min, None)?)
71 }
72
73 #[setter("__eff_range")]
74 fn set_eff_range_py(&mut self, eff_range: f64) -> PyResult<()> {
75 self.set_eff_range(eff_range)?;
76 Ok(())
77 }
78
79 #[getter("mass_kg")]
87 fn get_mass_py(&self) -> PyResult<Option<f64>> {
88 Ok(self.mass()?.map(|m| m.get::<si::kilogram>()))
89 }
90
91 #[getter]
92 fn get_specific_pwr_kw_per_kg(&self) -> Option<f64> {
93 self.specific_pwr
94 .map(|x| x.get::<si::kilowatt_per_kilogram>())
95 }
96}
97
98impl SerdeAPI for FuelConverter {}
99impl Init for FuelConverter {
100 fn init(&mut self) -> Result<(), Error> {
101 let _ = self
102 .mass()
103 .map_err(|err| Error::InitError(format_dbg!(err)))?;
104 self.thrml.init()?;
105 self.state
106 .init()
107 .map_err(|err| Error::InitError(format_dbg!(err)))?;
108 let eff_max = self
109 .get_eff_max()
110 .map_err(|err| Error::InitError(format_dbg!(err)))?;
111 self.pwr_for_peak_eff = match &self.eff_interp_from_pwr_out {
112 InterpolatorEnum::Interp1D(interp) => *interp.data.grid[0]
113 .get(
114 interp
115 .data
116 .values
117 .iter()
118 .position(|eff| eff == eff_max)
119 .ok_or_else(|| Error::InitError(format_dbg!()))?,
120 )
121 .ok_or_else(|| Error::InitError(format_dbg!()))?,
122 _ => {
123 return Err(Error::InitError(format_dbg!(
124 "Only 1-D interpolators are supported"
125 )))
126 }
127 } * self.pwr_out_max;
128 Ok(())
129 }
130}
131impl HistoryMethods for FuelConverter {
132 fn save_interval(&self) -> anyhow::Result<Option<usize>> {
133 Ok(self.save_interval)
134 }
135 fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
136 self.save_interval = save_interval;
137 self.thrml.set_save_interval(save_interval)?;
138 Ok(())
139 }
140 fn clear(&mut self) {
141 self.history.clear();
142 self.thrml.clear();
143 }
144}
145
146impl Mass for FuelConverter {
147 fn mass(&self) -> anyhow::Result<Option<si::Mass>> {
148 let derived_mass = self
149 .derived_mass()
150 .with_context(|| anyhow!(format_dbg!()))?;
151 if let (Some(derived_mass), Some(set_mass)) = (derived_mass, self.mass) {
152 ensure!(
153 utils::almost_eq_uom(&set_mass, &derived_mass, None),
154 format!(
155 "{}",
156 format_dbg!(utils::almost_eq_uom(&set_mass, &derived_mass, None)),
157 )
158 );
159 }
160 Ok(self.mass)
161 }
162
163 fn set_mass(
164 &mut self,
165 new_mass: Option<si::Mass>,
166 side_effect: MassSideEffect,
167 ) -> anyhow::Result<()> {
168 let derived_mass = self
169 .derived_mass()
170 .with_context(|| anyhow!(format_dbg!()))?;
171 if let (Some(derived_mass), Some(new_mass)) = (derived_mass, new_mass) {
172 if derived_mass != new_mass {
173 match side_effect {
174 MassSideEffect::Extensive => {
175 self.pwr_out_max = self.specific_pwr.ok_or_else(|| {
176 anyhow!(
177 "{}\nExpected `self.specific_pwr` to be `Some`.",
178 format_dbg!()
179 )
180 })? * new_mass;
181 }
182 MassSideEffect::Intensive => {
183 self.specific_pwr = Some(self.pwr_out_max / new_mass);
184 }
185 MassSideEffect::None => {
186 self.specific_pwr = None;
187 }
188 }
189 }
190 } else if new_mass.is_none() {
191 self.specific_pwr = None;
192 }
193 self.mass = new_mass;
194 Ok(())
195 }
196
197 fn derived_mass(&self) -> anyhow::Result<Option<si::Mass>> {
198 Ok(self
199 .specific_pwr
200 .map(|specific_pwr| self.pwr_out_max / specific_pwr))
201 }
202
203 fn expunge_mass_fields(&mut self) {
204 self.mass = None;
205 self.specific_pwr = None;
206 }
207}
208
209impl FuelConverter {
211 pub fn set_curr_pwr_out_max(&mut self, dt: si::Time) -> anyhow::Result<()> {
216 if self.pwr_out_max_init == si::Power::ZERO {
217 self.pwr_out_max_init = self.pwr_out_max / 10.
219 };
220 let pwr_out_max = (*self.state.pwr_prop.get_stale(|| format_dbg!())?
221 + *self.state.pwr_aux.get_stale(|| format_dbg!())?
222 + self.pwr_out_max / self.pwr_ramp_lag * dt)
223 .min(self.pwr_out_max)
224 .max(self.pwr_out_max_init);
225 self.state
226 .pwr_out_max
227 .update(pwr_out_max, || format_dbg!())?;
228 Ok(())
229 }
230
231 pub fn set_curr_pwr_prop_max(&mut self, pwr_aux: si::Power) -> anyhow::Result<()> {
236 ensure!(
237 pwr_aux >= si::Power::ZERO,
238 format!(
239 "{}\n`pwr_aux` must be >= 0",
240 format_dbg!(pwr_aux >= si::Power::ZERO),
241 )
242 );
243 self.state.pwr_aux.update(pwr_aux, || format_dbg!())?;
244 self.state.pwr_prop_max.update(
245 *self.state.pwr_out_max.get_fresh(|| format_dbg!())? - pwr_aux,
246 || format_dbg!(),
247 )?;
248 Ok(())
249 }
250
251 pub fn solve(
257 &mut self,
258 pwr_out_req: si::Power,
259 fc_on: bool,
260 dt: si::Time,
261 ) -> anyhow::Result<()> {
262 self.state.fc_on.update(fc_on, || format_dbg!())?;
263 if fc_on {
264 self.state.time_on.increment(dt, || format_dbg!())?;
265 } else {
266 self.state
267 .time_on
268 .update(si::Time::ZERO, || format_dbg!())?;
269 }
270 ensure!(
272 pwr_out_req >= si::Power::ZERO,
273 format!(
274 "{}\n`pwr_out_req` must be >= 0",
275 format_dbg!(pwr_out_req >= si::Power::ZERO),
276 )
277 );
278 ensure!(
279 pwr_out_req <= *self.state.pwr_prop_max.get_fresh(|| format_dbg!())?,
280 format!(
281 "{}\n`pwr_out_req` ({} W) must be < `self.state.pwr_prop_max` ({} W)",
282 format_dbg!(),
283 pwr_out_req.get::<si::watt>().format_eng(Some(5)),
284 self.state
285 .pwr_prop_max
286 .get_fresh(|| format_dbg!())?
287 .get::<si::watt>()
288 .format_eng(Some(5))
289 )
290 );
291 ensure!(
293 fc_on || (pwr_out_req == si::Power::ZERO && *self.state.pwr_aux.get_fresh(|| format_dbg!())? == si::Power::ZERO),
294 format!(
295 "{}\nEngine is off but pwr_out_req + pwr_aux is non-zero\n`pwr_out_req`: {} kW\n`self.state.pwr_aux`: {} kW",
296 format_dbg!(
297 fc_on
298 || (pwr_out_req == si::Power::ZERO
299 && *self.state.pwr_aux.get_fresh(|| format_dbg!())? == si::Power::ZERO)
300 ),
301 pwr_out_req.get::<si::kilowatt>(),
302 self.state.pwr_aux.get_fresh(|| format_dbg!())?.get::<si::kilowatt>()
303 )
304 );
305 self.state.pwr_prop.update(pwr_out_req, || format_dbg!())?;
306 self.state.eff.update(
307 if fc_on {
308 uc::R
309 * self
310 .eff_interp_from_pwr_out
311 .interpolate(&[((pwr_out_req
312 + *self.state.pwr_aux.get_fresh(|| format_dbg!())?)
313 / self.pwr_out_max)
314 .get::<si::ratio>()])
315 .with_context(|| {
316 anyhow!(
317 "{}\n failed to calculate {}",
318 format_dbg!(),
319 stringify!(self.state.eff)
320 )
321 })?
322 } else {
323 si::Ratio::ZERO
324 } * match self.thrml.temp_eff_coeff() {
325 Some(tec) => *tec.get_fresh(|| format_dbg!())?,
326 None => 1.0 * uc::R,
327 },
328 || format_dbg!(),
329 )?;
330 ensure!(
331 (*self.state.eff.get_fresh(|| format_dbg!())? >= 0.0 * uc::R
332 && *self.state.eff.get_fresh(|| format_dbg!())? <= 1.0 * uc::R),
333 format!(
334 "fc efficiency ({}) must be either between 0 and 1",
335 self.state
336 .eff
337 .get_fresh(|| format_dbg!())?
338 .get::<si::ratio>()
339 )
340 );
341
342 self.state.pwr_fuel.update(
343 if *self.state.fc_on.get_fresh(|| format_dbg!())? {
344 ((pwr_out_req + *self.state.pwr_aux.get_fresh(|| format_dbg!())?)
345 / *self.state.eff.get_fresh(|| format_dbg!())?)
346 .max(self.pwr_idle_fuel)
347 } else {
348 si::Power::ZERO
349 },
350 || format_dbg!(),
351 )?;
352 self.state.pwr_loss.update(
353 *self.state.pwr_fuel.get_fresh(|| format_dbg!())?
354 - *self.state.pwr_prop.get_fresh(|| format_dbg!())?,
355 || format_dbg!(),
356 )?;
357
358 Ok(())
367 }
368
369 pub fn solve_thermal(
370 &mut self,
371 te_amb: si::Temperature,
372 pwr_thrml_fc_to_cab: Option<si::Power>,
373 veh_state: &mut VehicleState,
374 dt: si::Time,
375 ) -> anyhow::Result<()> {
376 let veh_speed = *veh_state.speed_ach.get_stale(|| format_dbg!())?;
377 self.thrml
378 .solve_thermal(&self.state, te_amb, pwr_thrml_fc_to_cab, veh_speed, dt)
379 .with_context(|| format_dbg!())
380 }
381
382 pub fn temperature(&self) -> Option<&TrackedState<si::Temperature>> {
384 match &self.thrml {
385 FuelConverterThermalOption::FuelConverterThermal(fct) => Some(&fct.state.temperature),
386 FuelConverterThermalOption::None => None,
387 }
388 }
389
390 pub fn get_eff_max(&self) -> anyhow::Result<&f64> {
392 self.eff_interp_from_pwr_out.max()
393 }
394
395 pub fn get_eff_min(&self) -> anyhow::Result<&f64> {
397 self.eff_interp_from_pwr_out.min()
398 }
399
400 pub fn set_eff_max(
403 &mut self,
404 eff_max: f64,
405 scaling: Option<ScalingMethods>,
406 ) -> anyhow::Result<()> {
407 if (0.0..=1.0).contains(&eff_max) {
408 self.eff_interp_from_pwr_out.set_max(eff_max, scaling)?;
409 } else {
410 return Err(anyhow!(
411 "`eff_max` ({:.3}) must be between 0.0 and 1.0",
412 eff_max,
413 ));
414 }
415 self.init().map_err(|err| anyhow!("{:?}", err))?;
417 Ok(())
418 }
419
420 pub fn set_eff_min(
423 &mut self,
424 eff_min: f64,
425 scaling: Option<ScalingMethods>,
426 ) -> anyhow::Result<()> {
427 self.eff_interp_from_pwr_out.set_min(eff_min, scaling)
428 }
429
430 pub fn set_eff_range(&mut self, eff_range: f64) -> anyhow::Result<()> {
434 if (0. ..=1.0).contains(&eff_range) {
435 self.eff_interp_from_pwr_out.set_range(eff_range)
436 } else {
437 Err(anyhow!(format!(
438 "`eff_range` ({:.3}) must be between 0.0 and 1.0",
439 eff_range,
440 )))
441 }
442 }
443
444 pub fn fc_thrml_state_mut(&mut self) -> Option<&mut FuelConverterThermalState> {
445 match &mut self.thrml {
446 FuelConverterThermalOption::FuelConverterThermal(fct) => Some(&mut fct.state),
447 FuelConverterThermalOption::None => None,
448 }
449 }
450}
451
452impl TryFrom<fastsim_2::vehicle::RustVehicle> for FuelConverter {
453 type Error = anyhow::Error;
454 fn try_from(f2veh: fastsim_2::vehicle::RustVehicle) -> Result<FuelConverter, anyhow::Error> {
455 let mut fc: FuelConverter = FCBuilder {
456 pwr_out_max: f2veh.fc_max_kw * uc::KW,
457 pwr_ramp_lag: f2veh.fc_sec_to_peak_pwr * uc::S,
458 eff_interp_from_pwr_out: InterpolatorEnum::new_1d(
459 vec![
461 0.0, 0.005, 0.015, 0.04, 0.06, 0.1, 0.14, 0.2, 0.4, 0.6, 0.8, 1.0,
462 ]
463 .into(),
464 f2veh.fc_eff_map.clone().into(),
465 strategy::Linear,
466 Extrapolate::Error,
467 )
468 .with_context(|| format_dbg!())?,
469 pwr_for_peak_eff: uc::KW * f64::NAN, pwr_idle_fuel: si::Power::ZERO,
472 save_interval: Some(1),
473 }
474 .try_into()
475 .with_context(|| format_dbg!())?;
476 fc.init()?;
477 fc.set_mass(None, MassSideEffect::None)
478 .with_context(|| anyhow!(format_dbg!()))?;
479 Ok(fc)
480 }
481}
482
483impl TryFrom<FCBuilder> for FuelConverter {
484 type Error = anyhow::Error;
485 fn try_from(fcbuilder: FCBuilder) -> Result<FuelConverter, anyhow::Error> {
486 let mut fc = FuelConverter {
487 state: Default::default(),
488 thrml: Default::default(),
489 mass: None,
490 specific_pwr: None,
491 pwr_out_max: fcbuilder.pwr_out_max,
492 pwr_out_max_init: fcbuilder.pwr_out_max / fcbuilder.pwr_ramp_lag.get::<si::second>(),
494 pwr_ramp_lag: fcbuilder.pwr_ramp_lag,
495 eff_interp_from_pwr_out: fcbuilder.eff_interp_from_pwr_out,
496 pwr_for_peak_eff: uc::KW * f64::NAN, pwr_idle_fuel: si::Power::ZERO,
500 save_interval: Some(1),
501 history: Default::default(),
502 };
503 fc.init()?;
504 fc.set_mass(None, MassSideEffect::None)
505 .with_context(|| anyhow!(format_dbg!()))?;
506 Ok(fc)
507 }
508}
509
510#[serde_api]
511#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
512#[serde(deny_unknown_fields)]
513#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
514pub struct FCBuilder {
516 pub pwr_out_max: si::Power,
517 pub pwr_ramp_lag: si::Time,
520 pub eff_interp_from_pwr_out: InterpolatorEnumOwned<f64>,
522 #[serde(skip)]
524 pub(crate) pwr_for_peak_eff: si::Power,
525 pub pwr_idle_fuel: si::Power,
527 pub save_interval: Option<usize>,
529}
530
531#[serde_api]
532#[derive(
533 Clone,
534 Debug,
535 Default,
536 Deserialize,
537 Serialize,
538 PartialEq,
539 HistoryVec,
540 StateMethods,
541 SetCumulative,
542)]
543#[non_exhaustive]
544#[serde(default)]
545#[serde(deny_unknown_fields)]
546#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
547pub struct FuelConverterState {
548 pub i: TrackedState<usize>,
550 pub pwr_out_max: TrackedState<si::Power>,
552 pub pwr_prop_max: TrackedState<si::Power>,
554 pub eff: TrackedState<si::Ratio>,
556 pub pwr_prop: TrackedState<si::Power>,
558 pub energy_prop: TrackedState<si::Energy>,
560 pub pwr_aux: TrackedState<si::Power>,
562 pub energy_aux: TrackedState<si::Energy>,
564 pub pwr_fuel: TrackedState<si::Power>,
566 pub energy_fuel: TrackedState<si::Energy>,
568 pub pwr_loss: TrackedState<si::Power>,
570 pub energy_loss: TrackedState<si::Energy>,
572 pub fc_on: TrackedState<bool>,
574 pub time_on: TrackedState<si::Time>,
576}
577
578#[pyo3_api]
579impl FuelConverterState {}
580impl SerdeAPI for FuelConverterState {}
581impl Init for FuelConverterState {}
582
583#[derive(
585 Clone, Default, Debug, Serialize, Deserialize, PartialEq, IsVariant, derive_more::From, TryInto,
586)]
587pub enum FuelConverterThermalOption {
588 FuelConverterThermal(Box<FuelConverterThermal>),
590 #[default]
592 None,
593}
594
595impl StateMethods for FuelConverterThermalOption {}
596
597impl SaveState for FuelConverterThermalOption {
598 fn save_state<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
599 match self {
600 Self::FuelConverterThermal(fct) => fct.save_state(loc)?,
601 Self::None => {}
602 }
603 Ok(())
604 }
605}
606impl TrackedStateMethods for FuelConverterThermalOption {
607 fn check_and_reset<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
608 match self {
609 Self::FuelConverterThermal(fct) => {
610 fct.check_and_reset(|| format!("{}\n{}", loc(), format_dbg!()))?
611 }
612 Self::None => {}
613 }
614 Ok(())
615 }
616
617 fn mark_fresh<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
618 match self {
619 Self::FuelConverterThermal(fct) => {
620 fct.mark_fresh(|| format!("{}\n{}", loc(), format_dbg!()))?
621 }
622 Self::None => {}
623 }
624 Ok(())
625 }
626}
627impl Step for FuelConverterThermalOption {
628 fn step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
629 match self {
630 Self::FuelConverterThermal(fct) => fct.step(|| format!("{}\n{}", loc(), format_dbg!())),
631 Self::None => Ok(()),
632 }
633 }
634
635 fn reset_step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
636 match self {
637 Self::FuelConverterThermal(fct) => {
638 fct.reset_step(|| format!("{}\n{}", loc(), format_dbg!()))
639 }
640 Self::None => Ok(()),
641 }
642 }
643}
644impl Init for FuelConverterThermalOption {
645 fn init(&mut self) -> Result<(), Error> {
646 match self {
647 Self::FuelConverterThermal(fct) => fct.init()?,
648 Self::None => {}
649 }
650 Ok(())
651 }
652}
653impl SerdeAPI for FuelConverterThermalOption {}
654impl SetCumulative for FuelConverterThermalOption {
655 fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
656 match self {
657 Self::FuelConverterThermal(fct) => {
658 fct.set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?
659 }
660 Self::None => {}
661 }
662 Ok(())
663 }
664
665 fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
666 match self {
667 Self::FuelConverterThermal(fct) => {
668 fct.reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?
669 }
670 Self::None => {}
671 }
672 Ok(())
673 }
674}
675impl HistoryMethods for FuelConverterThermalOption {
676 fn save_interval(&self) -> anyhow::Result<Option<usize>> {
677 match self {
678 FuelConverterThermalOption::FuelConverterThermal(fct) => fct.save_interval(),
679 FuelConverterThermalOption::None => Ok(None),
680 }
681 }
682 fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
683 match self {
684 FuelConverterThermalOption::FuelConverterThermal(fct) => {
685 fct.set_save_interval(save_interval)
686 }
687 FuelConverterThermalOption::None => Ok(()),
688 }
689 }
690 fn clear(&mut self) {
691 match self {
692 FuelConverterThermalOption::FuelConverterThermal(fct) => {
693 fct.clear();
694 }
695 FuelConverterThermalOption::None => {}
696 }
697 }
698}
699impl FuelConverterThermalOption {
700 fn solve_thermal(
707 &mut self,
708 fc_state: &FuelConverterState,
709 te_amb: si::Temperature,
710 pwr_thrml_fc_to_cab: Option<si::Power>,
711 veh_speed: si::Velocity,
712 dt: si::Time,
713 ) -> anyhow::Result<()> {
714 match self {
715 Self::FuelConverterThermal(fct) => fct
716 .solve(
717 fc_state,
718 te_amb,
719 pwr_thrml_fc_to_cab.unwrap_or_default(),
720 veh_speed,
721 dt,
722 )
723 .with_context(|| format_dbg!())?,
724 Self::None => {
725 ensure!(
726 pwr_thrml_fc_to_cab.is_none(),
727 format_dbg!(
728 "`FuelConverterThermal needs to be configured to provide heat demand`"
729 )
730 );
731 }
732 }
733 Ok(())
734 }
735
736 fn temp_eff_coeff(&self) -> Option<&TrackedState<si::Ratio>> {
738 match self {
739 Self::FuelConverterThermal(fct) => Some(&fct.state.eff_coeff),
740 Self::None => None,
741 }
742 }
743}
744
745#[serde_api]
746#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, StateMethods)]
747#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
748#[non_exhaustive]
749#[serde(deny_unknown_fields)]
750pub struct FuelConverterThermal {
752 pub heat_capacitance: si::HeatCapacity,
754 pub length_for_convection: si::Length,
756 pub htc_to_amb_stop: si::HeatTransferCoeff,
758
759 pub conductance_from_comb: si::ThermalConductance,
761 pub max_frac_from_comb: si::Ratio,
764 pub tstat_te_sto: Option<si::Temperature>,
766 pub tstat_te_delta: Option<si::TemperatureInterval>,
768 #[serde(default = "tstat_interp_default")]
769 pub tstat_interp: Interp1DOwned<f64, strategy::Linear>,
770 pub radiator_effectiveness: si::Ratio,
773 pub fc_eff_model: FCTempEffModel,
775 #[serde(default)]
777 pub state: FuelConverterThermalState,
778 #[serde(default)]
780 pub history: FuelConverterThermalStateHistoryVec,
781 pub save_interval: Option<usize>,
782}
783
784#[pyo3_api]
785impl FuelConverterThermal {
786 #[staticmethod]
787 #[pyo3(name = "default")]
788 fn default_py() -> Self {
789 Default::default()
790 }
791}
792
793impl HistoryMethods for FuelConverterThermal {
794 fn save_interval(&self) -> anyhow::Result<Option<usize>> {
795 Ok(self.save_interval)
796 }
797 fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
798 self.save_interval = save_interval;
799 Ok(())
800 }
801 fn clear(&mut self) {
802 self.history.clear();
803 }
804}
805
806fn tstat_interp_default() -> Interp1DOwned<f64, strategy::Linear> {
808 Interp1D::new(
809 array![85.0, 90.0],
810 array![0.0, 1.0],
811 strategy::Linear,
812 Extrapolate::Clamp,
813 )
814 .unwrap()
815}
816
817lazy_static! {
818 pub static ref AFR_STOICH_GASOLINE: si::Ratio = uc::R * 14.7;
820 pub static ref GASOLINE_DENSITY: si::MassDensity = 0.75 * uc::KG / uc::L;
823 pub static ref GASOLINE_LHV: si::SpecificEnergy = 33.7 * uc::KWH / uc::GALLON / *GASOLINE_DENSITY;
825 pub static ref TE_ADIABATIC_STD: si::Temperature = Air::get_te_from_u(
826 Air::get_specific_energy(*TE_STD_AIR).with_context(|| format_dbg!()).unwrap()
827 + (Octane::get_specific_energy(*TE_STD_AIR).with_context(|| format_dbg!()).unwrap()
828 + *GASOLINE_LHV)
829 / *AFR_STOICH_GASOLINE,
830 )
831 .with_context(|| format_dbg!()).unwrap_or_else(|_| panic!("{}\nFailed to calculate adiabatic flame temp for gasoline", format_dbg!()));
832}
833
834impl FuelConverterThermal {
835 fn solve(
843 &mut self,
844 fc_state: &FuelConverterState,
845 te_amb: si::Temperature,
846 pwr_thrml_fc_to_cab: si::Power,
847 veh_speed: si::Velocity,
848 dt: si::Time,
849 ) -> anyhow::Result<()> {
850 self.state
851 .pwr_thrml_fc_to_cab
852 .update(pwr_thrml_fc_to_cab, || format_dbg!())?;
853 let te_air_film: si::Temperature = 0.5
855 * (self
856 .state
857 .temperature
858 .get_stale(|| format_dbg!())?
859 .get::<si::kelvin_abs>()
860 + te_amb.get::<si::kelvin_abs>())
861 * uc::KELVIN;
862 let fc_air_film_re =
865 Air::get_density(Some(te_air_film), None) * veh_speed * self.length_for_convection
866 / Air::get_dyn_visc(te_air_film).with_context(|| format_dbg!())?;
867
868 self.state.htc_to_amb.update(
870 if veh_speed < 1.0 * uc::MPS {
871 self.state.tstat_open_frac.update(
873 self.tstat_interp
874 .interpolate(&[self
875 .state
876 .temperature
877 .get_stale(|| format_dbg!())?
878 .get::<si::degree_celsius>()])
879 .with_context(|| format_dbg!())?,
880 || format_dbg!(),
881 )?;
882 (uc::R
883 + *self.state.tstat_open_frac.get_fresh(|| format_dbg!())?
884 * self.radiator_effectiveness)
885 * self.htc_to_amb_stop
886 } else {
887 let sphere_conv_params = get_sphere_conv_params(fc_air_film_re.get::<si::ratio>());
890 let htc_to_amb_sphere: si::HeatTransferCoeff = sphere_conv_params.0
891 * fc_air_film_re.get::<si::ratio>().powf(sphere_conv_params.1)
892 * Air::get_pr(te_air_film)
893 .with_context(|| format_dbg!())?
894 .get::<si::ratio>()
895 .powf(1.0 / 3.0)
896 * Air::get_therm_cond(te_air_film).with_context(|| format_dbg!())?
897 / self.length_for_convection;
898 self.state.tstat_open_frac.update(
900 self.tstat_interp
901 .interpolate(&[self
902 .state
903 .temperature
904 .get_stale(|| format_dbg!())?
905 .get::<si::degree_celsius>()])
906 .with_context(|| format_dbg!())?,
907 || format_dbg!(),
908 )?;
909 *self.state.tstat_open_frac.get_fresh(|| format_dbg!())? * htc_to_amb_sphere
910 },
911 || format_dbg!(),
912 )?;
913
914 self.state.pwr_thrml_to_amb.update(
915 *self.state.htc_to_amb.get_fresh(|| format_dbg!())?
916 * PI
917 * self.length_for_convection.powi(P2::new())
918 / 4.0
919 * (self
920 .state
921 .temperature
922 .get_stale(|| format_dbg!())?
923 .get::<si::degree_celsius>()
924 - te_amb.get::<si::degree_celsius>())
925 * uc::KELVIN_INT,
926 || format_dbg!(),
927 )?;
928
929 self.state.te_adiabatic.update(
933 Air::get_te_from_u(
934 Air::get_specific_energy(*self.state.temperature.get_stale(|| format_dbg!())?)
935 .with_context(|| format_dbg!())?
936 + (Octane::get_specific_energy(*self.state.temperature.get_stale(|| format_dbg!())?)
937 .with_context(|| format_dbg!())?
938 + *GASOLINE_LHV)
940 / *AFR_STOICH_GASOLINE,
941 )
942 .with_context(|| format_dbg!())?,
943 || format_dbg!(),
944 )?;
945 self.state.pwr_fuel_as_heat.update(
947 *fc_state.pwr_fuel.get_stale(|| format_dbg!())?
948 - (*fc_state.pwr_prop.get_stale(|| format_dbg!())?
949 + *fc_state.pwr_aux.get_stale(|| format_dbg!())?),
950 || format_dbg!(),
951 )?;
952 self.state.pwr_thrml_to_tm.update(
953 (self.conductance_from_comb
954 * (self
955 .state
956 .te_adiabatic
957 .get_fresh(|| format_dbg!())?
958 .get::<si::degree_celsius>()
959 - self
960 .state
961 .temperature
962 .get_stale(|| format_dbg!())?
963 .get::<si::degree_celsius>())
964 * uc::KELVIN_INT)
965 .min(
966 self.max_frac_from_comb
967 * *self.state.pwr_fuel_as_heat.get_fresh(|| format_dbg!())?,
968 ),
969 || format_dbg!(),
970 )?;
971 let delta_temp: si::TemperatureInterval =
972 ((*self.state.pwr_thrml_to_tm.get_fresh(|| format_dbg!())?
973 - *self.state.pwr_thrml_fc_to_cab.get_fresh(|| format_dbg!())?
974 - *self.state.pwr_thrml_to_amb.get_fresh(|| format_dbg!())?)
975 * dt)
976 / self.heat_capacitance;
977 self.state.temperature.update(
979 *self.state.temperature.get_stale(|| format_dbg!())? + delta_temp,
980 || format_dbg!(),
981 )?;
982
983 self.state.eff_coeff.update(
984 match self.fc_eff_model {
985 FCTempEffModel::Linear(FCTempEffModelLinear {
986 offset,
987 slope_per_kelvin: slope,
988 minimum,
989 }) => minimum.max(
990 {
991 let calc_unbound: si::Ratio = offset
992 + slope * uc::R / uc::KELVIN
993 * *self.state.temperature.get_fresh(|| format_dbg!())?;
994 calc_unbound
995 }
996 .min(1.0 * uc::R),
997 ),
998 FCTempEffModel::Exponential(FCTempEffModelExponential {
999 offset,
1000 lag,
1001 minimum,
1002 }) => {
1003 let dte: si::TemperatureInterval = (self
1004 .state
1005 .temperature
1006 .get_fresh(|| format_dbg!())?
1007 .get::<si::kelvin_abs>()
1008 - offset.get::<si::kelvin_abs>())
1009 * uc::KELVIN_INT;
1010 ((1.0 - f64::exp((-dte / lag).get::<si::ratio>())) * uc::R).max(minimum)
1011 }
1012 },
1013 || format_dbg!(),
1014 )?;
1015 Ok(())
1016 }
1017}
1018impl SerdeAPI for FuelConverterThermal {}
1019impl SetCumulative for FuelConverterThermal {
1020 fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
1021 self.state
1022 .set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))
1023 }
1024
1025 fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
1026 self.state
1027 .reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))
1028 }
1029}
1030impl Init for FuelConverterThermal {
1031 fn init(&mut self) -> Result<(), Error> {
1032 self.tstat_te_sto = self
1033 .tstat_te_sto
1034 .or(Some((85. + uc::CELSIUS_TO_KELVIN) * uc::KELVIN));
1035 self.tstat_te_delta = self.tstat_te_delta.or(Some(5. * uc::KELVIN_INT));
1036 self.tstat_interp = Interp1D::new(
1037 array![
1038 self.tstat_te_sto.unwrap().get::<si::degree_celsius>(),
1039 self.tstat_te_sto.unwrap().get::<si::degree_celsius>()
1040 + self.tstat_te_delta.unwrap().get::<si::kelvin>(),
1041 ],
1042 array![0.0, 1.0],
1043 strategy::Linear,
1044 Extrapolate::Clamp,
1045 )
1046 .map_err(|err| {
1047 Error::InitError(format!(
1048 "{}\n{}\n{}",
1049 err,
1050 format_dbg!(self.tstat_te_sto),
1051 format_dbg!(self.tstat_te_delta)
1052 ))
1053 })?;
1054 Ok(())
1055 }
1056}
1057impl Default for FuelConverterThermal {
1058 fn default() -> Self {
1059 let mut fct = Self {
1060 heat_capacitance: Default::default(),
1061 length_for_convection: Default::default(),
1062 htc_to_amb_stop: Default::default(),
1063 conductance_from_comb: Default::default(),
1064 max_frac_from_comb: Default::default(),
1065 tstat_te_sto: None,
1066 tstat_te_delta: None,
1067 tstat_interp: tstat_interp_default(),
1068 radiator_effectiveness: Default::default(),
1069 fc_eff_model: Default::default(),
1070 state: Default::default(),
1071 history: Default::default(),
1072 save_interval: Some(1),
1073 };
1074 fct.init().unwrap();
1075 fct
1076 }
1077}
1078
1079#[serde_api]
1080#[derive(
1081 Clone, Debug, Deserialize, Serialize, PartialEq, HistoryVec, StateMethods, SetCumulative,
1082)]
1083#[serde(default)]
1084#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
1085#[serde(deny_unknown_fields)]
1086pub struct FuelConverterThermalState {
1087 pub i: TrackedState<usize>,
1089 pub te_adiabatic: TrackedState<si::Temperature>,
1092 pub temperature: TrackedState<si::Temperature>,
1094 pub tstat_open_frac: TrackedState<f64>,
1096 pub htc_to_amb: TrackedState<si::HeatTransferCoeff>,
1098 pub pwr_thrml_to_amb: TrackedState<si::Power>,
1100 pub energy_thrml_to_amb: TrackedState<si::Energy>,
1102 pub eff_coeff: TrackedState<si::Ratio>,
1104 pub pwr_thrml_fc_to_cab: TrackedState<si::Power>,
1106 pub energy_thrml_fc_to_cab: TrackedState<si::Energy>,
1108 pub pwr_fuel_as_heat: TrackedState<si::Power>,
1110 pub energy_fuel_as_heat: TrackedState<si::Energy>,
1112 pub pwr_thrml_to_tm: TrackedState<si::Power>,
1114 pub energy_thrml_to_tm: TrackedState<si::Energy>,
1116}
1117#[pyo3_api]
1118impl FuelConverterThermalState {}
1119
1120impl Init for FuelConverterThermalState {}
1121impl SerdeAPI for FuelConverterThermalState {}
1122impl Default for FuelConverterThermalState {
1123 fn default() -> Self {
1124 Self {
1125 i: Default::default(),
1126 te_adiabatic: TrackedState::new(*TE_ADIABATIC_STD),
1127 temperature: TrackedState::new(*TE_STD_AIR),
1128 tstat_open_frac: Default::default(),
1129 htc_to_amb: Default::default(),
1130 eff_coeff: TrackedState::new(uc::R),
1131 pwr_thrml_fc_to_cab: Default::default(),
1132 energy_thrml_fc_to_cab: Default::default(),
1133 pwr_thrml_to_amb: Default::default(),
1134 energy_thrml_to_amb: Default::default(),
1135 pwr_fuel_as_heat: Default::default(),
1136 energy_fuel_as_heat: Default::default(),
1137 pwr_thrml_to_tm: Default::default(),
1138 energy_thrml_to_tm: Default::default(),
1139 }
1140 }
1141}
1142
1143#[derive(
1145 Debug, Clone, Deserialize, Serialize, PartialEq, IsVariant, derive_more::From, TryInto,
1146)]
1147pub enum FCTempEffModel {
1148 Linear(FCTempEffModelLinear),
1150 Exponential(FCTempEffModelExponential),
1152}
1153
1154impl Default for FCTempEffModel {
1155 fn default() -> Self {
1156 FCTempEffModel::Exponential(FCTempEffModelExponential::default())
1157 }
1158}
1159
1160#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
1161#[serde(deny_unknown_fields)]
1162pub struct FCTempEffModelLinear {
1163 pub offset: si::Ratio,
1164 pub slope_per_kelvin: f64,
1166 pub minimum: si::Ratio,
1167}
1168
1169impl Default for FCTempEffModelLinear {
1170 fn default() -> Self {
1171 Self {
1172 offset: 0.0 * uc::R,
1173 slope_per_kelvin: 25.0,
1174 minimum: 0.2 * uc::R,
1175 }
1176 }
1177}
1178
1179#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
1180#[serde(deny_unknown_fields)]
1181pub struct FCTempEffModelExponential {
1182 pub offset: si::Temperature,
1184 pub lag: si::TemperatureInterval,
1186 pub minimum: si::Ratio,
1188}
1189
1190impl Default for FCTempEffModelExponential {
1191 fn default() -> Self {
1192 Self {
1193 offset: 0.0 * uc::KELVIN,
1195 lag: 25.0 * uc::KELVIN_INT,
1196 minimum: 0.2 * uc::R,
1197 }
1198 }
1199}