Skip to main content

twine_thermo/
control_volume.rs

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/// A finite control volume representing a well-mixed region of fluid.
17///
18/// The internal fluid state is assumed to be spatially uniform,
19/// and any mass leaving the volume is at the current internal state.
20/// Changes in kinetic and potential energy are neglected.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct ControlVolume<Fluid> {
23    volume: Constrained<Volume, StrictlyPositive>,
24    state: State<Fluid>,
25}
26
27/// Represents mass, heat, or work flow across the boundary of a control volume.
28#[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    /// Creates a new [`ControlVolume`] from a volume and initial state.
37    ///
38    /// # Errors
39    ///
40    /// Returns a [`ConstraintError`] if `volume` is not strictly positive.
41    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    /// Creates a new [`ControlVolume`] from a pre-validated positive volume and state.
47    pub fn from_constrained(
48        volume: Constrained<Volume, StrictlyPositive>,
49        state: State<Fluid>,
50    ) -> Self {
51        Self { volume, state }
52    }
53
54    /// Returns the net mass flow rate into the control volume.
55    ///
56    /// Inflow contributions are positive.
57    /// Outflow contributions are negative.
58    ///
59    /// Only [`BoundaryFlow::Mass`] entries affect the result.
60    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    /// Returns the net energy flow rate into the control volume.
74    ///
75    /// Inflow contributions are positive.
76    /// Outflow contributions are negative.
77    ///
78    /// # Parameters
79    ///
80    /// - `flows`: Iterator over boundary flows.
81    /// - `model`: Model used to compute thermodynamic properties.
82    ///
83    /// # Errors
84    ///
85    /// Returns a [`PropertyError`] if any required enthalpy cannot be computed.
86    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    /// Returns the time derivative of the control volume's internal state.
115    ///
116    /// The model applies transient mass and energy balances to a fixed-volume,
117    /// well-mixed control volume with negligible kinetic and potential energy changes.
118    ///
119    /// # Mass and Energy Balances
120    ///
121    /// Conservation of mass and energy yield the following, using a
122    /// positive-into-the-system sign convention for both heat and work:
123    ///
124    /// ```text
125    /// dM/dt = V · dρ/dt = ∑ṁ_in − ∑ṁ_out
126    ///
127    /// dU/dt = Q̇_net + Ẇ_net + ∑(ṁ_in · h_in) − ∑(ṁ_out · h_out)
128    /// ```
129    ///
130    /// The total time derivative of internal energy in the fixed volume is:
131    ///
132    /// ```text
133    /// dU/dt = V · (ρ · du/dt + u · dρ/dt)
134    ///       = V · (ρ · cv · dT/dt + u · dρ/dt)
135    /// ```
136    ///
137    /// Substituting and solving for `dρ/dt` and `dT/dt`:
138    ///
139    /// ```text
140    /// dρ/dt = (∑ṁ_in − ∑ṁ_out) / V
141    ///
142    /// dT/dt = (Q̇_net + Ẇ_net + ∑(ṁ_in · h_in) − ∑(ṁ_out · h_out) − u · V · dρ/dt)
143    ///         / (ρ · V · cv)
144    /// ```
145    ///
146    /// Where:
147    ///
148    /// - `dρ/dt` = rate of change of fluid density (kg/m³·s)
149    /// - `dT/dt` = rate of change of fluid temperature (K/s)
150    /// - `dU/dt` = rate of change of total internal energy in the volume (W)
151    /// - `ṁ_in`  = mass inflow rate (kg/s)
152    /// - `ṁ_out` = mass outflow rate (kg/s)
153    /// - `h_in`  = specific enthalpy of the inflow (J/kg)
154    /// - `h_out` = specific enthalpy of the outflow (J/kg)
155    /// - `Q̇_net` = net heat transfer rate into the system (W)
156    /// - `Ẇ_net` = net work rate into the system (W)
157    /// - `u`     = specific internal energy of the fluid (J/kg)
158    /// - `ρ`     = fluid density (kg/m³)
159    /// - `cv`    = specific heat at constant volume (J/kg·K)
160    /// - `V`     = volume of the control region (m³)
161    ///
162    /// Note that SI units are shown for clarity.
163    /// All computations use unit-safe types via the [`uom`] system,
164    /// which enforces dimensional consistency at compile time.
165    ///
166    /// # Parameters
167    ///
168    /// - `flows`: Boundary flows affecting the control volume.
169    /// - `model`: Model used to compute thermodynamic properties.
170    ///
171    /// # Errors
172    ///
173    /// Returns a [`PropertyError`] if any required property cannot be computed.
174    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        // Mass balance:
287        //   dρ/dt = (ṁ_in − ṁ_out) / V = 0
288        assert_relative_eq!(derivative.density.value, 0.0);
289
290        // Energy balance:
291        //   Q̇_net = ṁ · cp · (T_in − T) = 0.3 · 1000 · 50 = 15,000
292        //   C = ρ · V · cv = 1 · 2 · 600 = 1200
293        //   dT/dt = Q̇_net / C = 15000 / 1200 = 12.5
294        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        // Mass balance:
317        //   dρ/dt = −ṁ / V = −0.2 / 2 = −0.1
318        assert_relative_eq!(derivative.density.value, -0.1);
319
320        // Energy balance for ideal gas adiabatic blowdown:
321        //   dT/dt = −ṁ · R · T / (cv · ρ · V)
322        //         = −0.2 · 400 · 300 / (600 · 1 · 2) = −20
323        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        // dρ/dt = 0 (no mass flow)
349        assert_relative_eq!(derivative.density.value, 0.0);
350
351        // dT/dt = Q̇ / (ρ · V · cv) = 600 / (2 · 1 · 600) = 0.5
352        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        // dρ/dt = 0 (no mass flow)
379        assert_relative_eq!(derivative.density.value, 0.0);
380
381        // Energy storage capacity:
382        //   C = ρ · V · cv = 1.2 · 3 · 600 = 2,160
383        //
384        // Net power into CV:
385        //   Q̇_net = 60 − 5,460 = −5,400
386        //
387        // Temperature rate:
388        //   dT/dt = Q̇_net / C = −5,400 / 2,160 = −2.5
389        assert_relative_eq!(derivative.temperature.value, -2.5);
390    }
391}