Skip to main content

alice_physics/
phase_change.rs

1//! Phase Change Simulation Modifier
2//!
3//! Temperature-driven phase transitions between solid, liquid, and gas:
4//! - **Melting** (solid → liquid): Surface softens, material flows downward
5//! - **Vaporization** (liquid → gas): Material expands, then dissipates
6//! - **Solidification** (liquid → solid): Material grows rigid, surface freezes
7//! - **Condensation** (gas → liquid): Gas contracts back to liquid
8//!
9//! # Physics Model
10//!
11//! Each cell tracks: temperature, phase state, and a latent-heat buffer.
12//! Transitions use the enthalpy method (Voller & Cross 1981; Carslaw &
13// LIMITATION(COV-THERM-069): with the heat capacity normalised to 1 the latent heats are in kelvin-equivalent units
14//! Jaeger ch. XI for the Stefan problem): with the heat capacity normalised
15//! to 1 the latent heats are in kelvin-equivalent units, and each `update`
16//! moves temperature above a transition point into the buffer (or releases
17//! it back on cooling) until the buffer reaches the latent heat. The cell
18//! therefore sits **on the plateau** `T = T_m` while it melts and
19//! `T + latent` is conserved exactly by the transition step; the melt time
20//! is set by how fast diffusion / applied heat feed the cell, not by the
21//! step size. Phase transitions modify the SDF: liquid flows downward
22//! (mass-conserving transfer), gas expands outward.
23//!
24//! Author: Moroya Sakamoto
25
26use crate::coupled_field::{CoupledField, CoupledFieldError, CoupledScalar};
27use crate::math::Fix128;
28use crate::sim_field::ScalarField3D;
29use crate::sim_modifier::PhysicsModifier;
30use crate::sim_modifier::{observe_max, observe_sum, StateReader, StateWriter};
31use crate::world_participant::{
32    ObservationSink, Participant, ParticipantFault, ParticipantKind, StateError, SubstepCtx,
33};
34
35#[cfg(not(feature = "std"))]
36use alloc::vec::Vec;
37
38// ============================================================================
39// Phase State
40// ============================================================================
41
42/// Phase of matter
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44#[repr(u8)]
45pub enum Phase {
46    /// Rigid — no SDF modification
47    Solid = 0,
48    /// Flows downward under gravity
49    Liquid = 1,
50    /// Expands outward, dissipates over time
51    Gas = 2,
52}
53
54// ============================================================================
55// Configuration
56// ============================================================================
57
58/// Phase change modifier configuration
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub struct PhaseChangeConfig {
61    /// Temperature at which solid melts to liquid
62    pub melt_temperature: f32,
63    /// Temperature at which liquid vaporizes to gas
64    pub boil_temperature: f32,
65    /// Latent heat of fusion in kelvin-equivalent units (`L_f / c_p`):
66    /// the temperature rise a cell must supply, and hold at
67    /// `melt_temperature`, before it turns liquid.
68    pub latent_heat_fusion: f32,
69    /// Latent heat of vaporization in kelvin-equivalent units (`L_v / c_p`).
70    pub latent_heat_vaporization: f32,
71    /// Thermal diffusion rate
72    pub diffusion_rate: f32,
73    /// Ambient temperature
74    pub ambient_temperature: f32,
75    /// Cooling rate toward ambient
76    pub cooling_rate: f32,
77    /// Liquid flow speed (gravity-driven downward): fraction of a liquid
78    /// cell's offset moved to the cell below per second, and (× 0.1) the
79    /// rate at which liquid softens the surface (offset per second).
80    pub liquid_flow_speed: f32,
81    /// Gas expansion rate (SDF offset per second)
82    pub gas_expansion_rate: f32,
83    /// Gas dissipation rate (material removal per second)
84    pub gas_dissipation_rate: f32,
85    /// Maximum SDF offset from phase changes
86    pub max_offset: f32,
87}
88
89impl Default for PhaseChangeConfig {
90    fn default() -> Self {
91        Self {
92            melt_temperature: 200.0,
93            boil_temperature: 500.0,
94            latent_heat_fusion: 50.0,
95            latent_heat_vaporization: 100.0,
96            diffusion_rate: 0.5,
97            ambient_temperature: 20.0,
98            cooling_rate: 0.1,
99            liquid_flow_speed: 1.0,
100            gas_expansion_rate: 0.5,
101            gas_dissipation_rate: 0.3,
102            max_offset: 3.0,
103        }
104    }
105}
106
107// ============================================================================
108// Phase Change Modifier
109// ============================================================================
110
111/// Phase-change SDF modifier with solid/liquid/gas transitions
112pub struct PhaseChangeModifier {
113    /// Configuration
114    pub config: PhaseChangeConfig,
115    /// Temperature field
116    pub temperature: ScalarField3D,
117    /// Phase state per cell (stored as f32: 0=Solid, 1=Liquid, 2=Gas)
118    pub phase: ScalarField3D,
119    /// Latent heat buffer (energy absorbed/released during phase transition)
120    pub latent_heat: ScalarField3D,
121    /// Accumulated SDF offset (positive = material removed)
122    pub sdf_offset: ScalarField3D,
123    /// Whether modifier is enabled
124    pub enabled: bool,
125}
126
127impl PhaseChangeModifier {
128    /// Create a new phase change modifier
129    #[must_use]
130    pub fn new(
131        config: PhaseChangeConfig,
132        resolution: usize,
133        min: (f32, f32, f32),
134        max: (f32, f32, f32),
135    ) -> Self {
136        Self {
137            config,
138            temperature: ScalarField3D::new_filled(
139                resolution,
140                resolution,
141                resolution,
142                min,
143                max,
144                config.ambient_temperature,
145            ),
146            phase: ScalarField3D::new(resolution, resolution, resolution, min, max),
147            latent_heat: ScalarField3D::new(resolution, resolution, resolution, min, max),
148            sdf_offset: ScalarField3D::new(resolution, resolution, resolution, min, max),
149            enabled: true,
150        }
151    }
152
153    /// Apply heat at a position
154    pub fn apply_heat_at(&mut self, x: f32, y: f32, z: f32, amount: f32, radius: f32) {
155        self.temperature.splat(x, y, z, amount, radius);
156    }
157
158    /// Get temperature at a point
159    #[must_use]
160    pub fn temperature_at(&self, x: f32, y: f32, z: f32) -> f32 {
161        self.temperature.sample(x, y, z)
162    }
163
164    /// Get phase at a point
165    #[must_use]
166    pub fn phase_at(&self, x: f32, y: f32, z: f32) -> Phase {
167        let v = self.phase.sample(x, y, z);
168        if v < 0.5 {
169            Phase::Solid
170        } else if v < 1.5 {
171            Phase::Liquid
172        } else {
173            Phase::Gas
174        }
175    }
176
177    /// Process phase transitions (enthalpy method).
178    ///
179    /// The latent buffer holds the latent enthalpy stored in the cell, in
180    /// kelvin-equivalent units: `0..L_f` while a solid melts,
181    /// `L_f..L_f + L_v` while a liquid boils, `L_f + L_v` for gas. Heat above
182    /// a transition temperature is moved from `temperature` into the buffer
183    /// (melting / boiling) and back out of it on cooling (freezing /
184    /// condensing), so `temperature + latent_heat` is conserved cell by cell
185    /// and the cell sits on the plateau until the buffer fills or empties.
186    /// Independent of `dt`: the step only redistributes enthalpy already
187    /// present in the cell.
188    fn process_transitions(&mut self) {
189        let melt_t = self.config.melt_temperature;
190        let boil_t = self.config.boil_temperature;
191        let lh_fusion = self.config.latent_heat_fusion.max(0.0);
192        let lh_vaporization = self.config.latent_heat_vaporization.max(0.0);
193        let lh_total = lh_fusion + lh_vaporization;
194
195        let n = self.temperature.cell_count();
196        for i in 0..n {
197            let mut temp = self.temperature.data[i];
198            let mut phase_val = self.phase.data[i];
199            let mut lh = self.latent_heat.data[i];
200
201            // Solid → Liquid: superheat above T_m fills the fusion buffer
202            if phase_val < 0.5 && temp > melt_t {
203                let absorbed = (temp - melt_t).min(lh_fusion - lh).max(0.0);
204                temp -= absorbed;
205                lh += absorbed;
206                if lh >= lh_fusion {
207                    phase_val = 1.0;
208                    lh = lh_fusion;
209                }
210            }
211
212            // Liquid → Gas: superheat above T_b fills the vaporization buffer
213            if (0.5..1.5).contains(&phase_val) && temp > boil_t {
214                let absorbed = (temp - boil_t).min(lh_total - lh).max(0.0);
215                temp -= absorbed;
216                lh += absorbed;
217                if lh >= lh_total {
218                    phase_val = 2.0;
219                    lh = lh_total;
220                }
221            }
222
223            // Gas → Liquid: subcooling below T_b drains the vaporization buffer
224            if phase_val >= 1.5 && temp < boil_t && lh > lh_fusion {
225                let released = (boil_t - temp).min(lh - lh_fusion);
226                temp += released;
227                lh -= released;
228                if lh <= lh_fusion {
229                    phase_val = 1.0;
230                    lh = lh_fusion;
231                }
232            }
233
234            // Liquid: a partly-boiled cell that cools first gives back the
235            // vaporization progress, then subcooling below T_m drains the
236            // fusion buffer until the cell freezes
237            if (0.5..1.5).contains(&phase_val) {
238                if temp < boil_t && lh > lh_fusion {
239                    let released = (boil_t - temp).min(lh - lh_fusion);
240                    temp += released;
241                    lh -= released;
242                }
243                if temp < melt_t && lh > 0.0 {
244                    let released = (melt_t - temp).min(lh);
245                    temp += released;
246                    lh -= released;
247                }
248                if temp < melt_t && lh <= 0.0 {
249                    phase_val = 0.0;
250                    lh = 0.0;
251                }
252            }
253
254            // Solid: a partly-melted cell that cools gives its progress back
255            if phase_val < 0.5 && temp < melt_t && lh > 0.0 {
256                let released = (melt_t - temp).min(lh);
257                temp += released;
258                lh -= released;
259            }
260
261            self.temperature.data[i] = temp;
262            self.phase.data[i] = phase_val;
263            self.latent_heat.data[i] = lh;
264        }
265    }
266
267    /// Accumulate SDF offsets based on phase state
268    fn accumulate_offsets(&mut self, dt: f32) {
269        let liquid_flow = self.config.liquid_flow_speed;
270        let gas_expand = self.config.gas_expansion_rate;
271        let gas_dissipate = self.config.gas_dissipation_rate;
272        let max_offset = self.config.max_offset;
273
274        let n = self.sdf_offset.cell_count();
275        for i in 0..n {
276            let phase_val = self.phase.data[i];
277
278            if phase_val >= 1.5 {
279                // Gas: expand and dissipate (increase SDF = material removed)
280                self.sdf_offset.data[i] = (gas_expand + gas_dissipate)
281                    .mul_add(dt, self.sdf_offset.data[i])
282                    .min(max_offset);
283            } else if phase_val >= 0.5 {
284                // Liquid: slight material softening
285                self.sdf_offset.data[i] = (liquid_flow * 0.1)
286                    .mul_add(dt, self.sdf_offset.data[i])
287                    .min(max_offset);
288            }
289            // Solid: no offset change
290        }
291
292        // Liquid gravity flow: move a fraction `liquid_flow · dt` (≤ 1) of
293        // each liquid cell's offset to the cell below. The amount removed
294        // from the source equals the amount the destination accepts (its
295        // `max_offset` head-room), so the column total is conserved.
296        if liquid_flow > 0.0 {
297            let fraction = (liquid_flow * dt).min(1.0);
298            let nx = self.sdf_offset.nx;
299            let ny = self.sdf_offset.ny;
300            let nz = self.sdf_offset.nz;
301            for iz in 0..nz {
302                for ix in 0..nx {
303                    for iy in 1..ny {
304                        let idx = self.phase.index(ix, iy, iz);
305                        let below_idx = self.phase.index(ix, iy - 1, iz);
306                        if !(0.5..1.5).contains(&self.phase.data[idx]) {
307                            continue;
308                        }
309                        let source = self.sdf_offset.data[idx];
310                        if source <= 0.0 {
311                            continue;
312                        }
313                        let room = (max_offset - self.sdf_offset.data[below_idx]).max(0.0);
314                        let transfer = (source * fraction).min(room);
315                        if transfer > 0.0 {
316                            self.sdf_offset.data[below_idx] += transfer;
317                            self.sdf_offset.data[idx] = source - transfer;
318                        }
319                    }
320                }
321            }
322        }
323    }
324}
325
326impl PhysicsModifier for PhaseChangeModifier {
327    #[inline]
328    fn modify_distance(&self, x: f32, y: f32, z: f32, original_dist: f32) -> f32 {
329        if !self.enabled {
330            return original_dist;
331        }
332
333        let offset = self.sdf_offset.sample(x, y, z);
334        original_dist + offset
335    }
336
337    fn update(&mut self, dt: f32) {
338        if !self.enabled {
339            return;
340        }
341
342        // 1. Diffuse temperature
343        self.temperature.diffuse(dt, self.config.diffusion_rate);
344
345        // 2. Cool toward ambient
346        self.temperature.decay_toward(
347            self.config.ambient_temperature,
348            self.config.cooling_rate,
349            dt,
350        );
351
352        // 3. Process phase transitions (enthalpy redistribution, dt-free)
353        self.process_transitions();
354
355        // 4. Accumulate SDF offsets
356        self.accumulate_offsets(dt);
357    }
358
359    fn name(&self) -> &'static str {
360        "phase_change"
361    }
362
363    fn is_active(&self) -> bool {
364        self.enabled
365    }
366}
367
368// ============================================================================
369// Coupling channel
370// ============================================================================
371
372/// The temperature field is shared through a deterministic `Fix128` channel.
373///
374/// The counterpart of the impl on [`crate::thermal::ThermalModifier`]: the two
375/// modifiers hold independent copies of the same physical quantity, and
376/// [`crate::coupled_field::reconcile_mean`] is what makes a point have one
377/// temperature instead of two.
378impl CoupledScalar for PhaseChangeModifier {
379    fn coupled_name(&self) -> &'static str {
380        "temperature"
381    }
382
383    fn coupled_channel(&self) -> Result<CoupledField, CoupledFieldError> {
384        CoupledField::try_matching(&self.temperature)
385    }
386
387    fn publish(&self, out: &mut CoupledField) -> Result<(), CoupledFieldError> {
388        out.copy_from_f32(&self.temperature)
389    }
390
391    fn adopt(&mut self, src: &CoupledField) -> Result<(), CoupledFieldError> {
392        src.write_to_f32(&mut self.temperature)
393    }
394
395    fn check_adopt(&self, src: &CoupledField) -> Result<(), CoupledFieldError> {
396        src.require_same_grid_f32(&self.temperature)
397    }
398}
399
400// ============================================================================
401// World participant
402// ============================================================================
403
404impl PhaseChangeModifier {
405    /// Snapshot tag of this type as a world participant: `"PHAS"`, the four
406    /// ASCII bytes read big endian. Never changes.
407    pub const PARTICIPANT_KIND: ParticipantKind =
408        ParticipantKind::new(u32::from_be_bytes(*b"PHAS"));
409
410    fn decode_state(bytes: &[u8]) -> Result<Self, StateError> {
411        let mut r = StateReader::new(bytes)?;
412        let config = PhaseChangeConfig {
413            melt_temperature: r.f32()?,
414            boil_temperature: r.f32()?,
415            latent_heat_fusion: r.f32()?,
416            latent_heat_vaporization: r.f32()?,
417            diffusion_rate: r.f32()?,
418            ambient_temperature: r.f32()?,
419            cooling_rate: r.f32()?,
420            liquid_flow_speed: r.f32()?,
421            gas_expansion_rate: r.f32()?,
422            gas_dissipation_rate: r.f32()?,
423            max_offset: r.f32()?,
424        };
425        let enabled = r.bool()?;
426        let temperature = r.field()?;
427        let phase = r.field()?;
428        let latent_heat = r.field()?;
429        let sdf_offset = r.field()?;
430        r.finish()?;
431        Ok(Self {
432            config,
433            temperature,
434            phase,
435            latent_heat,
436            sdf_offset,
437            enabled,
438        })
439    }
440}
441
442/// One `update(h)` per world substep (`h` converted with
443/// [`Fix128::to_f32`]); see the module documentation of
444/// [`crate::sim_modifier`] for the payload and what is not coupled yet.
445///
446/// Observations: channel 0 the highest cell temperature (absent for a grid
447/// without cells), channel 1 the number of cells that are not solid
448/// (stored phase ≥ 0.5), channel 2 the total SDF offset.
449impl Participant for PhaseChangeModifier {
450    fn kind(&self) -> ParticipantKind {
451        Self::PARTICIPANT_KIND
452    }
453
454    fn substep(&mut self, _ctx: &mut SubstepCtx<'_>, h: Fix128) -> Result<(), ParticipantFault> {
455        PhysicsModifier::update(self, h.to_f32());
456        Ok(())
457    }
458
459    fn observe(&self, out: &mut ObservationSink) {
460        observe_max(out, 0, &self.temperature);
461        let fluid = self.phase.data.iter().filter(|&&p| p >= 0.5).count();
462        out.push(1, Fix128::from_int(fluid as i64));
463        observe_sum(out, 2, &self.sdf_offset);
464    }
465
466    fn write_state(&self, out: &mut Vec<u8>) {
467        let mut w = StateWriter::new(out);
468        let c = &self.config;
469        for v in [
470            c.melt_temperature,
471            c.boil_temperature,
472            c.latent_heat_fusion,
473            c.latent_heat_vaporization,
474            c.diffusion_rate,
475            c.ambient_temperature,
476            c.cooling_rate,
477            c.liquid_flow_speed,
478            c.gas_expansion_rate,
479            c.gas_dissipation_rate,
480            c.max_offset,
481        ] {
482            w.f32(v);
483        }
484        w.bool(self.enabled);
485        w.field(&self.temperature);
486        w.field(&self.phase);
487        w.field(&self.latent_heat);
488        w.field(&self.sdf_offset);
489    }
490
491    fn check_state(&self, bytes: &[u8]) -> Result<(), StateError> {
492        Self::decode_state(bytes).map(drop)
493    }
494
495    fn read_state(&mut self, bytes: &[u8]) {
496        match Self::decode_state(bytes) {
497            Ok(state) => *self = state,
498            Err(e) => panic!("read_state called with a payload check_state refuses: {e:?}"),
499        }
500    }
501}
502
503// ============================================================================
504// Tests
505// ============================================================================
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_phase_change_no_heat() {
513        let config = PhaseChangeConfig::default();
514        let modifier = PhaseChangeModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
515
516        // No heat: everything is solid, no offset
517        let d = modifier.modify_distance(0.0, 0.0, 0.0, -1.0);
518        assert!(
519            (d - (-1.0)).abs() < 0.01,
520            "No heat should not change SDF, got {d}"
521        );
522        assert_eq!(modifier.phase_at(0.0, 0.0, 0.0), Phase::Solid);
523    }
524
525    #[test]
526    fn test_melting_transition() {
527        let config = PhaseChangeConfig {
528            melt_temperature: 100.0,
529            latent_heat_fusion: 10.0,
530            ambient_temperature: 20.0,
531            cooling_rate: 0.0,   // No cooling
532            diffusion_rate: 0.0, // No diffusion (prevent re-solidification)
533            ..Default::default()
534        };
535        let mut modifier = PhaseChangeModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
536
537        // Apply heat well above melt temperature (large radius for low-res grid)
538        modifier.apply_heat_at(0.0, 0.0, 0.0, 2000.0, 1.5);
539
540        // Simulate enough steps for transition
541        for _ in 0..100 {
542            modifier.update(0.016);
543        }
544
545        let phase = modifier.phase_at(0.0, 0.0, 0.0);
546        assert!(
547            phase == Phase::Liquid || phase == Phase::Gas,
548            "High heat should melt material, got {phase:?}"
549        );
550    }
551
552    #[test]
553    fn test_vaporization_transition() {
554        let config = PhaseChangeConfig {
555            melt_temperature: 50.0,
556            boil_temperature: 100.0,
557            latent_heat_fusion: 1.0,
558            latent_heat_vaporization: 1.0,
559            ambient_temperature: 20.0,
560            cooling_rate: 0.0,
561            diffusion_rate: 0.0,
562            ..Default::default()
563        };
564        let mut modifier = PhaseChangeModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
565
566        // Apply extreme heat (large radius for low-res grid)
567        modifier.apply_heat_at(0.0, 0.0, 0.0, 5000.0, 1.5);
568
569        for _ in 0..200 {
570            modifier.update(0.016);
571        }
572
573        let phase = modifier.phase_at(0.0, 0.0, 0.0);
574        assert_eq!(
575            phase,
576            Phase::Gas,
577            "Extreme heat should vaporize, got {phase:?}"
578        );
579
580        // Gas should have increased SDF offset
581        let offset = modifier.sdf_offset.sample(0.0, 0.0, 0.0);
582        assert!(offset > 0.0, "Gas should add SDF offset, got {offset}");
583    }
584
585    #[test]
586    fn test_solidification() {
587        let config = PhaseChangeConfig {
588            melt_temperature: 50.0,
589            latent_heat_fusion: 1.0,
590            ambient_temperature: 20.0,
591            cooling_rate: 0.0,
592            diffusion_rate: 0.0,
593            ..Default::default()
594        };
595        let mut modifier = PhaseChangeModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
596
597        // Heat to melt (large radius for low-res grid)
598        modifier.apply_heat_at(0.0, 0.0, 0.0, 2000.0, 1.5);
599        for _ in 0..50 {
600            modifier.update(0.016);
601        }
602
603        // Should be liquid by now
604        let phase_hot = modifier.phase_at(0.0, 0.0, 0.0);
605        assert!(
606            phase_hot == Phase::Liquid || phase_hot == Phase::Gas,
607            "Should be liquid/gas after heating, got {phase_hot:?}"
608        );
609
610        // Enable fast cooling and cool down
611        modifier.config.cooling_rate = 5.0;
612        for _ in 0..500 {
613            modifier.update(0.016);
614        }
615
616        let phase_cold = modifier.phase_at(0.0, 0.0, 0.0);
617        assert_eq!(
618            phase_cold,
619            Phase::Solid,
620            "Should solidify after cooling, got {phase_cold:?}"
621        );
622    }
623
624    fn still_config() -> PhaseChangeConfig {
625        PhaseChangeConfig {
626            melt_temperature: 200.0,
627            boil_temperature: 500.0,
628            latent_heat_fusion: 50.0,
629            latent_heat_vaporization: 100.0,
630            diffusion_rate: 0.0,
631            ambient_temperature: 20.0,
632            cooling_rate: 0.0,
633            liquid_flow_speed: 0.0,
634            gas_expansion_rate: 0.5,
635            gas_dissipation_rate: 0.25,
636            max_offset: 3.0,
637        }
638    }
639
640    /// oracle: a gas cell holding the full buffer `L_f + L_v = 150` at 400
641    /// (100 below boiling) condenses: it releases `min(100, 150 − 50) = 100`,
642    /// so it ends liquid at 500 with buffer `L_f = 50`.
643    #[test]
644    fn gas_condenses_to_liquid_closed_form() {
645        let mut m = PhaseChangeModifier::new(still_config(), 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
646        m.phase.data[0] = 2.0;
647        m.latent_heat.data[0] = 150.0;
648        m.temperature.data[0] = 400.0;
649        m.process_transitions();
650        assert_eq!(m.phase.data[0], 1.0);
651        assert_eq!(m.temperature.data[0], 500.0);
652        assert_eq!(m.latent_heat.data[0], 50.0);
653    }
654
655    /// oracle: a gas cell grows its offset by `(expansion + dissipation)·dt =
656    /// 0.75·(1/2) = 3/8` per call and stops at `max_offset`; a solid cell
657    /// gets nothing.
658    #[test]
659    fn gas_offset_grows_and_is_capped() {
660        let mut m = PhaseChangeModifier::new(still_config(), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
661        m.phase.data[0] = 2.0;
662        m.accumulate_offsets(0.5);
663        assert_eq!(m.sdf_offset.data[0], 0.375);
664        assert_eq!(m.sdf_offset.data[1], 0.0);
665        m.accumulate_offsets(100.0);
666        assert_eq!(m.sdf_offset.data[0], 3.0);
667    }
668
669    /// oracle: with every cell liquid, flow speed 1 and `dt = 1/2`, each cell
670    /// first gains `0.1·1·(1/2) = 1/20`; then a fraction `1·(1/2)` of the
671    /// upper cell flows down: lower `1/20 + 1/40 = 3/40`, upper `1/40`. In a
672    /// column whose lower cell is at `max_offset` there is no room, and an
673    /// upper cell with a negative offset has nothing to give.
674    #[test]
675    fn liquid_flows_down_with_room_and_source() {
676        let mut m = PhaseChangeModifier::new(
677            PhaseChangeConfig {
678                liquid_flow_speed: 1.0,
679                ..still_config()
680            },
681            2,
682            (0.0, 0.0, 0.0),
683            (1.0, 1.0, 1.0),
684        );
685        m.phase.data.fill(1.0);
686        let full_below = m.sdf_offset.index(1, 0, 0);
687        let full_above = m.sdf_offset.index(1, 1, 0);
688        m.sdf_offset.data[full_below] = 3.0;
689        let neg_below = m.sdf_offset.index(0, 0, 1);
690        let neg_above = m.sdf_offset.index(0, 1, 1);
691        m.sdf_offset.data[neg_above] = -1.0;
692        m.accumulate_offsets(0.5);
693        let below = m.sdf_offset.index(0, 0, 0);
694        let above = m.sdf_offset.index(0, 1, 0);
695        assert!((m.sdf_offset.data[below] - 0.075).abs() < 1e-6);
696        assert!((m.sdf_offset.data[above] - 0.025).abs() < 1e-6);
697        assert_eq!(m.sdf_offset.data[full_below], 3.0);
698        assert!((m.sdf_offset.data[full_above] - 0.05).abs() < 1e-6);
699        assert!((m.sdf_offset.data[neg_above] + 0.95).abs() < 1e-6);
700        assert!((m.sdf_offset.data[neg_below] - 0.05).abs() < 1e-6);
701    }
702
703    /// oracle: a disabled modifier leaves the distance and its fields as
704    /// they are.
705    #[test]
706    fn disabled_phase_change_is_inert() {
707        let mut m = PhaseChangeModifier::new(still_config(), 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
708        m.sdf_offset.data[0] = 0.5;
709        m.temperature.data[0] = 900.0;
710        m.enabled = false;
711        assert!(!m.is_active());
712        assert_eq!(m.modify_distance(0.5, 0.5, 0.5, 1.0), 1.0);
713        m.update(1.0);
714        assert_eq!(m.temperature.data[0], 900.0);
715        assert_eq!(m.phase.data[0], 0.0);
716    }
717
718    fn sample_modifier() -> PhaseChangeModifier {
719        let mut m = PhaseChangeModifier::new(
720            PhaseChangeConfig {
721                melt_temperature: 150.0,
722                boil_temperature: 450.0,
723                latent_heat_fusion: 40.0,
724                latent_heat_vaporization: 80.0,
725                diffusion_rate: 0.125,
726                ambient_temperature: 22.5,
727                cooling_rate: 0.25,
728                liquid_flow_speed: 0.5,
729                gas_expansion_rate: 0.375,
730                gas_dissipation_rate: 0.625,
731                max_offset: 2.5,
732            },
733            2,
734            (-1.0, -2.0, -3.0),
735            (1.0, 2.0, 3.0),
736        );
737        for (i, v) in m.temperature.data.iter_mut().enumerate() {
738            *v = 100.0 + i as f32;
739        }
740        m.phase.data[1] = 1.0;
741        m.phase.data[2] = 2.0;
742        m.latent_heat.data[1] = 40.0;
743        m.sdf_offset.data[2] = 0.75;
744        m.enabled = false;
745        m
746    }
747
748    /// oracle: the participant kind is `"PHAS"` read big endian; a payload
749    /// written by `write_state` is accepted and read back into a different
750    /// modifier, which then has the original config, flag and the four
751    /// fields and writes the same bytes. Observations: channel 0 the hottest
752    /// cell (107), channel 1 the liquid or gas cells (2), channel 2 the total
753    /// offset (3/4).
754    #[test]
755    fn participant_state_round_trip_and_observations() {
756        assert_eq!(
757            PhaseChangeModifier::PARTICIPANT_KIND,
758            ParticipantKind::new(0x5048_4153)
759        );
760        let src = sample_modifier();
761        assert_eq!(
762            Participant::kind(&src),
763            PhaseChangeModifier::PARTICIPANT_KIND
764        );
765        let mut bytes = Vec::new();
766        src.write_state(&mut bytes);
767        assert_eq!(src.check_state(&bytes), Ok(()));
768        let mut dst = PhaseChangeModifier::new(
769            PhaseChangeConfig::default(),
770            1,
771            (0.0, 0.0, 0.0),
772            (1.0, 1.0, 1.0),
773        );
774        dst.read_state(&bytes);
775        assert_eq!(dst.config, src.config);
776        assert!(!dst.enabled);
777        assert_eq!(dst.temperature.data, src.temperature.data);
778        assert_eq!(dst.phase.data, src.phase.data);
779        assert_eq!(dst.latent_heat.data, src.latent_heat.data);
780        assert_eq!(dst.sdf_offset.data, src.sdf_offset.data);
781        let mut again = Vec::new();
782        dst.write_state(&mut again);
783        assert_eq!(again, bytes);
784
785        let mut sink = ObservationSink::new();
786        dst.observe(&mut sink);
787        assert_eq!(
788            sink.values(),
789            &[
790                (0, Fix128::from_int(107)),
791                (1, Fix128::from_int(2)),
792                (2, Fix128::from_ratio(3, 4))
793            ]
794        );
795    }
796
797    /// oracle: the enabled flag sits after the version (4) and eleven `f32`s
798    /// (44), at offset 48; a value other than 0/1 is refused, as is a
799    /// payload one byte short or long.
800    #[test]
801    fn participant_state_refusals() {
802        let src = sample_modifier();
803        let mut bytes = Vec::new();
804        src.write_state(&mut bytes);
805        assert_eq!(bytes[48], 0);
806        let mut bad = bytes.clone();
807        bad[48] = 5;
808        assert_eq!(src.check_state(&bad), Err(StateError::InvalidValue));
809        assert!(matches!(
810            src.check_state(&bytes[..bytes.len() - 1]),
811            Err(StateError::Length { .. })
812        ));
813        let mut long = bytes;
814        long.push(0);
815        assert!(matches!(
816            src.check_state(&long),
817            Err(StateError::Length { .. })
818        ));
819    }
820
821    #[test]
822    #[should_panic(expected = "read_state called with a payload check_state refuses")]
823    fn participant_read_state_panics_on_a_refused_payload() {
824        let mut m = sample_modifier();
825        m.read_state(&[1, 0, 0]);
826    }
827
828    /// oracle: in a world of one substep `dt = 1/4`, a one-cell solid at 220
829    /// (melt 200, fusion buffer 50, no diffusion or cooling) absorbs
830    /// `min(20, 50) = 20` into its buffer and stays solid at 200: channel 0
831    /// reads 200, channel 1 (fluid cells) 0, channel 2 (offset) 0.
832    #[test]
833    fn participant_substep_in_a_world() {
834        let mut m = PhaseChangeModifier::new(still_config(), 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
835        m.temperature.data[0] = 220.0;
836        let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig {
837            substeps: 1,
838            ..Default::default()
839        });
840        world.add_participant(Box::new(m)).expect("register");
841        world.step(Fix128::from_ratio(1, 4));
842        let Some(crate::world_participant::Observed::Exact(sink)) = world.observe_participant(0)
843        else {
844            panic!("observation");
845        };
846        assert_eq!(
847            sink.values(),
848            &[
849                (0, Fix128::from_int(200)),
850                (1, Fix128::ZERO),
851                (2, Fix128::ZERO)
852            ]
853        );
854    }
855}