Skip to main content

alice_physics/
thermal.rs

1//! Thermal Simulation Modifier
2//!
3//! Heat diffusion drives SDF shape changes:
4//! - **Melt**: Above melt temperature, surface recedes + drips downward
5//! - **Thermal Expansion**: Warm areas expand outward
6//! - **Freeze**: Below freeze threshold, crystalline growth on surface
7//!
8//! # Physics Model
9//!
10//! Temperature field evolves via heat equation: dT/dt = k * laplacian(T)
11//! with source terms (heat sources, contact friction, radiation cooling).
12//! The temperature field then modulates the SDF distance.
13//!
14//! Author: Moroya Sakamoto
15
16use crate::coupled_field::{CoupledField, CoupledFieldError, CoupledScalar};
17use crate::math::Fix128;
18use crate::sim_field::ScalarField3D;
19use crate::sim_modifier::PhysicsModifier;
20use crate::sim_modifier::{observe_max, observe_sum, StateReader, StateWriter};
21use crate::world_participant::{
22    ObservationSink, Participant, ParticipantFault, ParticipantKind, StateError, SubstepCtx,
23};
24
25#[cfg(not(feature = "std"))]
26use alloc::vec::Vec;
27
28// ============================================================================
29// Configuration
30// ============================================================================
31
32/// Thermal simulation configuration
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ThermalConfig {
35    /// Thermal diffusion rate (conductivity)
36    pub diffusion_rate: f32,
37    /// Ambient temperature (environment)
38    pub ambient_temperature: f32,
39    /// Radiation cooling rate (toward ambient)
40    pub cooling_rate: f32,
41    /// Temperature at which material begins to melt
42    pub melt_temperature: f32,
43    /// Rate of surface recession when melting (distance/second/degree above melt)
44    pub melt_rate: f32,
45    /// Gravity droop strength (how much melt flows downward)
46    pub droop_strength: f32,
47    /// Thermal expansion coefficient (expansion per degree above ambient)
48    pub expansion_coefficient: f32,
49    /// Temperature at which freeze growth begins
50    pub freeze_temperature: f32,
51    /// Rate of surface growth when freezing
52    pub freeze_rate: f32,
53}
54
55impl Default for ThermalConfig {
56    fn default() -> Self {
57        Self {
58            diffusion_rate: 0.5,
59            ambient_temperature: 20.0,
60            cooling_rate: 0.1,
61            melt_temperature: 200.0,
62            melt_rate: 0.02,
63            droop_strength: 0.5,
64            expansion_coefficient: 0.0001,
65            freeze_temperature: -10.0,
66            freeze_rate: 0.01,
67        }
68    }
69}
70
71/// Heat source types
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub enum HeatSource {
74    /// Point heat source
75    Point {
76        /// Position X (world space)
77        x: f32,
78        /// Position Y (world space)
79        y: f32,
80        /// Position Z (world space)
81        z: f32,
82        // LIMITATION(COV-THERM-040): Heat power (degrees/second at center)
83        /// Heat power (degrees/second at center)
84        power: f32,
85        /// Influence radius
86        radius: f32,
87    },
88    /// Uniform heat in a volume
89    Volume {
90        /// Min corner
91        min: (f32, f32, f32),
92        /// Max corner
93        max: (f32, f32, f32),
94        /// Heat power
95        power: f32,
96    },
97}
98
99// ============================================================================
100// Thermal Modifier
101// ============================================================================
102
103/// Thermal simulation that modifies SDF based on temperature
104///
105/// Supports melting (surface recession + gravity droop),
106/// thermal expansion, and freeze growth.
107pub struct ThermalModifier {
108    /// Configuration
109    pub config: ThermalConfig,
110    /// Temperature field
111    pub temperature: ScalarField3D,
112    /// Accumulated melt deformation (positive = material removed)
113    pub melt_accumulator: ScalarField3D,
114    /// Active heat sources
115    pub heat_sources: Vec<HeatSource>,
116    /// Whether modifier is enabled
117    pub enabled: bool,
118}
119
120impl ThermalModifier {
121    /// Create a new thermal modifier
122    #[must_use]
123    pub fn new(
124        config: ThermalConfig,
125        resolution: usize,
126        min: (f32, f32, f32),
127        max: (f32, f32, f32),
128    ) -> Self {
129        Self {
130            config,
131            temperature: ScalarField3D::new_filled(
132                resolution,
133                resolution,
134                resolution,
135                min,
136                max,
137                config.ambient_temperature,
138            ),
139            melt_accumulator: ScalarField3D::new(resolution, resolution, resolution, min, max),
140            heat_sources: Vec::new(),
141            enabled: true,
142        }
143    }
144
145    /// Add a point heat source
146    pub fn add_heat_point(&mut self, x: f32, y: f32, z: f32, power: f32, radius: f32) {
147        self.heat_sources.push(HeatSource::Point {
148            x,
149            y,
150            z,
151            power,
152            radius,
153        });
154    }
155
156    /// Apply heat at a position (e.g., from friction, laser, etc.)
157    pub fn apply_heat_at(&mut self, x: f32, y: f32, z: f32, amount: f32, radius: f32) {
158        self.temperature.splat(x, y, z, amount, radius);
159    }
160
161    /// Get temperature at a point
162    #[must_use]
163    pub fn temperature_at(&self, x: f32, y: f32, z: f32) -> f32 {
164        self.temperature.sample(x, y, z)
165    }
166
167    /// Apply heat sources to the temperature field
168    fn apply_heat_sources(&mut self, dt: f32) {
169        for source in &self.heat_sources {
170            match *source {
171                HeatSource::Point {
172                    x,
173                    y,
174                    z,
175                    power,
176                    radius,
177                } => {
178                    self.temperature.splat(x, y, z, power * dt, radius);
179                }
180                HeatSource::Volume { min, max, power } => {
181                    // Apply uniform heat to all cells within volume
182                    let nx = self.temperature.nx;
183                    let ny = self.temperature.ny;
184                    let nz = self.temperature.nz;
185                    for iz in 0..nz {
186                        for iy in 0..ny {
187                            for ix in 0..nx {
188                                let wx = self.temperature.min.0
189                                    + ix as f32 * (self.temperature.max.0 - self.temperature.min.0)
190                                        / (nx - 1).max(1) as f32;
191                                let wy = self.temperature.min.1
192                                    + iy as f32 * (self.temperature.max.1 - self.temperature.min.1)
193                                        / (ny - 1).max(1) as f32;
194                                let wz = self.temperature.min.2
195                                    + iz as f32 * (self.temperature.max.2 - self.temperature.min.2)
196                                        / (nz - 1).max(1) as f32;
197
198                                if wx >= min.0
199                                    && wx <= max.0
200                                    && wy >= min.1
201                                    && wy <= max.1
202                                    && wz >= min.2
203                                    && wz <= max.2
204                                {
205                                    self.temperature.add(ix, iy, iz, power * dt);
206                                }
207                            }
208                        }
209                    }
210                }
211            }
212        }
213    }
214
215    /// Accumulate melt deformation where temperature exceeds melt point
216    fn accumulate_melt(&mut self, dt: f32) {
217        let melt_temp = self.config.melt_temperature;
218        let melt_rate = self.config.melt_rate;
219        let droop = self.config.droop_strength;
220
221        let n = self.temperature.cell_count();
222        for i in 0..n {
223            let temp = self.temperature.data[i];
224            if temp > melt_temp {
225                let excess = temp - melt_temp;
226                // Surface recession proportional to excess temperature
227                self.melt_accumulator.data[i] += excess * melt_rate * dt;
228            }
229        }
230
231        // Gravity droop: shift melt downward
232        if droop > 0.0 {
233            let nx = self.melt_accumulator.nx;
234            let ny = self.melt_accumulator.ny;
235            let nz = self.melt_accumulator.nz;
236            // Simple downward diffusion (bottom cells receive from above)
237            for iz in 0..nz {
238                for ix in 0..nx {
239                    for iy in 1..ny {
240                        let above_idx = self.melt_accumulator.index(ix, iy, iz);
241                        let below_idx = self.melt_accumulator.index(ix, iy - 1, iz);
242                        let transfer = self.melt_accumulator.data[above_idx] * droop * dt;
243                        if transfer > 0.0 {
244                            self.melt_accumulator.data[below_idx] += transfer;
245                            self.melt_accumulator.data[above_idx] -= transfer * 0.5;
246                        }
247                    }
248                }
249            }
250        }
251    }
252}
253
254impl PhysicsModifier for ThermalModifier {
255    #[inline]
256    fn modify_distance(&self, x: f32, y: f32, z: f32, original_dist: f32) -> f32 {
257        if !self.enabled {
258            return original_dist;
259        }
260
261        let temp = self.temperature.sample(x, y, z);
262        let mut d = original_dist;
263
264        // Melt: accumulated surface recession
265        let melt = self.melt_accumulator.sample(x, y, z);
266        if melt > 0.0 {
267            d += melt; // Positive = material removed
268        }
269
270        // Thermal expansion: warm areas expand (distance decreases)
271        let temp_delta = temp - self.config.ambient_temperature;
272        if temp_delta > 0.0 && self.config.expansion_coefficient > 0.0 {
273            d -= temp_delta * self.config.expansion_coefficient;
274        }
275
276        // Freeze growth: cold areas grow (distance decreases), capped to prevent unbounded expansion
277        if temp < self.config.freeze_temperature && self.config.freeze_rate > 0.0 {
278            let cold = self.config.freeze_temperature - temp;
279            let freeze_offset = crate::det_math::min(cold * self.config.freeze_rate, 1.0);
280            d -= freeze_offset;
281        }
282
283        d
284    }
285
286    fn update(&mut self, dt: f32) {
287        if !self.enabled {
288            return;
289        }
290
291        // 1. Apply heat sources
292        self.apply_heat_sources(dt);
293
294        // 2. Diffuse temperature
295        self.temperature.diffuse(dt, self.config.diffusion_rate);
296
297        // 3. Cool toward ambient
298        self.temperature.decay_toward(
299            self.config.ambient_temperature,
300            self.config.cooling_rate,
301            dt,
302        );
303
304        // 4. Accumulate melt
305        self.accumulate_melt(dt);
306    }
307
308    fn name(&self) -> &'static str {
309        "thermal"
310    }
311
312    fn is_active(&self) -> bool {
313        self.enabled
314    }
315}
316
317// ============================================================================
318// Coupling channel
319// ============================================================================
320
321/// The temperature field is shared through a deterministic `Fix128` channel.
322///
323/// `ThermalModifier` and [`crate::phase_change::PhaseChangeModifier`] each own
324/// a temperature field and nothing relates them — `PhysicsModifier::update`
325/// receives only `dt`, so neither can read the other's. Going through
326/// [`CoupledField`] gives the pair one agreed field; see
327/// [`crate::coupled_field::reconcile_mean`].
328///
329/// This does not change what `update` does: coupling happens only when the
330/// caller reconciles, so an existing chain behaves exactly as before.
331impl CoupledScalar for ThermalModifier {
332    fn coupled_name(&self) -> &'static str {
333        "temperature"
334    }
335
336    fn coupled_channel(&self) -> Result<CoupledField, CoupledFieldError> {
337        CoupledField::try_matching(&self.temperature)
338    }
339
340    fn publish(&self, out: &mut CoupledField) -> Result<(), CoupledFieldError> {
341        out.copy_from_f32(&self.temperature)
342    }
343
344    fn adopt(&mut self, src: &CoupledField) -> Result<(), CoupledFieldError> {
345        src.write_to_f32(&mut self.temperature)
346    }
347
348    fn check_adopt(&self, src: &CoupledField) -> Result<(), CoupledFieldError> {
349        src.require_same_grid_f32(&self.temperature)
350    }
351}
352
353// ============================================================================
354// World participant
355// ============================================================================
356
357impl ThermalModifier {
358    /// Snapshot tag of this type as a world participant: `"THRM"`, the four
359    /// ASCII bytes read big endian. Never changes.
360    pub const PARTICIPANT_KIND: ParticipantKind =
361        ParticipantKind::new(u32::from_be_bytes(*b"THRM"));
362
363    fn decode_state(bytes: &[u8]) -> Result<Self, StateError> {
364        let mut r = StateReader::new(bytes)?;
365        let config = ThermalConfig {
366            diffusion_rate: r.f32()?,
367            ambient_temperature: r.f32()?,
368            cooling_rate: r.f32()?,
369            melt_temperature: r.f32()?,
370            melt_rate: r.f32()?,
371            droop_strength: r.f32()?,
372            expansion_coefficient: r.f32()?,
373            freeze_temperature: r.f32()?,
374            freeze_rate: r.f32()?,
375        };
376        let enabled = r.bool()?;
377        let temperature = r.field()?;
378        let melt_accumulator = r.field()?;
379        let n = r.count(1 + 5 * 4)?;
380        let mut heat_sources = Vec::with_capacity(n);
381        for _ in 0..n {
382            heat_sources.push(match r.u8()? {
383                0 => HeatSource::Point {
384                    x: r.f32()?,
385                    y: r.f32()?,
386                    z: r.f32()?,
387                    power: r.f32()?,
388                    radius: r.f32()?,
389                },
390                1 => HeatSource::Volume {
391                    min: r.vec3()?,
392                    max: r.vec3()?,
393                    power: r.f32()?,
394                },
395                _ => return Err(StateError::InvalidValue),
396            });
397        }
398        r.finish()?;
399        Ok(Self {
400            config,
401            temperature,
402            melt_accumulator,
403            heat_sources,
404            enabled,
405        })
406    }
407}
408
409/// One `update(h)` per world substep (`h` converted with
410/// [`Fix128::to_f32`]); see the module documentation of
411/// [`crate::sim_modifier`] for the payload and what is not coupled yet.
412///
413/// Observations: channel 0 the highest cell temperature (absent for a grid
414/// without cells), channel 1 the total melt (sum of the melt cells).
415impl Participant for ThermalModifier {
416    fn kind(&self) -> ParticipantKind {
417        Self::PARTICIPANT_KIND
418    }
419
420    fn substep(&mut self, _ctx: &mut SubstepCtx<'_>, h: Fix128) -> Result<(), ParticipantFault> {
421        PhysicsModifier::update(self, h.to_f32());
422        Ok(())
423    }
424
425    fn observe(&self, out: &mut ObservationSink) {
426        observe_max(out, 0, &self.temperature);
427        observe_sum(out, 1, &self.melt_accumulator);
428    }
429
430    fn write_state(&self, out: &mut Vec<u8>) {
431        let mut w = StateWriter::new(out);
432        let c = &self.config;
433        for v in [
434            c.diffusion_rate,
435            c.ambient_temperature,
436            c.cooling_rate,
437            c.melt_temperature,
438            c.melt_rate,
439            c.droop_strength,
440            c.expansion_coefficient,
441            c.freeze_temperature,
442            c.freeze_rate,
443        ] {
444            w.f32(v);
445        }
446        w.bool(self.enabled);
447        w.field(&self.temperature);
448        w.field(&self.melt_accumulator);
449        w.usize(self.heat_sources.len());
450        for s in &self.heat_sources {
451            match *s {
452                HeatSource::Point {
453                    x,
454                    y,
455                    z,
456                    power,
457                    radius,
458                } => {
459                    w.u8(0);
460                    for v in [x, y, z, power, radius] {
461                        w.f32(v);
462                    }
463                }
464                HeatSource::Volume { min, max, power } => {
465                    w.u8(1);
466                    w.vec3(min);
467                    w.vec3(max);
468                    w.f32(power);
469                }
470            }
471        }
472    }
473
474    fn check_state(&self, bytes: &[u8]) -> Result<(), StateError> {
475        Self::decode_state(bytes).map(drop)
476    }
477
478    fn read_state(&mut self, bytes: &[u8]) {
479        match Self::decode_state(bytes) {
480            Ok(state) => *self = state,
481            Err(e) => panic!("read_state called with a payload check_state refuses: {e:?}"),
482        }
483    }
484}
485
486// ============================================================================
487// Tests
488// ============================================================================
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::sdf_collider::{ClosureSdf, SdfField};
494    use crate::sim_modifier::SingleModifiedSdf;
495
496    fn unit_sphere() -> ClosureSdf {
497        ClosureSdf::new(
498            |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
499            |x, y, z| {
500                let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
501                if len < 1e-10 {
502                    (0.0, 1.0, 0.0)
503                } else {
504                    (x / len, y / len, z / len)
505                }
506            },
507        )
508    }
509
510    #[test]
511    fn test_thermal_no_heat() {
512        let config = ThermalConfig::default();
513        let modifier = ThermalModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
514        let modified = SingleModifiedSdf::new(Box::new(unit_sphere()), modifier);
515
516        // No heat applied: distance should be unchanged
517        let d = modified.distance(2.0, 0.0, 0.0);
518        assert!(
519            (d - 1.0).abs() < 0.1,
520            "No heat should not change SDF, got {d}"
521        );
522    }
523
524    #[test]
525    fn test_thermal_melt() {
526        let config = ThermalConfig {
527            melt_temperature: 100.0,
528            melt_rate: 0.1,
529            ambient_temperature: 20.0,
530            ..Default::default()
531        };
532        let mut modifier = ThermalModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
533
534        // Apply extreme heat at surface (radius must be > cell_size for 8-res grid)
535        modifier.apply_heat_at(0.0, 0.0, 0.0, 2000.0, 1.5);
536
537        // Simulate several steps
538        for _ in 0..50 {
539            modifier.update(0.016);
540        }
541
542        // Check melt accumulation
543        let melt = modifier.melt_accumulator.sample(0.0, 0.0, 0.0);
544        assert!(melt > 0.0, "Melt should accumulate, got {melt}");
545    }
546
547    #[test]
548    fn test_thermal_expansion() {
549        let config = ThermalConfig {
550            expansion_coefficient: 0.01,
551            ambient_temperature: 20.0,
552            melt_temperature: 10000.0, // Won't melt
553            ..Default::default()
554        };
555        let mut modifier = ThermalModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
556
557        // Heat the entire field
558        modifier.temperature.data.fill(100.0); // 80 degrees above ambient
559
560        let d_before = 1.0f32; // distance at surface
561        let d_after = modifier.modify_distance(2.0, 0.0, 0.0, d_before);
562
563        // Expansion: distance should decrease (object grows)
564        assert!(
565            d_after < d_before,
566            "Expansion should decrease distance, before={d_before}, after={d_after}"
567        );
568    }
569
570    #[test]
571    fn test_heat_diffusion() {
572        let config = ThermalConfig {
573            diffusion_rate: 1.0,
574            ambient_temperature: 0.0,
575            cooling_rate: 0.0,
576            melt_temperature: 10000.0,
577            ..Default::default()
578        };
579        let mut modifier = ThermalModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
580
581        // Set ambient to 0
582        modifier.temperature.data.fill(0.0);
583
584        // Hot spot at center (large radius to cover grid cells)
585        modifier.apply_heat_at(0.0, 0.0, 0.0, 500.0, 1.5);
586        let center_before = modifier.temperature_at(0.0, 0.0, 0.0);
587
588        // Diffuse
589        for _ in 0..20 {
590            modifier.update(0.01);
591        }
592
593        let center_after = modifier.temperature_at(0.0, 0.0, 0.0);
594        assert!(
595            center_after < center_before,
596            "Heat should spread, before={center_before}, after={center_after}"
597        );
598    }
599
600    fn still_config() -> ThermalConfig {
601        ThermalConfig {
602            diffusion_rate: 0.0,
603            ambient_temperature: 20.0,
604            cooling_rate: 0.0,
605            melt_temperature: 200.0,
606            melt_rate: 0.0625,
607            droop_strength: 0.0,
608            expansion_coefficient: 0.0,
609            freeze_temperature: -10.0,
610            freeze_rate: 0.0,
611        }
612    }
613
614    /// oracle: on a 2³ grid over `[0, 1]³` the cells sit at `x = 0` and
615    /// `x = 1`; a volume source over `x ∈ [−1/2, 1/2]` of power 8 for
616    /// `dt = 1/4` adds `8·(1/4) = 2` to the four `x = 0` cells (20 → 22) and
617    /// nothing to the others; a point source of power 4 adds `4·dt` at its
618    /// centre cell.
619    #[test]
620    fn volume_and_point_heat_sources() {
621        let mut m = ThermalModifier::new(still_config(), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
622        m.heat_sources.push(HeatSource::Volume {
623            min: (-0.5, -0.5, -0.5),
624            max: (0.5, 1.5, 1.5),
625            power: 8.0,
626        });
627        m.apply_heat_sources(0.25);
628        for iz in 0..2 {
629            for iy in 0..2 {
630                assert_eq!(m.temperature.get(0, iy, iz), 22.0, "(0, {iy}, {iz})");
631                assert_eq!(m.temperature.get(1, iy, iz), 20.0, "(1, {iy}, {iz})");
632            }
633        }
634        let mut p = ThermalModifier::new(still_config(), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
635        p.add_heat_point(1.0, 1.0, 1.0, 4.0, 0.25);
636        p.apply_heat_sources(0.25);
637        assert!(p.temperature.get(1, 1, 1) > 20.0);
638        assert_eq!(p.temperature.get(0, 0, 0), 20.0);
639    }
640
641    /// oracle: a cell at 300 over the melt temperature 200 with melt rate 1/100
642    /// and `dt = 1` melts `100·(1/100)·1 = 1`; droop 1/2 then moves
643    /// `1·(1/2)·1 = 1/2` to the cell below and takes half of that from the
644    /// cell above: below 1/2, above 3/4.
645    #[test]
646    fn melt_and_droop_closed_form() {
647        let mut m = ThermalModifier::new(
648            ThermalConfig {
649                melt_rate: 0.01,
650                droop_strength: 0.5,
651                ..still_config()
652            },
653            2,
654            (0.0, 0.0, 0.0),
655            (1.0, 1.0, 1.0),
656        );
657        let above = m.temperature.index(0, 1, 0);
658        m.temperature.data[above] = 300.0;
659        m.accumulate_melt(1.0);
660        let below = m.melt_accumulator.index(0, 0, 0);
661        assert!((m.melt_accumulator.data[below] - 0.5).abs() < 1e-6);
662        assert!((m.melt_accumulator.data[above] - 0.75).abs() < 1e-6);
663        let other = m.melt_accumulator.index(1, 1, 1);
664        assert_eq!(m.melt_accumulator.data[other], 0.0);
665    }
666
667    /// oracle: with uniform fields (so sampling is exact) the distance is
668    /// `d + melt − (T − T_amb)·α` when warm and `d − min((T_f − T)·k, 1)`
669    /// when below freezing: `1 + 1/4 − 100·(1/1000) = 1.15`;
670    /// `1 − 50·(1/100) = 1/2`; `1 − min(200·(1/100), 1) = 0`. A disabled
671    /// modifier leaves the distance and fields as they are.
672    #[test]
673    fn modify_distance_melt_expansion_freeze_and_disabled() {
674        let mut m = ThermalModifier::new(
675            ThermalConfig {
676                expansion_coefficient: 0.001,
677                freeze_rate: 0.01,
678                ..still_config()
679            },
680            2,
681            (0.0, 0.0, 0.0),
682            (1.0, 1.0, 1.0),
683        );
684        m.temperature.data.fill(120.0);
685        m.melt_accumulator.data.fill(0.25);
686        assert!((m.modify_distance(0.5, 0.5, 0.5, 1.0) - 1.15).abs() < 1e-5);
687        m.melt_accumulator.data.fill(0.0);
688        m.temperature.data.fill(-60.0);
689        assert!((m.modify_distance(0.5, 0.5, 0.5, 1.0) - 0.5).abs() < 1e-5);
690        m.temperature.data.fill(-210.0);
691        assert!(m.modify_distance(0.5, 0.5, 0.5, 1.0).abs() < 1e-5);
692
693        m.enabled = false;
694        assert!(!m.is_active());
695        assert_eq!(m.modify_distance(0.5, 0.5, 0.5, 1.0), 1.0);
696        m.heat_sources.push(HeatSource::Volume {
697            min: (-1.0, -1.0, -1.0),
698            max: (2.0, 2.0, 2.0),
699            power: 100.0,
700        });
701        m.update(1.0);
702        assert!(m.temperature.data.iter().all(|&t| t == -210.0));
703    }
704
705    fn sample_modifier() -> ThermalModifier {
706        let mut m = ThermalModifier::new(
707            ThermalConfig {
708                diffusion_rate: 0.125,
709                ambient_temperature: 21.5,
710                cooling_rate: 0.25,
711                melt_temperature: 180.0,
712                melt_rate: 0.375,
713                droop_strength: 0.5,
714                expansion_coefficient: 0.0625,
715                freeze_temperature: -4.0,
716                freeze_rate: 0.75,
717            },
718            2,
719            (-1.0, -2.0, -3.0),
720            (1.0, 2.0, 3.0),
721        );
722        for (i, v) in m.temperature.data.iter_mut().enumerate() {
723            *v = 10.0 + i as f32;
724        }
725        m.melt_accumulator.data[2] = 0.5;
726        m.add_heat_point(0.5, 0.25, -0.5, 30.0, 0.75);
727        m.heat_sources.push(HeatSource::Volume {
728            min: (-1.0, -1.0, -1.0),
729            max: (0.0, 0.5, 1.0),
730            power: 12.0,
731        });
732        m.enabled = false;
733        m
734    }
735
736    /// oracle: the participant kind is `"THRM"` read big endian; a payload
737    /// written by `write_state` is accepted and read back into a different
738    /// modifier, which then has the original config, flag, fields and both
739    /// kinds of heat source and writes the same bytes. Observations: channel
740    /// 0 the hottest cell (`10 + 7 = 17`), channel 1 the total melt (1/2).
741    #[test]
742    fn participant_state_round_trip_and_observations() {
743        assert_eq!(
744            ThermalModifier::PARTICIPANT_KIND,
745            ParticipantKind::new(0x5448_524d)
746        );
747        let src = sample_modifier();
748        assert_eq!(Participant::kind(&src), ThermalModifier::PARTICIPANT_KIND);
749        let mut bytes = Vec::new();
750        src.write_state(&mut bytes);
751        assert_eq!(src.check_state(&bytes), Ok(()));
752        let mut dst = ThermalModifier::new(
753            ThermalConfig::default(),
754            1,
755            (0.0, 0.0, 0.0),
756            (1.0, 1.0, 1.0),
757        );
758        dst.read_state(&bytes);
759        assert_eq!(dst.config, src.config);
760        assert!(!dst.enabled);
761        assert_eq!(dst.temperature.data, src.temperature.data);
762        assert_eq!(dst.melt_accumulator.data, src.melt_accumulator.data);
763        assert_eq!(dst.heat_sources, src.heat_sources);
764        let mut again = Vec::new();
765        dst.write_state(&mut again);
766        assert_eq!(again, bytes);
767
768        let mut sink = ObservationSink::new();
769        dst.observe(&mut sink);
770        assert_eq!(
771            sink.values(),
772            &[(0, Fix128::from_int(17)), (1, Fix128::from_ratio(1, 2))]
773        );
774    }
775
776    /// oracle: the volume source is the last 1 + 7·4 = 29 bytes, its tag
777    /// first; tag 2 names no source. A payload one byte short or long is
778    /// refused too.
779    #[test]
780    fn participant_state_refusals() {
781        let src = sample_modifier();
782        let mut bytes = Vec::new();
783        src.write_state(&mut bytes);
784        let tag = bytes.len() - 29;
785        assert_eq!(bytes[tag], 1);
786        let mut bad = bytes.clone();
787        bad[tag] = 2;
788        assert_eq!(src.check_state(&bad), Err(StateError::InvalidValue));
789        assert!(matches!(
790            src.check_state(&bytes[..bytes.len() - 1]),
791            Err(StateError::Length { .. })
792        ));
793        let mut long = bytes;
794        long.push(0);
795        assert!(matches!(
796            src.check_state(&long),
797            Err(StateError::Length { .. })
798        ));
799    }
800
801    #[test]
802    #[should_panic(expected = "read_state called with a payload check_state refuses")]
803    fn participant_read_state_panics_on_a_refused_payload() {
804        let mut m = sample_modifier();
805        m.read_state(&[0, 0, 0, 0]);
806    }
807
808    /// oracle: in a world of one substep `dt = 1/4`, a one-cell modifier at
809    /// 300 (melt temperature 200, melt rate 1/16, no diffusion, cooling or
810    /// droop) melts `100·(1/16)·(1/4) = 25/16`; channel 0 reads 300 and
811    /// channel 1 reads 25/16.
812    #[test]
813    fn participant_substep_in_a_world() {
814        let mut m = ThermalModifier::new(still_config(), 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
815        m.temperature.data[0] = 300.0;
816        let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig {
817            substeps: 1,
818            ..Default::default()
819        });
820        world.add_participant(Box::new(m)).expect("register");
821        world.step(Fix128::from_ratio(1, 4));
822        let Some(crate::world_participant::Observed::Exact(sink)) = world.observe_participant(0)
823        else {
824            panic!("observation");
825        };
826        assert_eq!(
827            sink.values(),
828            &[(0, Fix128::from_int(300)), (1, Fix128::from_ratio(25, 16))]
829        );
830    }
831}