Skip to main content

qualia_core_db/domains/physical/
thermodynamics.rs

1//! Thermodynamics & Statistical Ensembles
2//! Implements pure Rust Markov Chain Monte Carlo (MCMC) sampling for macroscopic properties.
3
4/// State of a thermodynamic ensemble
5#[derive(Clone)]
6pub struct EnsembleState {
7    pub temperature: f64,
8    pub particles: usize,
9    pub total_energy: f64,
10}
11
12/// Computes thermodynamic macroscopic properties from discrete structures via MCMC.
13pub struct ThermodynamicSampler {
14    pub current_state: EnsembleState,
15}
16
17impl ThermodynamicSampler {
18    pub fn new(initial_temp: f64, particles: usize) -> Self {
19        Self {
20            current_state: EnsembleState {
21                temperature: initial_temp,
22                particles,
23                total_energy: 0.0,
24            },
25        }
26    }
27
28    /// Performs a Metropolis-Hastings MCMC step
29    pub fn metropolis_step(&mut self, proposed_energy: f64, random_uniform: f64) -> bool {
30        let delta_e = proposed_energy - self.current_state.total_energy;
31
32        // Accept if energy decreases or strictly probabilistically according to Boltzmann distribution
33        let k_b = 8.617333262145e-5; // Boltzmann constant in eV/K
34        let beta = 1.0 / (k_b * self.current_state.temperature);
35
36        let acceptance_probability = if delta_e < 0.0 {
37            1.0
38        } else {
39            (-beta * delta_e).exp()
40        };
41
42        if random_uniform < acceptance_probability {
43            self.current_state.total_energy = proposed_energy;
44            true // Accepted
45        } else {
46            false // Rejected
47        }
48    }
49
50    /// Calculates macroscopic Gibbs Free Energy approximation
51    pub fn calculate_gibbs_free_energy(&self, enthalpy: f64, entropy: f64) -> f64 {
52        // G = H - TS
53        enthalpy - (self.current_state.temperature * entropy)
54    }
55}
56
57// ─── Off-grid energy: battery / solar / heat-transfer models ─────────────────────
58//
59// The resilience-energy scope: rigorous thermal/electrical state for off-grid energy
60// storage (lithium banks under fluctuating loads), dynamic solar harvesting (MPP
61// tracking across mixed arrays), and heat-transfer efficiency in constrained/mobile
62// infrastructure. All zero-heap (pure-scalar equivalent-circuit / I-V / U·A·ΔT models).
63
64/// An off-grid lithium pack as an `S`×`P` array of prismatic cells, modelled as a
65/// first-order equivalent circuit (OCV − I·R sag). Captures the "down to the internal
66/// 4-prismatic-cell architecture" requirement: terminal voltage and deliverable power
67/// depend on the series/parallel topology, per-cell internal resistance, and SoC under
68/// a fluctuating load.
69#[derive(Debug, Clone, Copy)]
70pub struct LithiumPack {
71    pub cells_series: u32,
72    pub cells_parallel: u32,
73    pub cell_internal_resistance_ohm: f64,
74    pub cell_capacity_ah: f64,
75}
76
77impl LithiumPack {
78    /// Open-circuit voltage of ONE cell at a state-of-charge (0..1): a linearised
79    /// Li-ion curve from ~3.0 V (empty) to ~4.2 V (full).
80    pub fn cell_ocv(&self, soc: f64) -> f64 {
81        3.0 + (4.2 - 3.0) * soc.clamp(0.0, 1.0)
82    }
83
84    /// Pack open-circuit voltage = series count × cell OCV.
85    pub fn pack_ocv(&self, soc: f64) -> f64 {
86        self.cells_series as f64 * self.cell_ocv(soc)
87    }
88
89    /// Pack internal resistance = R_cell × S / P (series adds, parallel divides).
90    pub fn pack_resistance(&self) -> f64 {
91        self.cell_internal_resistance_ohm * self.cells_series as f64
92            / self.cells_parallel.max(1) as f64
93    }
94
95    /// Terminal voltage under a load current (A): OCV minus the internal-resistance sag.
96    pub fn terminal_voltage(&self, soc: f64, load_current_a: f64) -> f64 {
97        self.pack_ocv(soc) - load_current_a * self.pack_resistance()
98    }
99
100    /// Power (W) actually delivered to the load at a given SoC and current.
101    pub fn deliverable_power(&self, soc: f64, load_current_a: f64) -> f64 {
102        self.terminal_voltage(soc, load_current_a).max(0.0) * load_current_a
103    }
104
105    /// Total pack capacity (Ah) = per-cell capacity × parallel count.
106    pub fn pack_capacity_ah(&self) -> f64 {
107        self.cell_capacity_ah * self.cells_parallel as f64
108    }
109}
110
111/// A solar panel on a simplified single-knee I-V curve (`I_sc` scales with irradiance;
112/// `fill_factor` sets the knee sharpness ∝ panel/impedance quality).
113#[derive(Debug, Clone, Copy)]
114pub struct SolarPanel {
115    pub short_circuit_current_a: f64,
116    pub open_circuit_voltage_v: f64,
117    pub fill_factor: f64,
118}
119
120impl SolarPanel {
121    /// Current (A) at an operating voltage on the I-V curve `I = I_sc·(1 − (V/V_oc)^p)`,
122    /// with `p` derived from the fill factor (higher FF ⇒ squarer knee).
123    pub fn current_at(&self, v: f64) -> f64 {
124        if v <= 0.0 {
125            return self.short_circuit_current_a;
126        }
127        if v >= self.open_circuit_voltage_v {
128            return 0.0;
129        }
130        let p = (1.0 / (1.0 - self.fill_factor.clamp(0.05, 0.95))).max(1.0);
131        self.short_circuit_current_a * (1.0 - (v / self.open_circuit_voltage_v).powf(p))
132    }
133
134    /// Maximum power point: scan the I-V curve for the voltage maximising `P = V·I`.
135    /// Returns `(v_mp, i_mp, p_mp)` — the dynamic-harvesting operating point an MPPT
136    /// controller would seek as irradiance/impedance shift.
137    pub fn max_power_point(&self, scan_steps: u32) -> (f64, f64, f64) {
138        let mut best = (0.0f64, 0.0f64, 0.0f64);
139        let n = scan_steps.max(2);
140        for k in 1..n {
141            let v = self.open_circuit_voltage_v * k as f64 / n as f64;
142            let i = self.current_at(v);
143            let p = v * i;
144            if p > best.2 {
145                best = (v, i, p);
146            }
147        }
148        best
149    }
150}
151
152/// Total MPP power (W) harvested from a mixed-topology array — the sum of each panel's
153/// independently-tracked maximum power point. Zero-heap (slice in, scalar out).
154pub fn array_mppt_power(panels: &[SolarPanel], scan_steps: u32) -> f64 {
155    panels.iter().map(|p| p.max_power_point(scan_steps).2).sum()
156}
157
158/// Conductive/convective heat-loss rate (W) through an envelope: `Q = U·A·ΔT`.
159pub fn heat_loss_rate(u_value_w_m2k: f64, area_m2: f64, delta_t_k: f64) -> f64 {
160    u_value_w_m2k * area_m2 * delta_t_k
161}
162
163/// Latent heat (J) of a phase change for `mass_kg` at specific latent heat
164/// `latent_heat_j_kg` — the multi-phase (boiling/condensing/melting) energy term that
165/// sits alongside the sensible `Q = U·A·ΔT` loss.
166pub fn phase_change_energy(mass_kg: f64, latent_heat_j_kg: f64) -> f64 {
167    mass_kg * latent_heat_j_kg
168}
169
170/// Thermal efficiency of a heating/cooling system delivering `useful_power_w` against
171/// an envelope loss `U·A·ΔT`: `η = useful / (useful + loss)`, in `0..1`. Models the
172/// efficiency drop-off in constrained / mobile / pop-up infrastructure (thin envelope
173/// ⇒ high U ⇒ low η).
174pub fn thermal_efficiency(
175    useful_power_w: f64,
176    u_value_w_m2k: f64,
177    area_m2: f64,
178    delta_t_k: f64,
179) -> f64 {
180    let loss = heat_loss_rate(u_value_w_m2k, area_m2, delta_t_k).abs();
181    let useful = useful_power_w.max(0.0);
182    if useful + loss <= 0.0 {
183        return 0.0;
184    }
185    useful / (useful + loss)
186}
187
188#[cfg(test)]
189mod offgrid_energy_tests {
190    use super::*;
191
192    #[test]
193    fn lithium_pack_sags_under_load() {
194        // 4S2P pack, 5 mΩ per cell, 100 Ah cells.
195        let pack = LithiumPack {
196            cells_series: 4,
197            cells_parallel: 2,
198            cell_internal_resistance_ohm: 0.005,
199            cell_capacity_ah: 100.0,
200        };
201        assert!((pack.pack_ocv(1.0) - 16.8).abs() < 1e-9); // 4 × 4.2
202        assert!((pack.pack_capacity_ah() - 200.0).abs() < 1e-9); // 100 × 2
203        let rest = pack.terminal_voltage(0.5, 0.0);
204        let loaded = pack.terminal_voltage(0.5, 50.0);
205        assert!(loaded < rest, "terminal voltage must sag under load");
206        // sag = I·R_pack = 50 × (0.005×4/2) = 0.5 V
207        assert!((rest - loaded - 0.5).abs() < 1e-9);
208        assert!(pack.deliverable_power(0.5, 50.0) > 0.0);
209    }
210
211    #[test]
212    fn solar_mpp_is_between_zero_and_isc_voc() {
213        let panel = SolarPanel {
214            short_circuit_current_a: 8.0,
215            open_circuit_voltage_v: 40.0,
216            fill_factor: 0.75,
217        };
218        let (v_mp, i_mp, p_mp) = panel.max_power_point(256);
219        assert!(p_mp > 0.0);
220        assert!(v_mp > 0.0 && v_mp < panel.open_circuit_voltage_v);
221        assert!(i_mp > 0.0 && i_mp < panel.short_circuit_current_a);
222        // MPP power < the I_sc·V_oc rectangle (fill factor < 1).
223        assert!(p_mp < panel.short_circuit_current_a * panel.open_circuit_voltage_v);
224        // An array of two identical panels harvests ~2× one panel.
225        let arr = array_mppt_power(&[panel, panel], 256);
226        assert!((arr - 2.0 * p_mp).abs() < 1e-6);
227    }
228
229    #[test]
230    fn thermal_efficiency_drops_with_worse_insulation() {
231        // Same delivered heat, thicker vs thinner envelope (lower vs higher U).
232        let good = thermal_efficiency(1000.0, 0.5, 10.0, 20.0); // U=0.5 → loss 100 W
233        let bad = thermal_efficiency(1000.0, 3.0, 10.0, 20.0); // U=3.0 → loss 600 W
234        assert!(
235            good > bad,
236            "lower U (better insulation) ⇒ higher efficiency"
237        );
238        assert!(good > 0.0 && good < 1.0 && bad > 0.0 && bad < 1.0);
239        // Multi-phase latent term is additive and sane.
240        assert!((phase_change_energy(2.0, 334_000.0) - 668_000.0).abs() < 1.0); // ice→water
241    }
242}