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(crate) mass: Option<si::Mass>,
23 pub(crate) 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 #[serde(serialize_with = "serialize_nested")]
35 pub eff_interp_from_pwr_out: InterpolatorEnum<f64>,
36 #[serde(skip)]
38 pub(crate) pwr_for_peak_eff: si::Power,
39 pub pwr_idle_fuel: si::Power,
41 #[serde(default)]
43 pub state: FuelConverterState,
44 #[serde(
46 default,
47 skip_serializing_if = "FuelConverterStateHistoryVec::is_empty"
48 )]
49 pub history: FuelConverterStateHistoryVec,
50 pub save_interval: Option<usize>,
52}
53
54#[pyo3_api]
55impl FuelConverter {
56 #[getter("eff_max")]
58 fn get_eff_max_py(&self) -> PyResult<f64> {
59 Ok(*self.get_eff_max()?)
60 }
61
62 #[setter("__eff_max")]
63 fn set_eff_max_py(&mut self, eff_max: f64) -> PyResult<()> {
64 Ok(self.set_eff_max(eff_max, None)?)
65 }
66
67 #[getter("eff_min")]
68 fn get_eff_min_py(&self) -> PyResult<f64> {
69 Ok(*self.get_eff_min()?)
70 }
71
72 #[setter("__eff_min")]
73 fn set_eff_min_py(&mut self, eff_min: f64) -> PyResult<()> {
74 Ok(self.set_eff_min(eff_min, None)?)
75 }
76
77 #[setter("__eff_range")]
78 fn set_eff_range_py(&mut self, eff_range: f64) -> PyResult<()> {
79 self.set_eff_range(eff_range)?;
80 Ok(())
81 }
82
83 #[getter("mass_kg")]
91 fn get_mass_py(&self) -> PyResult<Option<f64>> {
92 Ok(self.mass()?.map(|m| m.get::<si::kilogram>()))
93 }
94
95 #[getter]
96 fn get_specific_pwr_kw_per_kg(&self) -> Option<f64> {
97 self.specific_pwr
98 .map(|x| x.get::<si::kilowatt_per_kilogram>())
99 }
100}
101
102impl FuelConverter {
104 pub fn new(
105 thrml: FuelConverterThermalOption,
106 mass: Option<si::Mass>,
107 specific_pwr: Option<si::SpecificPower>,
108 pwr_out_max: si::Power,
109 pwr_out_max_init: si::Power,
110 pwr_ramp_lag: si::Time,
111 eff_interp_from_pwr_out: InterpolatorEnum<f64>,
112 pwr_for_peak_eff: si::Power,
113 pwr_idle_fuel: si::Power,
114 save_interval: Option<usize>,
115 ) -> anyhow::Result<Self> {
116 let mut fc = Self {
117 thrml,
118 mass,
119 specific_pwr,
120 pwr_out_max,
121 pwr_out_max_init,
122 pwr_ramp_lag,
123 eff_interp_from_pwr_out,
124 pwr_for_peak_eff,
125 pwr_idle_fuel,
126 state: FuelConverterState::default(),
127 history: FuelConverterStateHistoryVec::default(),
128 save_interval,
129 };
130 fc.init()?;
131 Ok(fc)
132 }
133}
134
135impl SerdeAPI for FuelConverter {}
136impl Init for FuelConverter {
137 fn init(&mut self) -> Result<(), Error> {
138 let _ = self
139 .mass()
140 .map_err(|err| Error::InitError(format_dbg!(err)))?;
141 self.thrml.init()?;
142 self.state
143 .init()
144 .map_err(|err| Error::InitError(format_dbg!(err)))?;
145 let eff_max = self
146 .get_eff_max()
147 .map_err(|err| Error::InitError(format_dbg!(err)))?;
148 self.pwr_for_peak_eff = match &self.eff_interp_from_pwr_out {
149 InterpolatorEnum::Interp1D(interp) => *interp.data.grid[0]
150 .get(
151 interp
152 .data
153 .values
154 .iter()
155 .position(|eff| eff == eff_max)
156 .ok_or_else(|| Error::InitError(format_dbg!()))?,
157 )
158 .ok_or_else(|| Error::InitError(format_dbg!()))?,
159 _ => {
160 return Err(Error::InitError(format_dbg!(
161 "Only 1-D interpolators are supported"
162 )))
163 }
164 } * self.pwr_out_max;
165 Ok(())
166 }
167}
168impl HistoryMethods for FuelConverter {
169 fn save_interval(&self) -> anyhow::Result<Option<usize>> {
170 Ok(self.save_interval)
171 }
172 fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
173 self.save_interval = save_interval;
174 self.thrml.set_save_interval(save_interval)?;
175 Ok(())
176 }
177 fn clear(&mut self) {
178 self.history.clear();
179 self.thrml.clear();
180 }
181}
182
183impl Mass for FuelConverter {
184 fn mass(&self) -> anyhow::Result<Option<si::Mass>> {
185 let derived_mass = self
186 .derived_mass()
187 .with_context(|| anyhow!(format_dbg!()))?;
188 if let (Some(derived_mass), Some(set_mass)) = (derived_mass, self.mass) {
189 ensure!(
190 utils::almost_eq_uom(&set_mass, &derived_mass, None),
191 format!(
192 "{}",
193 format_dbg!(utils::almost_eq_uom(&set_mass, &derived_mass, None)),
194 )
195 );
196 }
197 Ok(self.mass)
198 }
199
200 fn set_mass(
201 &mut self,
202 new_mass: Option<si::Mass>,
203 side_effect: MassSideEffect,
204 ) -> anyhow::Result<()> {
205 let derived_mass = self
206 .derived_mass()
207 .with_context(|| anyhow!(format_dbg!()))?;
208 self.mass = match (new_mass, derived_mass) {
209 (Some(new_mass), Some(dm)) => {
211 if dm != new_mass {
212 match side_effect {
213 MassSideEffect::Extensive => {
214 self.pwr_out_max = self.specific_pwr.with_context(|| {
215 format!(
216 "{}\nExpected `self.specific_pwr` to be `Some`.",
217 format_dbg!()
218 )
219 })? * new_mass;
220 }
221 MassSideEffect::Intensive => {
222 self.specific_pwr = Some(self.pwr_out_max / new_mass);
223 }
224 MassSideEffect::None => {
225 self.specific_pwr = None;
226 }
227 }
228 }
229 Some(new_mass)
230 }
231 (Some(new_mass), None) => Some(new_mass),
232 (None, Some(dm)) => Some(dm),
233 (None, None) => bail!(
234 "Not all mass fields in `{}` are set and no mass was provided.",
235 stringify!(FuelConverter)
236 ),
237 };
238 ensure!(
239 self.mass > Some(0.0 * uc::KG),
240 "{} mass must be positive",
241 stringify!(FuelConverter)
242 );
243 Ok(())
244 }
245
246 fn derived_mass(&self) -> anyhow::Result<Option<si::Mass>> {
247 Ok(self
248 .specific_pwr
249 .map(|specific_pwr| self.pwr_out_max / specific_pwr))
250 }
251
252 fn expunge_mass_fields(&mut self) {
253 self.mass = None;
254 self.specific_pwr = None;
255 }
256}
257
258impl FuelConverter {
260 pub fn set_curr_pwr_out_max(&mut self, dt: si::Time) -> anyhow::Result<()> {
265 if self.pwr_out_max_init == si::Power::ZERO {
266 self.pwr_out_max_init = self.pwr_out_max / 10.
268 };
269 let pwr_out_max = (*self.state.pwr_prop.get_stale(|| format_dbg!())?
270 + *self.state.pwr_aux.get_stale(|| format_dbg!())?
271 + self.pwr_out_max / self.pwr_ramp_lag * dt)
272 .min(self.pwr_out_max)
273 .max(self.pwr_out_max_init);
274 self.state
275 .pwr_out_max
276 .update(pwr_out_max, || format_dbg!())?;
277 Ok(())
278 }
279
280 pub fn set_curr_pwr_prop_max(&mut self, pwr_aux: si::Power) -> anyhow::Result<()> {
285 ensure!(
286 pwr_aux >= si::Power::ZERO,
287 format!(
288 "{}\n`pwr_aux` must be >= 0",
289 format_dbg!(pwr_aux >= si::Power::ZERO),
290 )
291 );
292 self.state.pwr_aux.update(pwr_aux, || format_dbg!())?;
293 self.state.pwr_prop_max.update(
294 *self.state.pwr_out_max.get_fresh(|| format_dbg!())? - pwr_aux,
295 || format_dbg!(),
296 )?;
297 Ok(())
298 }
299
300 pub fn solve(
306 &mut self,
307 pwr_out_req: si::Power,
308 fc_on: bool,
309 dt: si::Time,
310 ) -> anyhow::Result<()> {
311 self.state.fc_on.update(fc_on, || format_dbg!())?;
312 if fc_on {
313 self.state.time_on.increment(dt, || format_dbg!())?;
314 } else {
315 self.state
316 .time_on
317 .update(si::Time::ZERO, || format_dbg!())?;
318 }
319 ensure!(
321 pwr_out_req >= si::Power::ZERO,
322 format!(
323 "{}\n`pwr_out_req` must be >= 0",
324 format_dbg!(pwr_out_req >= si::Power::ZERO),
325 )
326 );
327 ensure!(
328 pwr_out_req <= *self.state.pwr_prop_max.get_fresh(|| format_dbg!())?,
329 format!(
330 "{}\n`pwr_out_req` ({} W) must be < `self.state.pwr_prop_max` ({} W)",
331 format_dbg!(),
332 pwr_out_req.get::<si::watt>().format_eng(Some(5)),
333 self.state
334 .pwr_prop_max
335 .get_fresh(|| format_dbg!())?
336 .get::<si::watt>()
337 .format_eng(Some(5))
338 )
339 );
340 ensure!(
342 fc_on || (pwr_out_req == si::Power::ZERO && *self.state.pwr_aux.get_fresh(|| format_dbg!())? == si::Power::ZERO),
343 format!(
344 "{}\nEngine is off but pwr_out_req + pwr_aux is non-zero\n`pwr_out_req`: {} kW\n`self.state.pwr_aux`: {} kW",
345 format_dbg!(
346 fc_on
347 || (pwr_out_req == si::Power::ZERO
348 && *self.state.pwr_aux.get_fresh(|| format_dbg!())? == si::Power::ZERO)
349 ),
350 pwr_out_req.get::<si::kilowatt>(),
351 self.state.pwr_aux.get_fresh(|| format_dbg!())?.get::<si::kilowatt>()
352 )
353 );
354 self.state.pwr_prop.update(pwr_out_req, || format_dbg!())?;
355 self.state.eff.update(
356 if fc_on {
357 uc::R
358 * self
359 .eff_interp_from_pwr_out
360 .interpolate(&[((pwr_out_req
361 + *self.state.pwr_aux.get_fresh(|| format_dbg!())?)
362 / self.pwr_out_max)
363 .get::<si::ratio>()])
364 .with_context(|| {
365 anyhow!(
366 "{}\n failed to calculate {}",
367 format_dbg!(),
368 stringify!(self.state.eff)
369 )
370 })?
371 } else {
372 si::Ratio::ZERO
373 } * match self.thrml.temp_eff_coeff() {
374 Some(tec) => *tec.get_fresh(|| format_dbg!())?,
375 None => 1.0 * uc::R,
376 },
377 || format_dbg!(),
378 )?;
379 ensure!(
380 (*self.state.eff.get_fresh(|| format_dbg!())? >= 0.0 * uc::R
381 && *self.state.eff.get_fresh(|| format_dbg!())? <= 1.0 * uc::R),
382 format!(
383 "fc efficiency ({}) must be either between 0 and 1",
384 self.state
385 .eff
386 .get_fresh(|| format_dbg!())?
387 .get::<si::ratio>()
388 )
389 );
390
391 self.state.pwr_fuel.update(
392 if *self.state.fc_on.get_fresh(|| format_dbg!())? {
393 ((pwr_out_req + *self.state.pwr_aux.get_fresh(|| format_dbg!())?)
394 / *self.state.eff.get_fresh(|| format_dbg!())?)
395 .max(self.pwr_idle_fuel)
396 } else {
397 si::Power::ZERO
398 },
399 || format_dbg!(),
400 )?;
401 self.state.pwr_loss.update(
402 *self.state.pwr_fuel.get_fresh(|| format_dbg!())?
403 - *self.state.pwr_prop.get_fresh(|| format_dbg!())?,
404 || format_dbg!(),
405 )?;
406
407 Ok(())
416 }
417
418 pub fn solve_thermal(
419 &mut self,
420 te_amb: si::Temperature,
421 pwr_thrml_fc_to_cab: Option<si::Power>,
422 veh_state: &mut VehicleState,
423 dt: si::Time,
424 ) -> anyhow::Result<()> {
425 let veh_speed = *veh_state.speed_ach.get_stale(|| format_dbg!())?;
426 self.thrml
427 .solve_thermal(&self.state, te_amb, pwr_thrml_fc_to_cab, veh_speed, dt)
428 .with_context(|| format_dbg!())
429 }
430
431 pub fn temperature(&self) -> Option<&TrackedState<si::Temperature>> {
433 match &self.thrml {
434 FuelConverterThermalOption::FuelConverterThermal(fct) => Some(&fct.state.temperature),
435 FuelConverterThermalOption::None => None,
436 }
437 }
438
439 pub fn get_eff_max(&self) -> anyhow::Result<&f64> {
441 self.eff_interp_from_pwr_out.max()
442 }
443
444 pub fn get_eff_min(&self) -> anyhow::Result<&f64> {
446 self.eff_interp_from_pwr_out.min()
447 }
448
449 pub fn set_eff_max(
452 &mut self,
453 eff_max: f64,
454 scaling: Option<ScalingMethods>,
455 ) -> anyhow::Result<()> {
456 if (0.0..=1.0).contains(&eff_max) {
457 self.eff_interp_from_pwr_out.set_max(eff_max, scaling)?;
458 } else {
459 return Err(anyhow!(
460 "`eff_max` ({:.3}) must be between 0.0 and 1.0",
461 eff_max,
462 ));
463 }
464 self.init().map_err(|err| anyhow!("{:?}", err))?;
466 Ok(())
467 }
468
469 pub fn set_eff_min(
472 &mut self,
473 eff_min: f64,
474 scaling: Option<ScalingMethods>,
475 ) -> anyhow::Result<()> {
476 self.eff_interp_from_pwr_out.set_min(eff_min, scaling)
477 }
478
479 pub fn set_eff_range(&mut self, eff_range: f64) -> anyhow::Result<()> {
483 if (0. ..=1.0).contains(&eff_range) {
484 self.eff_interp_from_pwr_out.set_range(eff_range)
485 } else {
486 Err(anyhow!(format!(
487 "`eff_range` ({:.3}) must be between 0.0 and 1.0",
488 eff_range,
489 )))
490 }
491 }
492
493 pub fn fc_thrml_state_mut(&mut self) -> Option<&mut FuelConverterThermalState> {
494 match &mut self.thrml {
495 FuelConverterThermalOption::FuelConverterThermal(fct) => Some(&mut fct.state),
496 FuelConverterThermalOption::None => None,
497 }
498 }
499}
500
501impl TryFrom<FCBuilder> for FuelConverter {
502 type Error = anyhow::Error;
503 fn try_from(fcbuilder: FCBuilder) -> Result<FuelConverter, anyhow::Error> {
504 let mut fc = FuelConverter {
505 state: Default::default(),
506 thrml: Default::default(),
507 mass: None,
508 specific_pwr: None,
509 pwr_out_max: fcbuilder.pwr_out_max,
510 pwr_out_max_init: fcbuilder.pwr_out_max / fcbuilder.pwr_ramp_lag.get::<si::second>(),
512 pwr_ramp_lag: fcbuilder.pwr_ramp_lag,
513 eff_interp_from_pwr_out: fcbuilder.eff_interp_from_pwr_out,
514 pwr_for_peak_eff: uc::KW * f64::NAN, pwr_idle_fuel: si::Power::ZERO,
518 save_interval: Some(1),
519 history: Default::default(),
520 };
521 fc.init()?;
522 Ok(fc)
523 }
524}
525
526#[serde_api]
527#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
528#[serde(deny_unknown_fields)]
529#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
530pub struct FCBuilder {
532 pub pwr_out_max: si::Power,
533 pub pwr_ramp_lag: si::Time,
536 #[serde(serialize_with = "serialize_nested")]
538 pub eff_interp_from_pwr_out: InterpolatorEnum<f64>,
539 #[serde(skip)]
541 pub(crate) pwr_for_peak_eff: si::Power,
542 pub pwr_idle_fuel: si::Power,
544 pub save_interval: Option<usize>,
546}
547
548#[serde_api]
549#[derive(
550 Clone,
551 Debug,
552 Default,
553 Deserialize,
554 Serialize,
555 PartialEq,
556 HistoryVec,
557 StateMethods,
558 SetCumulative,
559)]
560#[non_exhaustive]
561#[serde(default)]
562#[serde(deny_unknown_fields)]
563#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
564pub struct FuelConverterState {
565 pub i: TrackedState<usize>,
567 pub pwr_out_max: TrackedState<si::Power>,
569 pub pwr_prop_max: TrackedState<si::Power>,
571 pub eff: TrackedState<si::Ratio>,
573 pub pwr_prop: TrackedState<si::Power>,
575 pub energy_prop: TrackedState<si::Energy>,
577 pub pwr_aux: TrackedState<si::Power>,
579 pub energy_aux: TrackedState<si::Energy>,
581 pub pwr_fuel: TrackedState<si::Power>,
583 pub energy_fuel: TrackedState<si::Energy>,
585 pub pwr_loss: TrackedState<si::Power>,
587 pub energy_loss: TrackedState<si::Energy>,
589 pub fc_on: TrackedState<bool>,
591 pub time_on: TrackedState<si::Time>,
593}
594
595#[pyo3_api]
596impl FuelConverterState {}
597impl SerdeAPI for FuelConverterState {}
598impl Init for FuelConverterState {}
599
600#[derive(
602 Clone, Default, Debug, Serialize, Deserialize, PartialEq, IsVariant, derive_more::From, TryInto,
603)]
604pub enum FuelConverterThermalOption {
605 FuelConverterThermal(Box<FuelConverterThermal>),
607 #[default]
609 None,
610}
611
612impl StateMethods for FuelConverterThermalOption {}
613
614impl SaveState for FuelConverterThermalOption {
615 fn save_state<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
616 match self {
617 Self::FuelConverterThermal(fct) => fct.save_state(loc)?,
618 Self::None => {}
619 }
620 Ok(())
621 }
622}
623impl TrackedStateMethods for FuelConverterThermalOption {
624 fn check_and_reset<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
625 match self {
626 Self::FuelConverterThermal(fct) => {
627 fct.check_and_reset(|| format!("{}\n{}", loc(), format_dbg!()))?
628 }
629 Self::None => {}
630 }
631 Ok(())
632 }
633
634 fn mark_fresh<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
635 match self {
636 Self::FuelConverterThermal(fct) => {
637 fct.mark_fresh(|| format!("{}\n{}", loc(), format_dbg!()))?
638 }
639 Self::None => {}
640 }
641 Ok(())
642 }
643}
644impl Step for FuelConverterThermalOption {
645 fn step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
646 match self {
647 Self::FuelConverterThermal(fct) => fct.step(|| format!("{}\n{}", loc(), format_dbg!())),
648 Self::None => Ok(()),
649 }
650 }
651
652 fn reset_step<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
653 match self {
654 Self::FuelConverterThermal(fct) => {
655 fct.reset_step(|| format!("{}\n{}", loc(), format_dbg!()))
656 }
657 Self::None => Ok(()),
658 }
659 }
660}
661impl Init for FuelConverterThermalOption {
662 fn init(&mut self) -> Result<(), Error> {
663 match self {
664 Self::FuelConverterThermal(fct) => fct.init()?,
665 Self::None => {}
666 }
667 Ok(())
668 }
669}
670impl SerdeAPI for FuelConverterThermalOption {}
671impl SetCumulative for FuelConverterThermalOption {
672 fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
673 match self {
674 Self::FuelConverterThermal(fct) => {
675 fct.set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))?
676 }
677 Self::None => {}
678 }
679 Ok(())
680 }
681
682 fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
683 match self {
684 Self::FuelConverterThermal(fct) => {
685 fct.reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))?
686 }
687 Self::None => {}
688 }
689 Ok(())
690 }
691}
692impl HistoryMethods for FuelConverterThermalOption {
693 fn save_interval(&self) -> anyhow::Result<Option<usize>> {
694 match self {
695 FuelConverterThermalOption::FuelConverterThermal(fct) => fct.save_interval(),
696 FuelConverterThermalOption::None => Ok(None),
697 }
698 }
699 fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
700 match self {
701 FuelConverterThermalOption::FuelConverterThermal(fct) => {
702 fct.set_save_interval(save_interval)
703 }
704 FuelConverterThermalOption::None => Ok(()),
705 }
706 }
707 fn clear(&mut self) {
708 match self {
709 FuelConverterThermalOption::FuelConverterThermal(fct) => {
710 fct.clear();
711 }
712 FuelConverterThermalOption::None => {}
713 }
714 }
715}
716impl FuelConverterThermalOption {
717 fn solve_thermal(
724 &mut self,
725 fc_state: &FuelConverterState,
726 te_amb: si::Temperature,
727 pwr_thrml_fc_to_cab: Option<si::Power>,
728 veh_speed: si::Velocity,
729 dt: si::Time,
730 ) -> anyhow::Result<()> {
731 match self {
732 Self::FuelConverterThermal(fct) => fct
733 .solve(
734 fc_state,
735 te_amb,
736 pwr_thrml_fc_to_cab.unwrap_or_default(),
737 veh_speed,
738 dt,
739 )
740 .with_context(|| format_dbg!())?,
741 Self::None => {
742 ensure!(
743 pwr_thrml_fc_to_cab.is_none(),
744 format_dbg!(
745 "`FuelConverterThermal needs to be configured to provide heat demand`"
746 )
747 );
748 }
749 }
750 Ok(())
751 }
752
753 fn temp_eff_coeff(&self) -> Option<&TrackedState<si::Ratio>> {
755 match self {
756 Self::FuelConverterThermal(fct) => Some(&fct.state.eff_coeff),
757 Self::None => None,
758 }
759 }
760}
761
762#[serde_api]
763#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, StateMethods)]
764#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
765#[non_exhaustive]
766#[serde(deny_unknown_fields)]
767pub struct FuelConverterThermal {
769 pub heat_capacitance: si::HeatCapacity,
771 pub length_for_convection: si::Length,
773 pub htc_to_amb_stop: si::HeatTransferCoeff,
775
776 pub conductance_from_comb: si::ThermalConductance,
778 pub max_frac_from_comb: si::Ratio,
781 pub tstat_te_sto: Option<si::Temperature>,
783 pub tstat_te_delta: Option<si::TemperatureInterval>,
785 #[serde(default = "tstat_interp_default", serialize_with = "serialize_nested")]
786 pub tstat_interp: Interp1D<f64, strategy::Linear>,
787 pub radiator_effectiveness: si::Ratio,
790 pub fc_eff_model: FCTempEffModel,
792 #[serde(default)]
794 pub state: FuelConverterThermalState,
795 #[serde(
797 default,
798 skip_serializing_if = "FuelConverterThermalStateHistoryVec::is_empty"
799 )]
800 pub history: FuelConverterThermalStateHistoryVec,
801 pub save_interval: Option<usize>,
802}
803
804#[pyo3_api]
805impl FuelConverterThermal {
806 #[staticmethod]
807 #[pyo3(name = "default")]
808 fn default_py() -> Self {
809 Default::default()
810 }
811}
812
813impl FuelConverterThermal {
814 pub fn new(
815 heat_capacitance: si::HeatCapacity,
816 length_for_convection: si::Length,
817 htc_to_amb_stop: si::HeatTransferCoeff,
818 conductance_from_comb: si::ThermalConductance,
819 max_frac_from_comb: si::Ratio,
820 tstat_te_sto: Option<si::Temperature>,
821 tstat_te_delta: Option<si::TemperatureInterval>,
822 tstat_interp: Interp1D<f64, strategy::Linear>,
823 radiator_effectiveness: si::Ratio,
824 fc_eff_model: FCTempEffModel,
825 save_interval: Option<usize>,
826 ) -> anyhow::Result<Self> {
827 let mut fc_thermal = Self {
828 heat_capacitance,
829 length_for_convection,
830 htc_to_amb_stop,
831 conductance_from_comb,
832 max_frac_from_comb,
833 tstat_te_sto,
834 tstat_te_delta,
835 tstat_interp,
836 radiator_effectiveness,
837 fc_eff_model,
838 state: FuelConverterThermalState::default(),
839 history: FuelConverterThermalStateHistoryVec::default(),
840 save_interval,
841 };
842 fc_thermal.init()?;
843 Ok(fc_thermal)
844 }
845}
846
847impl HistoryMethods for FuelConverterThermal {
848 fn save_interval(&self) -> anyhow::Result<Option<usize>> {
849 Ok(self.save_interval)
850 }
851 fn set_save_interval(&mut self, save_interval: Option<usize>) -> anyhow::Result<()> {
852 self.save_interval = save_interval;
853 Ok(())
854 }
855 fn clear(&mut self) {
856 self.history.clear();
857 }
858}
859
860fn tstat_interp_default() -> Interp1D<f64, strategy::Linear> {
862 Interp1D::new(
863 array![85.0, 90.0],
864 array![0.0, 1.0],
865 strategy::Linear,
866 Extrapolate::Clamp,
867 )
868 .unwrap()
869}
870
871lazy_static! {
872 pub static ref AFR_STOICH_GASOLINE: si::Ratio = uc::R * 14.7;
874 pub static ref GASOLINE_DENSITY: si::MassDensity = 0.75 * uc::KG / uc::L;
877 pub static ref GASOLINE_LHV: si::SpecificEnergy = 33.7 * uc::KWH / uc::GALLON / *GASOLINE_DENSITY;
879 pub static ref TE_ADIABATIC_STD: si::Temperature = Air::get_te_from_u(
880 Air::get_specific_energy(*TE_STD_AIR).with_context(|| format_dbg!()).unwrap()
881 + (Octane::get_specific_energy(*TE_STD_AIR).with_context(|| format_dbg!()).unwrap()
882 + *GASOLINE_LHV)
883 / *AFR_STOICH_GASOLINE,
884 )
885 .with_context(|| format_dbg!()).unwrap_or_else(|_| panic!("{}\nFailed to calculate adiabatic flame temp for gasoline", format_dbg!()));
886}
887
888impl FuelConverterThermal {
889 fn solve(
897 &mut self,
898 fc_state: &FuelConverterState,
899 te_amb: si::Temperature,
900 pwr_thrml_fc_to_cab: si::Power,
901 veh_speed: si::Velocity,
902 dt: si::Time,
903 ) -> anyhow::Result<()> {
904 self.state
905 .pwr_thrml_fc_to_cab
906 .update(pwr_thrml_fc_to_cab, || format_dbg!())?;
907 let te_air_film: si::Temperature = 0.5
909 * (self
910 .state
911 .temperature
912 .get_stale(|| format_dbg!())?
913 .get::<si::kelvin_abs>()
914 + te_amb.get::<si::kelvin_abs>())
915 * uc::KELVIN;
916 let fc_air_film_re =
919 Air::get_density(Some(te_air_film), None) * veh_speed * self.length_for_convection
920 / Air::get_dyn_visc(te_air_film).with_context(|| format_dbg!())?;
921
922 self.state.htc_to_amb.update(
924 if veh_speed < 1.0 * uc::MPS {
925 self.state.tstat_open_frac.update(
927 self.tstat_interp
928 .interpolate(&[self
929 .state
930 .temperature
931 .get_stale(|| format_dbg!())?
932 .get::<si::degree_celsius>()])
933 .with_context(|| format_dbg!())?,
934 || format_dbg!(),
935 )?;
936 (uc::R
937 + *self.state.tstat_open_frac.get_fresh(|| format_dbg!())?
938 * self.radiator_effectiveness)
939 * self.htc_to_amb_stop
940 } else {
941 let sphere_conv_params = get_sphere_conv_params(fc_air_film_re.get::<si::ratio>());
944 let htc_to_amb_sphere: si::HeatTransferCoeff = sphere_conv_params.0
945 * fc_air_film_re.get::<si::ratio>().powf(sphere_conv_params.1)
946 * Air::get_pr(te_air_film)
947 .with_context(|| format_dbg!())?
948 .get::<si::ratio>()
949 .powf(1.0 / 3.0)
950 * Air::get_therm_cond(te_air_film).with_context(|| format_dbg!())?
951 / self.length_for_convection;
952 self.state.tstat_open_frac.update(
954 self.tstat_interp
955 .interpolate(&[self
956 .state
957 .temperature
958 .get_stale(|| format_dbg!())?
959 .get::<si::degree_celsius>()])
960 .with_context(|| format_dbg!())?,
961 || format_dbg!(),
962 )?;
963 *self.state.tstat_open_frac.get_fresh(|| format_dbg!())? * htc_to_amb_sphere
964 },
965 || format_dbg!(),
966 )?;
967
968 self.state.pwr_thrml_to_amb.update(
969 *self.state.htc_to_amb.get_fresh(|| format_dbg!())?
970 * PI
971 * self.length_for_convection.powi(P2::new())
972 / 4.0
973 * (self
974 .state
975 .temperature
976 .get_stale(|| format_dbg!())?
977 .get::<si::degree_celsius>()
978 - te_amb.get::<si::degree_celsius>())
979 * uc::KELVIN_INT,
980 || format_dbg!(),
981 )?;
982
983 self.state.te_adiabatic.update(
987 Air::get_te_from_u(
988 Air::get_specific_energy(*self.state.temperature.get_stale(|| format_dbg!())?)
989 .with_context(|| format_dbg!())?
990 + (Octane::get_specific_energy(*self.state.temperature.get_stale(|| format_dbg!())?)
991 .with_context(|| format_dbg!())?
992 + *GASOLINE_LHV)
994 / *AFR_STOICH_GASOLINE,
995 )
996 .with_context(|| format_dbg!())?,
997 || format_dbg!(),
998 )?;
999 self.state.pwr_fuel_as_heat.update(
1001 *fc_state.pwr_fuel.get_stale(|| format_dbg!())?
1002 - (*fc_state.pwr_prop.get_stale(|| format_dbg!())?
1003 + *fc_state.pwr_aux.get_stale(|| format_dbg!())?),
1004 || format_dbg!(),
1005 )?;
1006 self.state.pwr_thrml_to_tm.update(
1007 (self.conductance_from_comb
1008 * (self
1009 .state
1010 .te_adiabatic
1011 .get_fresh(|| format_dbg!())?
1012 .get::<si::degree_celsius>()
1013 - self
1014 .state
1015 .temperature
1016 .get_stale(|| format_dbg!())?
1017 .get::<si::degree_celsius>())
1018 * uc::KELVIN_INT)
1019 .min(
1020 self.max_frac_from_comb
1021 * *self.state.pwr_fuel_as_heat.get_fresh(|| format_dbg!())?,
1022 ),
1023 || format_dbg!(),
1024 )?;
1025 let delta_temp: si::TemperatureInterval =
1026 ((*self.state.pwr_thrml_to_tm.get_fresh(|| format_dbg!())?
1027 - *self.state.pwr_thrml_fc_to_cab.get_fresh(|| format_dbg!())?
1028 - *self.state.pwr_thrml_to_amb.get_fresh(|| format_dbg!())?)
1029 * dt)
1030 / self.heat_capacitance;
1031 self.state.temperature.update(
1033 *self.state.temperature.get_stale(|| format_dbg!())? + delta_temp,
1034 || format_dbg!(),
1035 )?;
1036
1037 self.state.eff_coeff.update(
1038 match self.fc_eff_model {
1039 FCTempEffModel::Linear(FCTempEffModelLinear {
1040 offset,
1041 slope_per_kelvin: slope,
1042 minimum,
1043 }) => minimum.max(
1044 {
1045 let calc_unbound: si::Ratio = offset
1046 + slope * uc::R / uc::KELVIN
1047 * *self.state.temperature.get_fresh(|| format_dbg!())?;
1048 calc_unbound
1049 }
1050 .min(1.0 * uc::R),
1051 ),
1052 FCTempEffModel::Exponential(FCTempEffModelExponential {
1053 offset,
1054 lag,
1055 minimum,
1056 }) => {
1057 let dte: si::TemperatureInterval = (self
1058 .state
1059 .temperature
1060 .get_fresh(|| format_dbg!())?
1061 .get::<si::kelvin_abs>()
1062 - offset.get::<si::kelvin_abs>())
1063 * uc::KELVIN_INT;
1064 ((1.0 - f64::exp((-dte / lag).get::<si::ratio>())) * uc::R).max(minimum)
1065 }
1066 },
1067 || format_dbg!(),
1068 )?;
1069 Ok(())
1070 }
1071}
1072impl SerdeAPI for FuelConverterThermal {}
1073impl SetCumulative for FuelConverterThermal {
1074 fn set_cumulative<F: Fn() -> String>(&mut self, dt: si::Time, loc: F) -> anyhow::Result<()> {
1075 self.state
1076 .set_cumulative(dt, || format!("{}\n{}", loc(), format_dbg!()))
1077 }
1078
1079 fn reset_cumulative<F: Fn() -> String>(&mut self, loc: F) -> anyhow::Result<()> {
1080 self.state
1081 .reset_cumulative(|| format!("{}\n{}", loc(), format_dbg!()))
1082 }
1083}
1084impl Init for FuelConverterThermal {
1085 fn init(&mut self) -> Result<(), Error> {
1086 self.tstat_te_sto = self
1087 .tstat_te_sto
1088 .or(Some((85. + uc::CELSIUS_TO_KELVIN) * uc::KELVIN));
1089 self.tstat_te_delta = self.tstat_te_delta.or(Some(5. * uc::KELVIN_INT));
1090 self.tstat_interp = Interp1D::new(
1091 array![
1092 self.tstat_te_sto.unwrap().get::<si::degree_celsius>(),
1093 self.tstat_te_sto.unwrap().get::<si::degree_celsius>()
1094 + self.tstat_te_delta.unwrap().get::<si::kelvin>(),
1095 ],
1096 array![0.0, 1.0],
1097 strategy::Linear,
1098 Extrapolate::Clamp,
1099 )
1100 .map_err(|err| {
1101 Error::InitError(format!(
1102 "{}\n{}\n{}",
1103 err,
1104 format_dbg!(self.tstat_te_sto),
1105 format_dbg!(self.tstat_te_delta)
1106 ))
1107 })?;
1108 Ok(())
1109 }
1110}
1111impl Default for FuelConverterThermal {
1112 fn default() -> Self {
1113 let mut fct = Self {
1114 heat_capacitance: Default::default(),
1115 length_for_convection: Default::default(),
1116 htc_to_amb_stop: Default::default(),
1117 conductance_from_comb: Default::default(),
1118 max_frac_from_comb: Default::default(),
1119 tstat_te_sto: None,
1120 tstat_te_delta: None,
1121 tstat_interp: tstat_interp_default(),
1122 radiator_effectiveness: Default::default(),
1123 fc_eff_model: Default::default(),
1124 state: Default::default(),
1125 history: Default::default(),
1126 save_interval: Some(1),
1127 };
1128 fct.init().unwrap();
1129 fct
1130 }
1131}
1132
1133#[serde_api]
1134#[derive(
1135 Clone, Debug, Deserialize, Serialize, PartialEq, HistoryVec, StateMethods, SetCumulative,
1136)]
1137#[serde(default)]
1138#[cfg_attr(feature = "pyo3", pyclass(module = "fastsim", subclass, eq))]
1139#[serde(deny_unknown_fields)]
1140pub struct FuelConverterThermalState {
1141 pub i: TrackedState<usize>,
1143 pub te_adiabatic: TrackedState<si::Temperature>,
1146 pub temperature: TrackedState<si::Temperature>,
1148 pub tstat_open_frac: TrackedState<f64>,
1150 pub htc_to_amb: TrackedState<si::HeatTransferCoeff>,
1152 pub pwr_thrml_to_amb: TrackedState<si::Power>,
1154 pub energy_thrml_to_amb: TrackedState<si::Energy>,
1156 pub eff_coeff: TrackedState<si::Ratio>,
1158 pub pwr_thrml_fc_to_cab: TrackedState<si::Power>,
1160 pub energy_thrml_fc_to_cab: TrackedState<si::Energy>,
1162 pub pwr_fuel_as_heat: TrackedState<si::Power>,
1164 pub energy_fuel_as_heat: TrackedState<si::Energy>,
1166 pub pwr_thrml_to_tm: TrackedState<si::Power>,
1168 pub energy_thrml_to_tm: TrackedState<si::Energy>,
1170}
1171#[pyo3_api]
1172impl FuelConverterThermalState {}
1173
1174impl Init for FuelConverterThermalState {}
1175impl SerdeAPI for FuelConverterThermalState {}
1176impl Default for FuelConverterThermalState {
1177 fn default() -> Self {
1178 Self {
1179 i: Default::default(),
1180 te_adiabatic: TrackedState::new(*TE_ADIABATIC_STD),
1181 temperature: TrackedState::new(*TE_STD_AIR),
1182 tstat_open_frac: Default::default(),
1183 htc_to_amb: Default::default(),
1184 eff_coeff: TrackedState::new(uc::R),
1185 pwr_thrml_fc_to_cab: Default::default(),
1186 energy_thrml_fc_to_cab: Default::default(),
1187 pwr_thrml_to_amb: Default::default(),
1188 energy_thrml_to_amb: Default::default(),
1189 pwr_fuel_as_heat: Default::default(),
1190 energy_fuel_as_heat: Default::default(),
1191 pwr_thrml_to_tm: Default::default(),
1192 energy_thrml_to_tm: Default::default(),
1193 }
1194 }
1195}
1196
1197#[derive(
1199 Debug, Clone, Deserialize, Serialize, PartialEq, IsVariant, derive_more::From, TryInto,
1200)]
1201pub enum FCTempEffModel {
1202 Linear(FCTempEffModelLinear),
1204 Exponential(FCTempEffModelExponential),
1206}
1207
1208impl Default for FCTempEffModel {
1209 fn default() -> Self {
1210 FCTempEffModel::Exponential(FCTempEffModelExponential::default())
1211 }
1212}
1213
1214#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
1215#[serde(deny_unknown_fields)]
1216pub struct FCTempEffModelLinear {
1217 pub offset: si::Ratio,
1218 pub slope_per_kelvin: f64,
1220 pub minimum: si::Ratio,
1221}
1222
1223impl FCTempEffModelLinear {
1224 pub fn new(
1225 offset: si::Ratio,
1226 slope_per_kelvin: f64,
1227 minimum: si::Ratio,
1228 ) -> anyhow::Result<Self> {
1229 Ok(Self {
1230 offset,
1231 slope_per_kelvin,
1232 minimum,
1233 })
1234 }
1235}
1236
1237impl Default for FCTempEffModelLinear {
1238 fn default() -> Self {
1239 Self {
1240 offset: 0.0 * uc::R,
1241 slope_per_kelvin: 25.0,
1242 minimum: 0.2 * uc::R,
1243 }
1244 }
1245}
1246
1247#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
1248#[serde(deny_unknown_fields)]
1249pub struct FCTempEffModelExponential {
1250 pub offset: si::Temperature,
1252 pub lag: si::TemperatureInterval,
1254 pub minimum: si::Ratio,
1256}
1257
1258impl FCTempEffModelExponential {
1259 pub fn new(
1260 offset: si::Temperature,
1261 lag: si::TemperatureInterval,
1262 minimum: si::Ratio,
1263 ) -> anyhow::Result<Self> {
1264 Ok(Self {
1265 offset,
1266 lag,
1267 minimum,
1268 })
1269 }
1270}
1271
1272impl Default for FCTempEffModelExponential {
1273 fn default() -> Self {
1274 Self {
1275 offset: 0.0 * uc::KELVIN,
1277 lag: 25.0 * uc::KELVIN_INT,
1278 minimum: 0.2 * uc::R,
1279 }
1280 }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use approx::assert_abs_diff_eq;
1286
1287 use super::*;
1288
1289 struct FuelConverterAndResult {
1290 fc: FuelConverter,
1291 result: anyhow::Result<()>,
1292 }
1293
1294 const EFF_AT_000_PERCENT_PWR: f64 = 0.30;
1297 const EFF_AT_080_PERCENT_PWR: f64 = 0.35;
1298 const EFF_AT_100_PERCENT_PWR: f64 = 0.31;
1299 const PEAK_POWER_KW: f64 = 50.0;
1300
1301 fn create_test_fuel_converter(
1302 aux_pwr: si::Power,
1303 idle_pwr: si::Power,
1304 is_on: bool,
1305 ) -> FuelConverterAndResult {
1306 let peak_pwr = PEAK_POWER_KW * uc::KW;
1307 let eff_interp_pwr_out_fraction = vec![0.0, 0.8, 1.0];
1308 let eff_interp_eff_out = vec![
1309 EFF_AT_000_PERCENT_PWR,
1310 EFF_AT_080_PERCENT_PWR,
1311 EFF_AT_100_PERCENT_PWR,
1312 ];
1313 let mut fc_state = FuelConverterState::default();
1316 fc_state.i.mark_stale();
1317 let res_i_update = fc_state.i.update(1, || format_dbg!());
1318 assert!(res_i_update.is_ok());
1319 fc_state.pwr_out_max.mark_stale();
1320 fc_state.pwr_prop_max.mark_stale();
1321 let res_pwr_prop_max_update = fc_state.pwr_prop_max.update(0.0 * uc::KW, || format_dbg!());
1322 assert!(res_pwr_prop_max_update.is_ok());
1323 fc_state.eff.mark_stale();
1324 fc_state.pwr_prop.mark_stale();
1325 fc_state.energy_prop.mark_stale();
1326 fc_state.pwr_aux.mark_stale();
1327 let res_pwr_aux_update = fc_state.pwr_aux.update(aux_pwr, || format_dbg!());
1328 assert!(res_pwr_aux_update.is_ok());
1329 fc_state.energy_aux.mark_stale();
1330 fc_state.pwr_fuel.mark_stale();
1331 fc_state.energy_fuel.mark_stale();
1332 fc_state.pwr_loss.mark_stale();
1333 fc_state.energy_loss.mark_stale();
1334 fc_state.fc_on.mark_stale();
1335 fc_state.time_on.mark_stale();
1336 let mut fc = FuelConverter {
1337 thrml: FuelConverterThermalOption::None,
1338 mass: None,
1339 specific_pwr: None,
1340 pwr_out_max: peak_pwr,
1341 pwr_out_max_init: 5.0 * uc::KW,
1342 pwr_ramp_lag: 5.0 * uc::S,
1343 eff_interp_from_pwr_out: InterpolatorEnum::new_1d(
1344 eff_interp_pwr_out_fraction.into(),
1345 eff_interp_eff_out.into(),
1346 strategy::Linear,
1347 Extrapolate::Error,
1348 )
1349 .unwrap(),
1350 pwr_for_peak_eff: peak_pwr * 0.8,
1351 pwr_idle_fuel: idle_pwr,
1352 state: fc_state,
1353 history: FuelConverterStateHistoryVec::default(),
1354 save_interval: None,
1355 };
1356 let init_result = fc.init();
1357 assert!(init_result.is_ok());
1358 let pwr_out_req = 0.0 * uc::KW;
1359 let fc_on = is_on;
1360 let dt = 1.0 * uc::S;
1361 let solve_result = fc.solve(pwr_out_req, fc_on, dt);
1362 FuelConverterAndResult {
1363 fc,
1364 result: solve_result,
1365 }
1366 }
1367
1368 #[test]
1369 fn calling_solve_with_aux_load_and_fc_on() {
1370 let peak_pwr = PEAK_POWER_KW * uc::KW;
1371 let aux_pwr = 2.0 * uc::KW;
1372 let idle_pwr = 1.0 * uc::KW;
1373 let fc_is_on = true;
1374 let fc_and_res = create_test_fuel_converter(aux_pwr, idle_pwr, fc_is_on);
1375 assert!(fc_and_res.result.is_ok());
1376 let fc = fc_and_res.fc;
1377 let alpha = aux_pwr.value / (peak_pwr.value * 0.8);
1380 let expected_eff =
1381 (EFF_AT_080_PERCENT_PWR - EFF_AT_000_PERCENT_PWR) * alpha + EFF_AT_000_PERCENT_PWR;
1382 let expected_fuel_in = aux_pwr.value / expected_eff;
1383 let actual_fuel_in_result = fc.state.pwr_fuel.get_fresh(|| format_dbg!());
1384 assert!(actual_fuel_in_result.is_ok());
1385 let actual_fuel_in = actual_fuel_in_result.unwrap().value;
1386 assert_abs_diff_eq!(actual_fuel_in, expected_fuel_in);
1387 let fc_on_result = fc.state.fc_on.get_fresh(|| format_dbg!());
1388 assert!(fc_on_result.is_ok());
1389 let fc_on = *fc_on_result.unwrap();
1390 assert_eq!(fc_on, fc_is_on);
1391 }
1392
1393 #[test]
1394 fn calling_solve_with_no_aux_load_but_fc_on_causes_idle_fuel_use() {
1395 let aux_pwr = 0.0 * uc::KW;
1396 let idle_pwr = 1.0 * uc::KW;
1397 let fc_is_on = true;
1398 let fc_and_res = create_test_fuel_converter(aux_pwr, idle_pwr, fc_is_on);
1399 assert!(fc_and_res.result.is_ok());
1400 let fc = fc_and_res.fc;
1401 let expected_fuel_in = idle_pwr.value;
1402 let actual_fuel_in_result = fc.state.pwr_fuel.get_fresh(|| format_dbg!());
1403 assert!(actual_fuel_in_result.is_ok());
1404 let actual_fuel_in = actual_fuel_in_result.unwrap().value;
1405 assert_abs_diff_eq!(actual_fuel_in, expected_fuel_in);
1406 let fc_on_result = fc.state.fc_on.get_fresh(|| format_dbg!());
1407 assert!(fc_on_result.is_ok());
1408 let fc_on = *fc_on_result.unwrap();
1409 assert_eq!(fc_on, fc_is_on);
1410 }
1411
1412 #[test]
1413 fn calling_solve_with_engine_off_and_no_aux_load_results_in_no_fuel_use() {
1414 let aux_pwr = 0.0 * uc::KW;
1415 let idle_pwr = 1.0 * uc::KW;
1416 let fc_is_on = false;
1417 let fc_and_res = create_test_fuel_converter(aux_pwr, idle_pwr, fc_is_on);
1418 assert!(fc_and_res.result.is_ok());
1419 let fc = fc_and_res.fc;
1420 let expected_fuel_in = 0.0;
1421 let actual_fuel_in_result = fc.state.pwr_fuel.get_fresh(|| format_dbg!());
1422 assert!(actual_fuel_in_result.is_ok());
1423 let actual_fuel_in = actual_fuel_in_result.unwrap().value;
1424 assert_abs_diff_eq!(actual_fuel_in, expected_fuel_in);
1425 let fc_on_result = fc.state.fc_on.get_fresh(|| format_dbg!());
1426 assert!(fc_on_result.is_ok());
1427 let fc_on = *fc_on_result.unwrap();
1428 assert_eq!(fc_on, fc_is_on);
1429 }
1430
1431 #[test]
1432 fn calling_solve_with_engine_off_and_postive_aux_load_is_error() {
1433 let aux_pwr = 2.0 * uc::KW;
1434 let idle_pwr = 1.0 * uc::KW;
1435 let fc_is_on = false;
1436 let fc_and_res = create_test_fuel_converter(aux_pwr, idle_pwr, fc_is_on);
1437 assert!(fc_and_res.result.is_err());
1438 }
1439}