1use twine_core::{
2 TimeDerivative, TimeIntegrable,
3 constraint::{Constrained, ConstraintError, StrictlyPositive},
4};
5use uom::{
6 ConstZero,
7 num_traits::Zero,
8 si::f64::{MassDensity, MassRate, Power, Volume},
9};
10
11use crate::{
12 HeatFlow, MassFlow, PropertyError, State, StateDerivative, WorkFlow,
13 capability::{HasCv, HasEnthalpy, HasInternalEnergy, ThermoModel},
14};
15
16#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct ControlVolume<Fluid> {
23 volume: Constrained<Volume, StrictlyPositive>,
24 state: State<Fluid>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum BoundaryFlow<Fluid> {
30 Mass(MassFlow<Fluid>),
31 Heat(HeatFlow),
32 Work(WorkFlow),
33}
34
35impl<Fluid> ControlVolume<Fluid> {
36 pub fn new(volume: Volume, state: State<Fluid>) -> Result<Self, ConstraintError> {
42 let volume = Constrained::new(volume)?;
43 Ok(Self::from_constrained(volume, state))
44 }
45
46 pub fn from_constrained(
48 volume: Constrained<Volume, StrictlyPositive>,
49 state: State<Fluid>,
50 ) -> Self {
51 Self { volume, state }
52 }
53
54 pub fn net_mass_flow<'a, I>(flows: I) -> MassRate
61 where
62 I: IntoIterator<Item = &'a BoundaryFlow<Fluid>>,
63 Fluid: 'a,
64 {
65 flows
66 .into_iter()
67 .fold(MassRate::ZERO, |m_dot_net, flow| match flow {
68 BoundaryFlow::Mass(mass_flow) => m_dot_net + mass_flow.signed_mass_rate(),
69 _ => m_dot_net,
70 })
71 }
72
73 pub fn net_energy_flow<'a, I, Model>(
87 &self,
88 flows: I,
89 model: &Model,
90 ) -> Result<Power, PropertyError>
91 where
92 Model: ThermoModel<Fluid = Fluid> + HasEnthalpy,
93 I: IntoIterator<Item = &'a BoundaryFlow<Fluid>>,
94 Fluid: 'a,
95 {
96 let h_cv = model.enthalpy(&self.state)?;
97 flows.into_iter().try_fold(Power::ZERO, |q_dot_net, flow| {
98 let q_dot_flow = match flow {
99 BoundaryFlow::Mass(MassFlow::In(stream)) => stream.enthalpy_flow(model)?,
100 BoundaryFlow::Mass(MassFlow::Out(m_dot)) => -m_dot.into_inner() * h_cv,
101 BoundaryFlow::Mass(MassFlow::None) => Power::ZERO,
102 BoundaryFlow::Heat(heat_flow) => heat_flow.signed(),
103 BoundaryFlow::Work(work_flow) => work_flow.signed(),
104 };
105 Ok(q_dot_net + q_dot_flow)
106 })
107 }
108}
109
110impl<Fluid> ControlVolume<Fluid>
111where
112 Fluid: TimeIntegrable<Derivative = ()>,
113{
114 pub fn state_derivative<Model>(
175 &self,
176 flows: &[BoundaryFlow<Fluid>],
177 model: &Model,
178 ) -> Result<StateDerivative<Fluid>, PropertyError>
179 where
180 Model: ThermoModel<Fluid = Fluid> + HasCv + HasEnthalpy + HasInternalEnergy,
181 {
182 let volume = self.volume.into_inner();
183 let heat_capacity = volume * self.state.density * model.cv(&self.state)?;
184
185 let m_dot_net = Self::net_mass_flow(flows);
186 let q_dot_net = self.net_energy_flow(flows, model)?;
187
188 let (rho_dt, temp_dt) = if m_dot_net.is_zero() {
189 (
190 TimeDerivative::<MassDensity>::ZERO,
191 q_dot_net / heat_capacity,
192 )
193 } else {
194 let u = model.internal_energy(&self.state)?;
195 (
196 m_dot_net / volume,
197 (q_dot_net - m_dot_net * u) / heat_capacity,
198 )
199 };
200
201 Ok(StateDerivative::<Fluid> {
202 temperature: temp_dt,
203 density: rho_dt,
204 fluid: (),
205 })
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 use approx::assert_relative_eq;
214 use twine_core::TimeIntegrable;
215 use twine_core::constraint::Constrained;
216 use uom::si::{
217 f64::{
218 MassDensity, MassRate, SpecificHeatCapacity, ThermodynamicTemperature, Time, Volume,
219 },
220 mass_density::kilogram_per_cubic_meter,
221 mass_rate::kilogram_per_second,
222 power::watt,
223 specific_heat_capacity::joule_per_kilogram_kelvin,
224 thermodynamic_temperature::kelvin,
225 volume::cubic_meter,
226 };
227
228 use crate::{
229 BoundaryFlow, ControlVolume, HeatFlow, MassFlow, State, Stream, WorkFlow,
230 model::perfect_gas::{PerfectGas, PerfectGasFluid, PerfectGasParameters},
231 units::SpecificGasConstant,
232 };
233
234 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235 struct MockGas;
236
237 impl TimeIntegrable for MockGas {
238 type Derivative = ();
239
240 fn step(self, _derivative: Self::Derivative, _dt: Time) -> Self {
241 self
242 }
243 }
244
245 impl PerfectGasFluid for MockGas {
246 fn parameters() -> PerfectGasParameters {
247 PerfectGasParameters::new(
248 SpecificGasConstant::new::<joule_per_kilogram_kelvin>(400.0),
249 SpecificHeatCapacity::new::<joule_per_kilogram_kelvin>(1000.0),
250 )
251 }
252 }
253
254 fn mock_gas_model() -> PerfectGas<MockGas> {
255 PerfectGas::<MockGas>::new().expect("mock gas parameters must be physically valid")
256 }
257
258 #[test]
259 fn equal_inflow_and_outflow_conserves_mass_and_energy() {
260 let thermo = mock_gas_model();
261
262 let volume = Volume::new::<cubic_meter>(2.0);
263
264 let state = State::new(
265 ThermodynamicTemperature::new::<kelvin>(300.0),
266 MassDensity::new::<kilogram_per_cubic_meter>(1.0),
267 MockGas,
268 );
269
270 let (inflow, outflow) = MassFlow::balanced_pair(
271 Stream::new(
272 MassRate::new::<kilogram_per_second>(0.3),
273 state.with_temperature(ThermodynamicTemperature::new::<kelvin>(350.0)),
274 )
275 .unwrap(),
276 );
277
278 let derivative = ControlVolume::new(volume, state)
279 .unwrap()
280 .state_derivative(
281 &[BoundaryFlow::Mass(inflow), BoundaryFlow::Mass(outflow)],
282 &thermo,
283 )
284 .unwrap();
285
286 assert_relative_eq!(derivative.density.value, 0.0);
289
290 assert_relative_eq!(derivative.temperature.value, 12.5);
295 }
296
297 #[test]
298 fn adiabatic_outflow_decreases_temperature_and_density() {
299 let thermo = mock_gas_model();
300
301 let volume = Volume::new::<cubic_meter>(2.0);
302
303 let state = State::new(
304 ThermodynamicTemperature::new::<kelvin>(300.0),
305 MassDensity::new::<kilogram_per_cubic_meter>(1.0),
306 MockGas,
307 );
308
309 let m_dot = Constrained::new(MassRate::new::<kilogram_per_second>(0.2)).unwrap();
310
311 let derivative = ControlVolume::new(volume, state)
312 .unwrap()
313 .state_derivative(&[BoundaryFlow::Mass(MassFlow::Out(m_dot))], &thermo)
314 .unwrap();
315
316 assert_relative_eq!(derivative.density.value, -0.1);
319
320 assert_relative_eq!(derivative.temperature.value, -20.0);
324 }
325
326 #[test]
327 fn heat_input_without_mass_flow_increases_temperature() {
328 let thermo = mock_gas_model();
329
330 let volume = Volume::new::<cubic_meter>(1.0);
331
332 let state = State::new(
333 ThermodynamicTemperature::new::<kelvin>(300.0),
334 MassDensity::new::<kilogram_per_cubic_meter>(2.0),
335 MockGas,
336 );
337
338 let derivative = ControlVolume::new(volume, state)
339 .unwrap()
340 .state_derivative(
341 &[BoundaryFlow::Heat(
342 HeatFlow::incoming(Power::new::<watt>(600.0)).unwrap(),
343 )],
344 &thermo,
345 )
346 .unwrap();
347
348 assert_relative_eq!(derivative.density.value, 0.0);
350
351 assert_relative_eq!(derivative.temperature.value, 0.5);
353 }
354
355 #[test]
356 fn net_power_out_without_mass_flow_decreases_temperature() {
357 let thermo = mock_gas_model();
358
359 let volume = Volume::new::<cubic_meter>(3.0);
360
361 let state = State::new(
362 ThermodynamicTemperature::new::<kelvin>(290.0),
363 MassDensity::new::<kilogram_per_cubic_meter>(1.2),
364 MockGas,
365 );
366
367 let derivative = ControlVolume::new(volume, state)
368 .unwrap()
369 .state_derivative(
370 &[
371 BoundaryFlow::Heat(HeatFlow::incoming(Power::new::<watt>(60.0)).unwrap()),
372 BoundaryFlow::Work(WorkFlow::outgoing(Power::new::<watt>(5460.0)).unwrap()),
373 ],
374 &thermo,
375 )
376 .unwrap();
377
378 assert_relative_eq!(derivative.density.value, 0.0);
380
381 assert_relative_eq!(derivative.temperature.value, -2.5);
390 }
391}