From 2fb72df81ef80200eaee1c78e4c38f0c501a04ab Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:09:49 -0500 Subject: [PATCH 01/14] Mathmatics pass per #8 comments and existing issues. Additional documentation and variable renames to make modification easier. --- README.md | 4 +- docs/atmospherics_technical_documentation.md | 185 ++-- src/Numos.API/AtmosSimulation.cs | 43 +- src/Numos.CoreSim/AtmosChunk.cs | 118 ++- src/Numos.CoreSim/AtmosChunkConstants.cs | 25 + src/Numos.CoreSim/AtmosConfig.cs | 106 ++- src/Numos.CoreSim/AtmosConfigDefaults.cs | 69 ++ src/Numos.CoreSim/AtmosKernel.API.cs | 41 +- src/Numos.CoreSim/AtmosKernel.cs | 825 +++++++++++------- src/Numos.CoreSim/AtmosPhysicalConstants.cs | 28 + src/Numos.CoreSim/AtmosSolverConstants.cs | 30 + .../Datatypes/Events/BoundaryFlowEvent.cs | 14 +- .../Datatypes/Events/PrecipitationEvent.cs | 15 +- .../Datatypes/Events/ThermalBoundaryEvent.cs | 3 +- .../Datatypes/Snapshots/AtmosChunkSnapshot.cs | 6 +- .../Datatypes/Snapshots/AtmosVoxelSnapshot.cs | 10 +- .../Datatypes/Snapshots/GasSnapshot.cs | 4 +- src/Numos.CoreSim/GasAccumulator.cs | 20 +- src/Numos.CoreSim/GasChannel.cs | 4 +- src/Numos.CoreSim/GasProperties.cs | 32 +- src/Numos.CoreSim/RoomNode.cs | 69 +- src/Numos.SimDrawer/DrawableData.cs | 5 +- src/Numos.SimDrawer/Visualization.cs | 5 +- src/Numos.Viewer/SimulationViewer.Project.cs | 25 +- .../SimulationViewer.ProjectUi.cs | 36 +- src/Numos.Viewer/SimulationViewer.RenderUi.cs | 92 +- .../Numos.API.Tests/AtmosChunkVersionTests.cs | 4 +- .../AtmosSimulationContractTests.cs | 106 ++- tests/Numos.API.Tests/AtmosSimulationTests.cs | 2 +- .../CrossChunkFlowTests.cs | 38 +- .../IntraChunkFlowTests.cs | 112 ++- .../SimTestHelpers.cs | 43 +- .../ThermodynamicsIntegrationTests.cs | 160 +++- .../TopologyAndSymmetryTests.cs | 16 +- .../AtmosChunkInjectionTests.cs | 28 +- .../AtmosChunkSnapshotTests.cs | 7 +- .../AtmosChunkTopologyTests.cs | 26 +- tests/Numos.CoreSim.Tests/AtmosConfigTests.cs | 45 +- tests/Numos.CoreSim.Tests/RoomNodeTests.cs | 70 +- .../VoxelClassificationTests.cs | 10 - 40 files changed, 1607 insertions(+), 874 deletions(-) create mode 100644 src/Numos.CoreSim/AtmosChunkConstants.cs create mode 100644 src/Numos.CoreSim/AtmosConfigDefaults.cs create mode 100644 src/Numos.CoreSim/AtmosPhysicalConstants.cs create mode 100644 src/Numos.CoreSim/AtmosSolverConstants.cs diff --git a/README.md b/README.md index 4bd42bb..70f6c61 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ The project will follow a regular semantic versioning structure when I feel comf - Engine-agnostic, with a supported `Numos.API` facade over an internal simulation kernel - Multithreaded intra-chunk advection and thermodynamics - Singlethreaded cross-chunk boundary flow -- Simplified ideal gas law (`P = n \cdot T`) -- Constant volume voxels +- Ideal-gas pressure in pascals (`P = nRT/V`) with configurable, uniform voxel volume +- Sensible internal-energy transport using per-species molar heat capacity at constant volume - Attempts at being trimmable and Native AOT-compatible ## Bug Reports & Contributions diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 214bafa..c908450 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -1,9 +1,9 @@ # Atmospherics System — Technical Documentation > [!NOTE] -> The thermodynamics and energy-transfer sections were updated for the effective molar heat-capacity model. Other legacy sections may not reflect every current implementation detail. +> Pressure, thermodynamics, phase changes, and energy transfer use the explicit SI unit model described below. Other legacy sections may not reflect every current implementation detail. -> **Revision**: 2026-08-15 +> **Revision**: 2026-08-19 > **Scope**: Engine-agnostic specification. --- @@ -25,16 +25,16 @@ - 4.3 [Phase 2 — Cross-Chunk Boundary Flow](#43-phase-2--cross-chunk-boundary-flow) - 4.4 [Phase 3 — Thermodynamics](#44-phase-3--thermodynamics) 5. [Stability & Convergence Mechanisms](#5-stability--convergence-mechanisms) - - 5.1 [CFL Flow Cap](#51-cfl-flow-cap) - - 5.2 [Damping & Snap-to-Equilibrium](#52-damping--snap-to-equilibrium) - - 5.3 [Minimum Flow Cutoff (Stiction)](#53-minimum-flow-cutoff-stiction) + - 5.1 [Per-Neighbor Bulk-Flow Cap](#51-per-neighbor-bulk-flow-cap) + - 5.2 [Damping & Low-Delta Regime](#52-damping--low-delta-regime) + - 5.3 [Minimum Pressure Transfer (Stiction)](#53-minimum-pressure-transfer-stiction) - 5.4 [Vacuum Cleanup](#54-vacuum-cleanup) - 5.5 [Delta Buffers (Ordering Scope)](#55-delta-buffers-ordering-scope) 6. [Sleep System](#6-sleep-system) 7. [The Leaky Faucet Problem & GasAccumulator](#7-the-leaky-faucet-problem--gasaccumulator) 8. [Phase Changes (Condensation)](#8-phase-changes-condensation) - 8.1 [Clausius-Clapeyron Saturation Model](#81-clausius-clapeyron-saturation-model) - - 8.2 [Latent-Heat Energy Balance](#82-latent-heat-energy-balance) + - 8.2 [Phase-Change Internal-Energy Balance](#82-phase-change-internal-energy-balance) 9. [Networking & Replication](#9-networking--replication) 10. [Known Flaws & Limitations](#10-known-flaws--limitations) 11. [Porting Guidance](#11-porting-guidance) @@ -45,7 +45,7 @@ The system is built to simulate atmospheric gas dynamics in the context of a space-station or sealed-environment game. The core design priorities, as observable from the code, are: -1. **Performance over physical fidelity.** The simulation uses a simplified ideal gas law (`P = n * T`) with unit voxel volume rather than the full `PV = nRT` with real gas constants. This trades physical accuracy for speed and tunability. +1. **Performance with auditable units.** The simulation uses the ideal-gas law `P = nRT/V` with one configurable, uniform voxel volume. Pressure is stored in pascals, temperature in kelvins, amount in moles, and sensible energy in joules. The cellular flow model remains a game-oriented approximation rather than a Navier–Stokes solver. 2. **Work-proportional cost.** CPU cycles should be spent only on regions with active pressure gradients. Stable rooms should cost effectively zero. 3. **Engine independence.** The core simulation logic has no dependency on any specific game engine, rendering framework, or platform API. It is written as a standalone module that can be dropped into any engine's update loop. 4. **Multi-gas support.** The system tracks multiple independent gas species with distinct physical properties. Memory is allocated lazily per-gas, per-chunk. @@ -125,26 +125,29 @@ Each chunk stores: | Array | Type | Description | |-------|------|-------------| | `VoxelRoomMap` | `int[]` | Classifies each voxel (see §3.3) | -| `TotalPressure` | `float[]` | Cached pressure per voxel, recalculated at advection start and refreshed as state changes | -| `Temperature` | `float[]` | Temperature in Kelvin per voxel | +| `TotalPressure` | `float[]` | Cached pressure per voxel in pascals (Pa), recalculated at advection start and refreshed as state changes | +| `Temperature` | `float[]` | Temperature in kelvins (K) per voxel | | `TotalHeatCapacity` | `float[]` | Cached total heat capacity per voxel, in J/K | | `ActiveAirIndices` | `ushort[]` | Dense list of voxel indices belonging to the currently active rooms | | `ActiveGases` | `GasChannel[]` | Sparse array of gas-specific mole data (see §3.2) | -For thermodynamic calculations, each gas uses an effective molar heat capacity: +For thermodynamic calculations, each gas uses an effective molar heat capacity at constant volume: ``` -c_fallback = isFinite(DefaultSpecificHeatCapacity) && DefaultSpecificHeatCapacity > 0 - ? DefaultSpecificHeatCapacity - : 1 J/(mol·K) -c_effective = gasIsRegistered && isFinite(SpecificHeatCapacity) && SpecificHeatCapacity > 0 - ? SpecificHeatCapacity +c_fallback = isFinite(DefaultMolarHeatCapacityAtConstantVolume) && DefaultMolarHeatCapacityAtConstantVolume > 0 + ? DefaultMolarHeatCapacityAtConstantVolume + : 5R/2 +c_effective = gasIsRegistered && isFinite(MolarHeatCapacityAtConstantVolume) && MolarHeatCapacityAtConstantVolume > 0 + ? MolarHeatCapacityAtConstantVolume : c_fallback C_voxel = sum(moles[g] * c_effective[g]) E_voxel = C_voxel * effectiveTemperature +P_voxel = totalMoles * R * effectiveTemperature / VoxelVolume ``` -`C_voxel` is a total heat capacity in J/K, not a molar heat capacity. `E_voxel` is the sensible energy represented by the voxel state. `DefaultSpecificHeatCapacity` defaults to `1 J/(mol·K)` and is itself normalized to that value if configured to a non-finite or nonpositive value. The heat-capacity cache is recalculated or updated whenever gas composition changes. When a gas-bearing voxel's stored temperature is non-finite or nonpositive, pressure and energy calculations use `DefaultTemperatureFallback` as the starting effective temperature. An energy update then stores its calculated blended, diffused, or phase-change temperature. +`C_voxel` is a total heat capacity in J/K, not a molar heat capacity. `E_voxel` is the sensible internal energy represented by the voxel state, so the model uses constant-volume heat capacity (`C_v`) rather than constant-pressure heat capacity (`C_p`). `R` is the molar gas constant (`8.31446262 J/(mol·K)`) and `VoxelVolume` is in m³, making `P_voxel` pascals. `DefaultMolarHeatCapacityAtConstantVolume` defaults to the ideal-diatomic value `5R/2` (`20.786... J/(mol·K)`) and is normalized to that value if configured to a non-finite or nonpositive value. The heat-capacity cache is recalculated or updated whenever gas composition changes. When a gas-bearing voxel's stored temperature is non-finite or nonpositive, pressure and energy calculations use `DefaultTemperatureFallback`; an invalid fallback is normalized to `293.15 K`. An energy update then stores its calculated blended, diffused, or phase-change temperature. + +The gas-constant value and SI relationship follow the [NIST reference constants](https://physics.nist.gov/cgi-bin/cuu/Value?r). Chunks are identified by an `Int3 GridPosition` in a spatial map (e.g. a `ConcurrentDictionary`). @@ -189,17 +192,20 @@ When a room is at equilibrium (sleeping), it is represented by a `RoomNode`: struct RoomNode { int RoomId; bool IsAsleep; - int TotalVoxelVolume; + int VoxelCount; + float VoxelVolume; float EquilibriumPressure; float AverageTemperature; + float TotalHeatCapacity; + float TotalMoles; float[] GasMoles; // Total moles of each gas in the entire room } ``` The `RoomNode` provides O(1) gas addition/removal using the ideal gas law: -- **AddGas**: Recalculates `AverageTemperature` as a mole-weighted average, then updates `EquilibriumPressure = TotalMoles * AverageTemperature / TotalVoxelVolume`. -- **RemoveGas**: Clamps removal to available moles, recalculates pressure. Temperature is not changed on removal (assumes uniform mixture). +- **AddGas**: Recalculates `AverageTemperature` by conserving sensible internal energy with the incoming species' `C_v`, then updates `EquilibriumPressure = TotalMoles * R * AverageTemperature / (VoxelCount * VoxelVolume)`. +- **RemoveGas**: Clamps removal to available moles, removes the species' heat capacity, and recalculates pressure. Temperature is not changed on removal (assumes a uniform mixture). > [!IMPORTANT] > The `RoomNode` is defined with complete logic but is **not wired into the simulation loop**. `AtmosSimulation` operates exclusively at the voxel (micro) level. The `RoomNode` and `GasAccumulator` exist as data structures with complete logic, but the orchestration that transitions between macro and micro layers is not implemented. An integrator must build this transition logic. @@ -211,35 +217,44 @@ Each gas species is defined by a `GasProperties` struct: | Field | Type | Purpose | |-------|------|---------| | `Name` | `string` | Display name | -| `SpecificHeatCapacity` | `float` | Effective molar heat capacity in J/(mol·K). It controls sensible energy during injection, gas flow, thermal diffusion, and condensation. Energy and capacity paths use `DefaultSpecificHeatCapacity` for missing registry entries and non-finite or nonpositive values; condensation skips unregistered gas IDs. | -| `BoilingPoint` | `float` | Temperature (K) above which the gas remains gaseous | -| `CondensationPoint` | `float` | Temperature (K) below which condensation can begin. In practice, used as a boolean gate (`> 0` means "this gas can condense") | -| `LatentHeatOfVaporization` | `float` | Energy released per mole during condensation, in J/mol | +| `MolarHeatCapacityAtConstantVolume` | `float` | Molar `C_v` in J/(mol·K). It controls sensible internal energy during injection, gas flow, thermal diffusion, and condensation. Energy and capacity paths use `DefaultMolarHeatCapacityAtConstantVolume` for missing registry entries and non-finite or nonpositive values; condensation skips unregistered gas IDs. | +| `BoilingPoint` | `float` | Normal boiling temperature (K) at `SaturationReferencePressure` | +| `CondensationEnabled` | `bool` | Enables this species in the condensation model. | +| `MolarEnthalpyOfVaporization` | `float` | Vaporization enthalpy in J/mol, used by Clausius–Clapeyron and converted to an approximate constant-volume internal-energy change for condensation. | | `LiquidId` | `int` | ID of the liquid this gas condenses into (for a separate liquid simulation system) | -| `DiffusionCoefficient` | `float` | Fickian diffusion rate for partial-pressure-driven mixing | +| `DiffusionCoefficient` | `float` | Dimensionless fraction of the per-species mole imbalance mixed per simulation tick; finite values are clamped to [0, 1], and non-finite values disable species diffusion. | -The registry is stored as a `List` indexed by gas ID. Gas ID 0 is conventionally a placeholder/dummy entry. +The registry is stored as a `List` indexed by gas ID; zero is a valid gas ID. ### 3.6 Configuration Parameters All tunable simulation parameters are centralized in a configuration object: +The literals backing these defaults are exposed through `AtmosConfigDefaults`, while immutable SI and reference +condition values are exposed through `AtmosPhysicalConstants`. Internal fixed-step scheduling values and numerical +cutoffs live in `AtmosSolverConstants`; they are deliberately not presented as runtime configuration. Default chunk +dimensions and hard chunk capacities are exposed through `AtmosChunkConstants`, while reserved room IDs have a +single definition in `VoxelClassification`. + | Parameter | Default | Description | |-----------|---------|-------------| | `GlobalTemperature` | 293.15 | Reference ambient temperature (K). Not actively used in the simulation loop. | -| `DefaultTemperatureFallback` | 293.15 | Starting effective temperature used for pressure and sensible energy when a gas-bearing voxel stores a non-finite or nonpositive temperature. Callers must keep this value finite and positive because runtime does not normalize it. Energy evolution then stores its calculated result. | -| `DefaultSpecificHeatCapacity` | 1 | Effective molar heat capacity in J/(mol·K) used for missing registry entries and non-finite or nonpositive gas heat capacities. A non-finite or nonpositive fallback value is normalized to 1. | +| `DefaultTemperatureFallback` | 293.15 | Starting effective temperature (K) used for pressure and sensible energy when a gas-bearing voxel stores a non-finite or nonpositive temperature. Invalid values normalize to 293.15 K. | +| `DefaultMolarHeatCapacityAtConstantVolume` | `5R/2` | Ideal-diatomic molar `C_v` in J/(mol·K), used for missing registry entries and non-finite or nonpositive gas heat capacities. A non-finite or nonpositive fallback value is normalized to the same value. | +| `VoxelVolume` | 1 | Physical volume represented by each voxel (m³). Invalid values normalize to 1 m³. | +| `SaturationReferencePressure` | 101325 | Pressure (Pa) at which each gas's `BoilingPoint` applies. Invalid values normalize to one standard atmosphere. | +| `DefaultDiffusionCoefficient` | 0.02 | Dimensionless per-tick mixing fraction for unregistered gas IDs. Finite values are clamped to [0, 1]; non-finite values disable fallback diffusion. | | `SpaceTemperature` | 2.7 | Temperature of space (K). Not actively used in the simulation loop. | -| `FlowFriction` | 0.25 | Fraction of pressure delta converted to flow per tick. The `k` constant. | -| `DampingFactor` | 0.5 | Multiplier applied to `FlowFriction` during large-delta advection to reduce oscillation. | -| `SnapThreshold` | 5.0 | Below this pressure delta, flow uses the CFL cap directly instead of `FlowFriction * DampingFactor`. | -| `MinFlowCutoff` | 0.1 | Flows below this magnitude are discarded ("stiction"). | -| `VacuumThreshold` | 1.0 | Below this pressure, voxel contents are zeroed out. | -| `SleepThreshold` | 100 | Consecutive ticks below `SleepEpsilon` before a chunk goes to sleep. | -| `SleepEpsilon` | 3.5 | Maximum pressure delta considered "at rest". | -| `ThermalConductivity` | 0.05 | Effective conductance in J/K per thermodynamics tick (currently every second simulation tick). Multiplying it by a temperature difference produces a candidate energy transfer, which is then bounded for stability. Non-finite or nonpositive values disable thermal diffusion. | -| `CondensationRateFactor` | 0.5 | Rate multiplier for phase-change condensation. | -| `CflFlowCap` | 0.16 | Maximum fraction of a voxel's pressure that can flow to a single neighbor per tick (≈1/6 for 3D). | +| `BulkFlowCoefficient` | 0.25 | Dimensionless fraction of pressure delta requested as bulk flow per tick. Finite values are clamped to [0, 1]; non-finite values disable the large-delta branch. | +| `BulkFlowDamping` | 0.5 | Multiplier applied to `BulkFlowCoefficient` during large-delta advection to reduce oscillation. Finite values are clamped to [0, 1]; non-finite values disable the large-delta branch. | +| `LowPressureDeltaThreshold` | 5.0 | Below this pressure delta (Pa), flow uses `MaxPressureTransferFractionPerNeighbor` directly instead of `BulkFlowCoefficient * BulkFlowDamping`. Invalid or negative values normalize to zero. | +| `MinimumPressureTransfer` | 0.1 | Candidate pressure transfers below this magnitude (Pa/tick) are discarded ("stiction"). Invalid or negative values normalize to zero. | +| `VacuumThreshold` | 1.0 | Below this pressure (Pa), voxel contents are zeroed out. Invalid or negative values normalize to zero. | +| `SleepThreshold` | 100 | Consecutive ticks below `SleepEpsilon` before a chunk goes to sleep. Negative values normalize to zero. | +| `SleepEpsilon` | 3.5 | Maximum pressure delta considered "at rest" (Pa). Invalid or negative values normalize to zero. | +| `ThermalConductance` | 0.05 | Effective per-face conductance in J/K per thermodynamics tick. Multiplying it by a temperature difference produces a candidate energy transfer, which is bounded for explicit-solver stability. Invalid or nonpositive values disable thermal diffusion. | +| `CondensationRateFactor` | 0.5 | Dimensionless fraction of supersaturated vapor condensed per thermodynamics tick. Finite values are clamped to [0, 1]; non-finite values disable condensation. | +| `MaxPressureTransferFractionPerNeighbor` | 0.16 | Maximum fraction of a voxel's pressure requested as bulk flow to one neighbor per tick. Finite values are clamped to [0, 1]; non-finite values disable bulk flow. | --- @@ -268,32 +283,32 @@ This is the core fluid dynamics step. It runs in parallel across chunks. **For each awake chunk:** -1. **Recalculate pressure and heat capacity**: For every active voxel, `TotalPressure[i] = TotalMoles[i] * effectiveTemperature[i]`. This is a simplified ideal gas law with unit volume (`V = 1`); `effectiveTemperature` is the stored temperature when it is finite and positive, otherwise `DefaultTemperatureFallback`. The kernel also caches `TotalHeatCapacity[i] = sum(moles[g] * c_effective[g])` for energy calculations. +1. **Recalculate pressure and heat capacity**: For every active voxel, `TotalPressure[i] = TotalMoles[i] * R * effectiveTemperature[i] / VoxelVolume`. `effectiveTemperature` is the stored temperature when it is finite and positive, otherwise the normalized `DefaultTemperatureFallback`. The kernel also caches `TotalHeatCapacity[i] = sum(moles[g] * c_effective[g])` for energy calculations. 2. **Compute flow deltas**: For every active voxel, examine each Von Neumann neighbor (±X, ±Y, ±Z — 4 neighbors for 2D chunks, 6 for 3D): - Skip solid neighbors. - Treat void neighbors as pressure 0. - Calculate `pressureDelta = currentPressure - neighborPressure`. - If `pressureDelta > 0` (flow is outward): - - If `pressureDelta < SnapThreshold`: use `flow = pressureDelta * CflFlowCap` (fast snap to equilibrium). - - Else: use `flow = pressureDelta * FlowFriction * DampingFactor`. - - Discard if `flow < MinFlowCutoff`. - - Clamp: `flow = min(flow, currentPressure * CflFlowCap)`. - - Convert flow to moles: `molesToMove = (flow / sourceEffectiveTemperature) * moleFraction` for each gas. + - If `pressureDelta < LowPressureDeltaThreshold`: use `flow = pressureDelta * MaxPressureTransferFractionPerNeighbor`. + - Else: use `flow = pressureDelta * BulkFlowCoefficient * BulkFlowDamping`. + - Discard if `flow < MinimumPressureTransfer`. + - Clamp: `flow = min(flow, currentPressure * MaxPressureTransferFractionPerNeighbor)`. + - Convert the pressure transfer to moles: `advectedMoles = flow * VoxelVolume / (R * sourceEffectiveTemperature)`, then multiply by each species' mole fraction. - Compute the sensible energy carried by each species: `energyToMove = molesToMove * c_effective * sourceEffectiveTemperature`. - Cap each species' combined scheduled outflow across all neighbors to the moles present at the start of the pass. - Accumulate mole and energy changes into flat delta buffers (not applied immediately). Gas entering a void contributes no target delta, so both its moles and energy leave the simulation. -3. **Fickian Diffusion**: In addition to bulk advection, a diffusion term based on concentration gradients is applied: +3. **Fickian Diffusion**: Independently of the total-pressure gradient and bulk-flow cutoff, a species diffusion term based on partial-pressure imbalance is applied: ``` deltaN = moles[src] - moles[neighbor] * (neighborTemp / srcTemp) molesDiffused = deltaN * DiffusionCoefficient ``` - This allows gases with different diffusion rates to mix even after bulk pressure has equalized. The Z-axis is checked conditionally, only when `Depth > 1`, allowing efficient 2D operation. + This allows gases with different diffusion rates to mix after bulk pressure has equalized and permits one species to counter-diffuse against the net bulk-flow direction. Coefficients are clamped to [0, 1] for explicit-step stability. The Z-axis is checked conditionally, only when `Depth > 1`, allowing efficient 2D operation. -4. **Apply deltas**: After all voxels have been processed, the accumulated mole deltas are applied and values below 0.0001 are snapped to 0. Each voxel's heat capacity is recalculated from its new composition, then its temperature is recovered from `newTemperature = (oldTotalHeatCapacity * oldEffectiveTemperature + energyDelta) / newTotalHeatCapacity`. A voxel with no heat capacity retains its stored temperature. The pressure cache is refreshed from the resulting moles and temperature before boundary processing. +4. **Apply deltas**: After all voxels have been processed, the accumulated mole deltas are applied and per-species amounts below `AtmosSolverConstants.MinimumTrackedMoles` (currently 0.0001 mol) are snapped to 0. Each voxel's heat capacity is recalculated from its new composition, then its temperature is recovered from `newTemperature = (oldTotalHeatCapacity * oldEffectiveTemperature + energyDelta) / newTotalHeatCapacity`. A voxel with no heat capacity retains its stored temperature. The pressure cache is refreshed from the resulting moles and temperature before boundary processing. -5. **Emit boundary events**: If a voxel is on the edge of the chunk (coordinate is 0 or `Size - 1`) and has pressure > 1.0, a `BoundaryFlowEvent` is emitted for cross-chunk processing. +5. **Emit boundary events**: If a voxel is on the edge of the chunk (coordinate is 0 or `Size - 1`) and has positive pressure at or above the normalized `VacuumThreshold`, a `BoundaryFlowEvent` is emitted for cross-chunk processing. ### 4.3 Phase 2 — Cross-Chunk Boundary Flow @@ -304,16 +319,16 @@ For each boundary event: 2. For each of the 6 directions, check if the neighbor coordinate is outside the chunk bounds. 3. If outside: look up the neighboring chunk at `GridPosition + direction`. 4. Map the out-of-bounds coordinate into the neighbor's local space using modular arithmetic: `nX = (targetX + neighborWidth) % neighborWidth`. -5. If the neighbor voxel is solid, skip. If the neighbor chunk is asleep, wake the target room. -6. Calculate pressure delta and flow with the same `CalculateFlow` logic used by intra-chunk advection, including damping, snap, minimum-flow cutoff, and the CFL cap. -7. For each source species, combine bulk advection with the same positive partial-pressure diffusion term used inside a chunk: +5. If the neighbor voxel is solid, skip. +6. Calculate any outward bulk pressure transfer with the same limiter used by intra-chunk advection, including damping, the low-delta branch, minimum-transfer cutoff, and the per-neighbor cap. A sleeping target is woken only if a positive mole transfer will actually be injected. +7. For each source species, combine bulk advection with the same positive partial-pressure diffusion term used inside a chunk. Diffusion is evaluated even when bulk flow is zero or points in the opposite direction: ``` - molesAdvected = (flow / sourceEffectiveTemperature) * moleFraction + molesAdvected = (flow * VoxelVolume / (R * sourceEffectiveTemperature)) * moleFraction deltaN = sourceMoles - neighborMoles * (neighborEffectiveTemperature / sourceEffectiveTemperature) molesDiffused = DiffusionCoefficient > 0 ? max(0, deltaN * DiffusionCoefficient) : 0 molesMoved = min(sourceMoles, molesAdvected + molesDiffused) ``` - For a void target, neighbor moles and temperature are treated as zero. An unregistered gas uses a diffusion coefficient of `0.02`. + For a void target, neighbor moles and temperature are treated as zero. An unregistered gas uses `DefaultDiffusionCoefficient`. 8. Transfer the capped moles directly (no delta buffer — this is sequential). Each species carries `molesMoved * c_effective * sourceEffectiveTemperature` of sensible energy during the direct transfer. The source and target heat-capacity caches, temperatures, and pressures are updated immediately by energy balance. Before injection, the target voxel's existing heat capacity is recalculated from its current moles and the live gas registry, including for a target chunk that was sleeping before the transfer. @@ -326,12 +341,12 @@ Thermodynamics runs at half frequency (every 2nd tick) to save computation. Exec 1. In parallel for each awake chunk, solve intra-chunk thermal diffusion and queue thermal-boundary events. 2. Still within that per-chunk pass, process phase changes using the post-diffusion voxel state. -3. After all parallel chunk work completes, process thermal-boundary events sequentially. Boundary handling recalculates current pressure, heat capacity, and effective temperature, so it uses post-phase-change state rather than the temperature captured when the event was queued. +3. After all parallel chunk work completes, deduplicate cross-chunk faces and solve them from one immutable boundary snapshot. Boundary handling recalculates current pressure, heat capacity, and effective temperature, so it uses post-phase-change state. **Intra-Chunk Thermal Diffusion**: Adjacent non-vacuum voxels exchange energy according to their temperature difference and total heat capacities. Intra-chunk diffusion uses a two-pass solve from one immutable temperature and heat-capacity snapshot. Each undirected edge `(i, j)` is visited once, and its pair conductance is: ``` -g_ij = min(ThermalConductivity, C_i * C_j / (C_i + C_j)) +g_ij = min(ThermalConductance, C_i * C_j / (C_i + C_j)) G_i = sum(g_ij for every edge incident to i) s_ij = min(1, C_i / G_i, C_j / G_j) Q_ij = s_ij * g_ij * (T_i - T_j) @@ -341,7 +356,7 @@ The first pass accumulates each voxel's incident conductance `G`; the second rec **Phase Changes (Condensation)**: See §8. These run after intra-chunk thermal temperatures have been applied and before thermal-boundary events are drained. -**Cross-Chunk Thermal Diffusion**: Thermal boundary events are applied sequentially after the parallel per-chunk pass. For a mapped hot/cold pair they transfer the minimum of `ThermalConductivity * (T_h - T_c)`, the pair-equilibrium energy `(T_h - T_c) / (1 / C_h + 1 / C_c)`, and the hot voxel's available sensible energy. Solid voxels block conduction, voxels below `VacuumThreshold` are excluded, and a missing adjacent chunk receives no heat. Depth-one chunks do not conduct through their Z faces. Unlike gas boundary flow, thermal transfer can update a sleeping neighbor's cached temperature without waking that chunk. Because these transfers update current state immediately, their result can depend on sequential event order when a voxel participates in multiple cross-chunk edges. +**Cross-Chunk Thermal Diffusion**: Boundary faces are deduplicated, their post-phase-change temperatures and heat capacities are snapshotted, and the same `g`, `G`, `s`, and `Q` equations are applied across the entire boundary set. Equal-and-opposite energy deltas are buffered before any boundary temperature is written, eliminating concurrent-queue traversal bias. Solid voxels block conduction, voxels below `VacuumThreshold` are excluded, and a missing adjacent chunk receives no heat. Depth-one chunks do not conduct through their Z faces. Thermal transfer can update a sleeping neighbor without waking it. --- @@ -349,30 +364,30 @@ The first pass accumulates each voxel's incident conductance `G`; the second rec The advection loop is a first-order explicit cellular automaton, which is inherently prone to oscillation ("ringing") if flow per tick exceeds stability limits. The system employs several interlocking mechanisms to ensure convergence. -### 5.1 CFL Flow Cap +### 5.1 Per-Neighbor Bulk-Flow Cap -The CFL (Courant–Friedrichs–Lewy) cap limits the bulk pressure-flow candidate from a source voxel to one neighbor: +The per-neighbor cap limits the bulk pressure-transfer candidate from a source voxel to one neighbor: ``` -bulkFlowPerNeighbor ≤ currentPressure * CflFlowCap +bulkFlowPerNeighbor ≤ currentPressure * MaxPressureTransferFractionPerNeighbor ``` -With the default `CflFlowCap = 0.16 ≈ 1/6`, the six bulk-flow candidates in a 3D neighborhood total at most about 96% of the source pressure. This bound applies only to bulk advection: the Fickian term is added afterward and can make the combined requested species outflow exceed that amount. +With the default `MaxPressureTransferFractionPerNeighbor = 0.16 ≈ 1/6`, the six bulk-flow candidates in a 3D neighborhood total at most about 96% of the source pressure. This is a local inventory/stability heuristic, not a formal CFL number because the model does not track wave speed or cell length. The bound applies only to bulk advection: the Fickian term is added afterward and can make the combined requested species outflow exceed that amount. A separate gas-major `scheduledOutflows` buffer provides the actual inventory protection. For each gas and source voxel, every neighbor transfer is capped to `sourceMoles - alreadyScheduledOutflow`, so aggregate scheduled outflow cannot exceed the moles present at the start of the pass. Neighbors are checked in fixed `-X`, `+X`, `-Y`, `+Y`, then (for 3D) `-Z`, `+Z` order. If requests exhaust the inventory, later directions receive only the remainder, so the safety cap can introduce directional allocation bias under saturation. -Design note: a naive bulk cap of 0.5 is unstable for more than 2 neighbors (0.5 * 6 = 3.0 > 1.0); 0.16 (≈1/6) keeps the bulk component within the 3D CFL limit, while `scheduledOutflows` enforces the final mole bound after diffusion is included. +Design note: a naive bulk cap of 0.5 is unstable for more than 2 neighbors (0.5 * 6 = 3.0 > 1.0); 0.16 (≈1/6) keeps the six 3D bulk candidates below one source-pressure inventory, while `scheduledOutflows` enforces the final mole bound after diffusion is included. -### 5.2 Damping & Snap-to-Equilibrium +### 5.2 Damping & Low-Delta Regime Two regimes are used depending on the magnitude of the pressure delta: -- **Large delta** (`pressureDelta ≥ SnapThreshold`): `flow = pressureDelta * FlowFriction * DampingFactor`. The `DampingFactor` (0.5) reduces the effective flow rate to kill ringing in high-energy scenarios. -- **Small delta** (`pressureDelta < SnapThreshold`): `flow = pressureDelta * CflFlowCap`. This bypasses the friction model entirely and snaps the voxel toward equilibrium at the maximum stable rate. +- **Large delta** (`pressureDelta ≥ LowPressureDeltaThreshold`): `flow = pressureDelta * BulkFlowCoefficient * BulkFlowDamping`. The `BulkFlowDamping` (0.5) reduces the effective flow rate to kill ringing in high-energy scenarios. +- **Small delta** (`pressureDelta < LowPressureDeltaThreshold`): `flow = pressureDelta * MaxPressureTransferFractionPerNeighbor`. This bypasses the friction model and uses the configured low-delta fraction directly. -### 5.3 Minimum Flow Cutoff (Stiction) +### 5.3 Minimum Pressure Transfer (Stiction) -Flows below `MinFlowCutoff` (0.1) are discarded entirely. This prevents infinitesimal flows from keeping a chunk awake indefinitely and accelerates convergence by eliminating micro-oscillations. +Flows below `MinimumPressureTransfer` (0.1) are discarded entirely. This prevents infinitesimal flows from keeping a chunk awake indefinitely and accelerates convergence by eliminating micro-oscillations. ### 5.4 Vacuum Cleanup @@ -449,9 +464,9 @@ EvaluateState(currentPressureDelta, wakeThreshold) → Hold | Diffuse | Inject After either `Diffuse` or `Inject`, the accumulator is reset. Unit tests confirm: -- A 0.5 kPa delta holds. -- A 5.0 kPa delta after 20 ticks diffuses to the macro layer. -- A 150.0 kPa spike injects immediately. +- A 0.5 Pa delta holds. +- A 5.0 Pa delta after 20 ticks diffuses to the macro layer. +- A 150.0 Pa spike injects immediately. > [!IMPORTANT] > As with the `RoomNode`, the `GasAccumulator` is **fully implemented as a data structure** but is **not wired into the simulation loop**. `AtmosSimulation` does not reference it. An integrator must build the orchestration that feeds gas sources into accumulators and dispatches the resulting `Diffuse` or `Inject` actions. The unit tests for `GasAccumulator` test the struct in isolation. @@ -470,38 +485,42 @@ T_effective = storedTemperature > 0 && isFinite(storedTemperature) : DefaultTemperatureFallback ``` -Where `P_reference = 1000.0` (a reference pressure scale, not atmospheric pressure). +`SaturationReferencePressure` defaults to one standard atmosphere (`101325 Pa`) and is the pressure at which the configured `BoilingPoint` applies. -For a registered species, phase-change processing first requires `CondensationPoint > 0`, more than `0.01` moles in the voxel, and a positive effective temperature. Condensation then occurs when partial pressure exceeds saturation: +For a registered species, phase-change processing first requires `CondensationEnabled`, more than `0.01` moles in the voxel, and a positive effective temperature. Condensation then occurs when partial pressure exceeds saturation: ``` -if gasIsRegistered && CondensationPoint > 0 && gasMoles > 0.01 && T_effective > 0: - P_sat = P_reference * exp(-LatentHeat * (1/T_effective - 1/T_boiling)) - currentPartialPressure = gasMoles * T_effective +if gasIsRegistered && CondensationEnabled && gasMoles > 0.01 && T_effective > 0: + P_sat = SaturationReferencePressure + * exp(-(MolarEnthalpyOfVaporization / R) * (1/T_effective - 1/T_boiling)) + currentPartialPressure = gasMoles * R * T_effective / VoxelVolume if currentPartialPressure > P_sat: excessPressure = currentPartialPressure - P_sat - requestedMoles = (excessPressure / T_effective) * CondensationRateFactor + requestedMoles = (excessPressure * VoxelVolume / (R * T_effective)) + * CondensationRateFactor molesToCondense = min(gasMoles, requestedMoles) ``` -`CondensationPoint` is a boolean enablement gate; the current temperature is not compared with its numeric value. Subject to the gates above, this model allows condensation at any temperature where the gas is supersaturated rather than only below a fixed boiling point. Gas IDs without a registry entry are skipped because their phase-change properties are unavailable. +Dividing molar vaporization enthalpy by `R` makes the exponential dimensionless. This integrated Clausius–Clapeyron form assumes ideal vapor and approximately constant vaporization enthalpy over the modeled temperature interval. Subject to the gates above, this model allows condensation at any temperature where the gas is supersaturated rather than only below a fixed temperature. Gas IDs without a registry entry, invalid boiling points, and invalid or nonpositive vaporization enthalpies are skipped. -### 8.2 Latent-Heat Energy Balance +The approximation and its assumptions match the integrated ideal-vapor derivation summarized in [NISTIR 5321](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir5321.pdf). -Condensation removes both the condensed gas's heat capacity and the sensible energy that gas carried, then releases latent heat into the gas remaining in the voxel. Let `n_condensed` be the number of moles condensed, `c_effective` the species' effective molar heat capacity, and `C_before` the voxel's total heat capacity before condensation: +### 8.2 Phase-Change Internal-Energy Balance + +Condensation removes both the condensed gas's heat capacity and the sensible energy that gas carried. Clausius–Clapeyron uses vaporization enthalpy, but this is a constant-volume internal-energy balance, so the released energy per mole is approximated as `ΔU_vap = max(0, ΔH_vap - RT)`. Let `n_condensed` be the number of moles condensed, `c_effective` the species' effective molar `C_v`, and `C_before` the voxel's total heat capacity before condensation: ``` C_after = max(0, C_before - n_condensed * c_effective) E_after = T_effective * C_before - T_effective * n_condensed * c_effective - + n_condensed * LatentHeatOfVaporization + + n_condensed * max(0, MolarEnthalpyOfVaporization - R * T_effective) if C_after > 0: T_after = max(0, E_after / C_after) ``` -The temperature division is performed only when `C_after > 0`. The voxel's cached `TotalHeatCapacity` and `TotalPressure` are updated immediately. As elsewhere in the energy model, a non-finite or nonpositive configured `SpecificHeatCapacity` uses the normalized `DefaultSpecificHeatCapacity`. +The temperature division is performed only when `C_after > 0`. The voxel's cached `TotalHeatCapacity` and `TotalPressure` are updated immediately. As elsewhere in the energy model, a non-finite or nonpositive configured `MolarHeatCapacityAtConstantVolume` uses the normalized `DefaultMolarHeatCapacityAtConstantVolume`. -Latent heat generally warms the remaining gas, which raises saturation pressure and slows further condensation. Accounting for the condensed gas's departing sensible energy avoids assigning its energy to gas that remains in the voxel. +Phase-change energy generally warms the remaining gas, which raises saturation pressure and slows further condensation. Accounting for both the ideal-gas `pV` term and the condensed gas's departing sensible energy avoids assigning enthalpy directly to a constant-volume internal-energy state. ### Output: PrecipitationEvent @@ -510,9 +529,9 @@ Condensed gas is packaged into a `PrecipitationEvent`: ``` struct PrecipitationEvent { ushort LocalVoxelIndex; - int LiquidID; - float MolesToSpawn; - float InheritedTemp; + int LiquidId; + float CondensedMoles; + float Temperature; } ``` diff --git a/src/Numos.API/AtmosSimulation.cs b/src/Numos.API/AtmosSimulation.cs index 3e34846..cbc35e3 100644 --- a/src/Numos.API/AtmosSimulation.cs +++ b/src/Numos.API/AtmosSimulation.cs @@ -22,7 +22,7 @@ public sealed class AtmosSimulation : IDisposable /// /// Elapsed-time updates therefore use a fixed step of 1 / SimulationRate seconds. [PublicAPI] - public const float SimulationRate = AtmosKernel.SimulationRate; + public const float SimulationRate = AtmosSolverConstants.SimulationRate; private readonly int _chunkDepth; private readonly int _chunkHeight; @@ -40,7 +40,10 @@ public sealed class AtmosSimulation : IDisposable /// A chunk dimension is zero or negative, or the combined voxel count exceeds /// . /// - public AtmosSimulation(int chunkWidth = 16, int chunkHeight = 16, int chunkDepth = 16) + public AtmosSimulation( + int chunkWidth = AtmosChunkConstants.DefaultWidth, + int chunkHeight = AtmosChunkConstants.DefaultHeight, + int chunkDepth = AtmosChunkConstants.DefaultDepth) : this(new AtmosConfig(), chunkWidth, chunkHeight, chunkDepth) { } @@ -61,24 +64,30 @@ public AtmosSimulation(int chunkWidth = 16, int chunkHeight = 16, int chunkDepth /// A chunk dimension is zero or negative, or the combined voxel count exceeds /// . /// - public AtmosSimulation(AtmosConfig config, int chunkWidth = 16, int chunkHeight = 16, int chunkDepth = 16) + public AtmosSimulation( + AtmosConfig config, + int chunkWidth = AtmosChunkConstants.DefaultWidth, + int chunkHeight = AtmosChunkConstants.DefaultHeight, + int chunkDepth = AtmosChunkConstants.DefaultDepth) { ArgumentNullException.ThrowIfNull(config); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(chunkWidth); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(chunkHeight); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(chunkDepth); - if (chunkWidth > AtmosChunk.MaxVoxelCount || chunkHeight > AtmosChunk.MaxVoxelCount || - chunkDepth > AtmosChunk.MaxVoxelCount) + if (chunkWidth > AtmosChunkConstants.MaximumVoxelCount || + chunkHeight > AtmosChunkConstants.MaximumVoxelCount || + chunkDepth > AtmosChunkConstants.MaximumVoxelCount) { throw new ArgumentOutOfRangeException(nameof(chunkWidth), chunkWidth, - $"No chunk dimension may exceed {AtmosChunk.MaxVoxelCount}."); + $"No chunk dimension may exceed {AtmosChunkConstants.MaximumVoxelCount}."); } long voxelCount = (long)chunkWidth * chunkHeight * chunkDepth; - if (voxelCount > AtmosChunk.MaxVoxelCount) + if (voxelCount > AtmosChunkConstants.MaximumVoxelCount) { throw new ArgumentOutOfRangeException(nameof(chunkWidth), chunkWidth, - $"Chunk dimensions contain {voxelCount} voxels, but at most {AtmosChunk.MaxVoxelCount} are supported."); + $"Chunk dimensions contain {voxelCount} voxels, but at most " + + $"{AtmosChunkConstants.MaximumVoxelCount} are supported."); } Config = config; @@ -242,7 +251,9 @@ public void SetAtmosConfig(AtmosConfig config) /// A chunk is already registered at . /// The simulation has been disposed. [PublicAPI] - public AtmosChunkHandle CreateAndRegisterChunk(Int3 position, int maxActiveRooms = 64) + public AtmosChunkHandle CreateAndRegisterChunk( + Int3 position, + int maxActiveRooms = AtmosChunkConstants.DefaultMaxActiveRooms) { ThrowIfDisposed(); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxActiveRooms); @@ -568,11 +579,12 @@ public void SetVoxelTemperature(AtmosChunkHandle chunk, int x, int y, int z, flo /// The temperature of the added gas, in kelvins. /// /// The room classification containing the voxel is activated before injection. Injection into a solid or - /// void voxel is ignored. The added gas carries sensible energy according to its effective molar heat - /// capacity, and the stored temperature is updated by sensible-energy balance. Before blending, the heat + /// void voxel is ignored. The added gas carries sensible internal energy according to its molar heat + /// capacity at constant volume, and the stored temperature is updated by energy balance. Before blending, the + /// heat /// capacity of gas already in the voxel is recomputed from the current . A missing /// registry entry or non-finite or nonpositive configured heat capacity uses - /// . When gas is already present, a non-finite or + /// . When gas is already present, a non-finite or /// nonpositive stored temperature contributes prior sensible energy at /// . An empty voxel instead adopts the incoming /// temperature. @@ -604,11 +616,12 @@ public void AddGasToVoxel(AtmosChunkHandle chunk, ushort localVoxelIndex, int ga /// The temperature of the added gas, in kelvins. /// /// The room classification containing the voxel is activated before injection. Injection into a solid or - /// void voxel is ignored. The added gas carries sensible energy according to its effective molar heat - /// capacity, and the stored temperature is updated by sensible-energy balance. Before blending, the heat + /// void voxel is ignored. The added gas carries sensible internal energy according to its molar heat + /// capacity at constant volume, and the stored temperature is updated by energy balance. Before blending, the + /// heat /// capacity of gas already in the voxel is recomputed from the current . A missing /// registry entry or non-finite or nonpositive configured heat capacity uses - /// . When gas is already present, a non-finite or + /// . When gas is already present, a non-finite or /// nonpositive stored temperature contributes prior sensible energy at /// . An empty voxel instead adopts the incoming /// temperature. diff --git a/src/Numos.CoreSim/AtmosChunk.cs b/src/Numos.CoreSim/AtmosChunk.cs index 92265d6..3e20eea 100644 --- a/src/Numos.CoreSim/AtmosChunk.cs +++ b/src/Numos.CoreSim/AtmosChunk.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using Numos.CoreSim.Collections; +using Numos.CoreSim.Datatypes.Primitives; using Numos.CoreSim.Datatypes.Snapshots; using Numos.Maths; @@ -19,26 +20,6 @@ internal class AtmosChunk { private static long _nextGeneration; - /// - /// Largest voxel count representable by the chunk's flat indexes. - /// - internal const int MaxVoxelCount = ushort.MaxValue; - - /// - /// Indicates that a voxel has not been assigned to a room. - /// - public const int RoomUnassigned = 0; - - /// - /// Indicates that a voxel represents the space outside the simulated map. - /// - public const int RoomVoid = -1; - - /// - /// Indicates that a voxel is solid and cannot contain or exchange gas. - /// - public const int RoomSolid = -2; - /// /// Number of valid entries at the beginning of . /// @@ -132,12 +113,12 @@ internal class AtmosChunk public int SleepTimer; /// - /// Temperature value for each voxel, indexed by flat voxel index or local coordinate. + /// Temperature for each voxel, in kelvins (K), indexed by flat voxel index or local coordinate. /// public FlatArray Temperature; /// - /// Cached pressure value for each voxel, indexed by flat voxel index or local coordinate. + /// Cached pressure for each voxel, in pascals (Pa), indexed by flat voxel index or local coordinate. /// /// These values are recomputed by the simulation each tick. public FlatArray TotalPressure; @@ -163,12 +144,13 @@ internal class AtmosChunk /// /// /// Positive IDs identify rooms. The reserved values - /// , , and + /// , , and + /// /// identify unassigned, void, and solid voxels respectively. /// - /// - /// - /// + /// + /// + /// public FlatArray VoxelRoomMap; /// @@ -179,9 +161,14 @@ internal class AtmosChunk /// The number of voxels along the z axis. /// The maximum number of rooms that can be active at once. /// - /// A dimension is non-positive or the combined voxel count exceeds . + /// A dimension is non-positive or the combined voxel count exceeds + /// . /// - public AtmosChunk(int width = 16, int height = 16, int depth = 16, int maxActiveRooms = 64) + public AtmosChunk( + int width = AtmosChunkConstants.DefaultWidth, + int height = AtmosChunkConstants.DefaultHeight, + int depth = AtmosChunkConstants.DefaultDepth, + int maxActiveRooms = AtmosChunkConstants.DefaultMaxActiveRooms) { int voxelCount = GetValidatedVoxelCount(width, height, depth); MaxActiveRooms = maxActiveRooms; @@ -211,7 +198,7 @@ public void EnsureInitialized() EnsureInitialized(ref TotalHeatCapacity, dimensions); EnsureInitialized(ref Temperature, dimensions); if (ActiveGases == null) - ActiveGases = new GasChannel[16]; // TODO unhardcode maxgases + ActiveGases = new GasChannel[AtmosChunkConstants.MaximumGasChannelsPerChunk]; if (ActiveRoomIds == null || ActiveRoomIds.Length != MaxActiveRooms) ActiveRoomIds = new int[MaxActiveRooms]; } @@ -225,14 +212,20 @@ public void EnsureInitialized() /// The depth of the chunk. /// The maximum number of rooms that can be active in this chunk simultaneously. /// - /// A dimension is non-positive or the combined voxel count exceeds . + /// A dimension is non-positive or the combined voxel count exceeds + /// . /// /// /// Initialization puts the chunk to sleep, resets all active counts and timers, and clears /// its per-voxel, gas-channel, and active-room data. /// [PublicAPI] - public void Initialize(Int3 position, int width = 16, int height = 16, int depth = 16, int maxActiveRooms = 64) + public void Initialize( + Int3 position, + int width = AtmosChunkConstants.DefaultWidth, + int height = AtmosChunkConstants.DefaultHeight, + int depth = AtmosChunkConstants.DefaultDepth, + int maxActiveRooms = AtmosChunkConstants.DefaultMaxActiveRooms) { int voxelCount = GetValidatedVoxelCount(width, height, depth); GridPosition = position; @@ -298,7 +291,7 @@ public void Release() /// Thrown when would exceed . public virtual void WakeRoom(int targetRoomId) { - if (targetRoomId == RoomSolid || targetRoomId == RoomVoid) + if (targetRoomId == VoxelClassification.RoomSolid || targetRoomId == VoxelClassification.RoomVoid) return; if (IsAwake) @@ -367,48 +360,32 @@ public virtual void Sleep() } /// - /// Adds gas to a voxel with unit effective molar heat capacity and updates the voxel's cached thermal state. + /// Adds gas to a voxel and updates pressure with the supplied ideal-gas pressure coefficient. /// /// The flat index of the target voxel within this chunk. /// The ID of the gas to add. /// The number of moles to add. - /// The temperature of the injected gas. - /// - /// Injection is ignored when the chunk is sleeping or the target voxel is solid or void. - /// A new gas channel is created when this gas is not already present in the chunk. - /// - public void InjectGasToVoxel(ushort localVoxelIndex, int gasId, float molesToAdd, float temperature) - { - InjectGasToVoxel(localVoxelIndex, gasId, molesToAdd, temperature, 1f); - } - - /// - /// Adds gas to a voxel using its effective molar heat capacity to conserve sensible energy. - /// - /// The flat index of the target voxel within this chunk. - /// The ID of the gas to add. - /// The number of moles to add. - /// The temperature of the injected gas. - /// - /// The already-resolved, finite, positive effective molar heat capacity of the injected gas, in J/(mol·K). + /// The temperature of the injected gas, in kelvins (K). + /// + /// The already-resolved, finite, positive molar heat capacity at constant volume, in J/(mol·K). + /// + /// + /// The already-resolved ideal-gas coefficient R/V, in Pa/(mol·K). /// - /// - /// Injection is ignored when the chunk is sleeping or the target voxel is solid or void. - /// A new gas channel is created when this gas is not already present in the chunk. - /// public void InjectGasToVoxel(ushort localVoxelIndex, int gasId, float molesToAdd, float temperature, - float effectiveSpecificHeatCapacity) + float effectiveMolarHeatCapacityAtConstantVolume, double pressurePerMoleKelvin) { - Debug.Assert(float.IsFinite(effectiveSpecificHeatCapacity) && - effectiveSpecificHeatCapacity > 0f); + Debug.Assert(float.IsFinite(effectiveMolarHeatCapacityAtConstantVolume) && + effectiveMolarHeatCapacityAtConstantVolume > 0f); + Debug.Assert(double.IsFinite(pressurePerMoleKelvin) && pressurePerMoleKelvin > 0d); if (!IsAwake) return; int room = VoxelRoomMap[localVoxelIndex]; - if (room == RoomSolid) + if (room == VoxelClassification.RoomSolid) return; - if (room == RoomVoid) + if (room == VoxelClassification.RoomVoid) return; SleepTimer = 0; @@ -447,17 +424,20 @@ public void InjectGasToVoxel(ushort localVoxelIndex, int gasId, float molesToAdd currentTotalMoles += ActiveGases[g].Moles[localVoxelIndex]; } - float incomingHeatCapacity = molesToAdd * effectiveSpecificHeatCapacity; + float incomingHeatCapacity = molesToAdd * effectiveMolarHeatCapacityAtConstantVolume; float newHeatCapacity = currentHeatCapacity + incomingHeatCapacity; float currentTemp = Temperature[localVoxelIndex]; float newTemp = currentHeatCapacity > 0f && newHeatCapacity > 0f - ? (currentHeatCapacity * currentTemp + incomingHeatCapacity * temperature) / newHeatCapacity + ? currentTemp == temperature + ? currentTemp + : (float)(((double)currentHeatCapacity * currentTemp + + (double)incomingHeatCapacity * temperature) / newHeatCapacity) : temperature; TotalHeatCapacity[localVoxelIndex] = newHeatCapacity; Temperature[localVoxelIndex] = newTemp; - TotalPressure[localVoxelIndex] = currentTotalMoles * newTemp; + TotalPressure[localVoxelIndex] = (float)(currentTotalMoles * newTemp * pressurePerMoleKelvin); MarkChanged(); } @@ -571,17 +551,19 @@ private static int GetValidatedVoxelCount(int width, int height, int depth) ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(depth); - if (width > MaxVoxelCount || height > MaxVoxelCount || depth > MaxVoxelCount) + if (width > AtmosChunkConstants.MaximumVoxelCount || height > AtmosChunkConstants.MaximumVoxelCount || + depth > AtmosChunkConstants.MaximumVoxelCount) { throw new ArgumentOutOfRangeException(nameof(width), width, - $"No chunk dimension may exceed {MaxVoxelCount}."); + $"No chunk dimension may exceed {AtmosChunkConstants.MaximumVoxelCount}."); } long voxelCount = (long)width * height * depth; - if (voxelCount > MaxVoxelCount) + if (voxelCount > AtmosChunkConstants.MaximumVoxelCount) { throw new ArgumentOutOfRangeException(nameof(width), width, - $"Chunk dimensions contain {voxelCount} voxels, but at most {MaxVoxelCount} are supported."); + $"Chunk dimensions contain {voxelCount} voxels, but at most " + + $"{AtmosChunkConstants.MaximumVoxelCount} are supported."); } return (int)voxelCount; diff --git a/src/Numos.CoreSim/AtmosChunkConstants.cs b/src/Numos.CoreSim/AtmosChunkConstants.cs new file mode 100644 index 0000000..404321a --- /dev/null +++ b/src/Numos.CoreSim/AtmosChunkConstants.cs @@ -0,0 +1,25 @@ +namespace Numos.CoreSim; + +/// +/// Canonical default dimensions and implementation limits for atmospheric chunks. +/// +public static class AtmosChunkConstants +{ + /// Default number of voxels along a chunk's x-axis. + public const int DefaultWidth = 16; + + /// Default number of voxels along a chunk's y-axis. + public const int DefaultHeight = 16; + + /// Default number of voxels along a chunk's z-axis. + public const int DefaultDepth = 16; + + /// Default maximum number of simultaneously active rooms in a chunk. + public const int DefaultMaxActiveRooms = 64; + + /// Maximum number of distinct gas channels supported by one chunk. + public const int MaximumGasChannelsPerChunk = 16; + + /// Maximum voxel count representable by the chunk's unsigned 16-bit flat indices. + public const int MaximumVoxelCount = ushort.MaxValue; +} \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosConfig.cs b/src/Numos.CoreSim/AtmosConfig.cs index 991f225..c5f05cc 100644 --- a/src/Numos.CoreSim/AtmosConfig.cs +++ b/src/Numos.CoreSim/AtmosConfig.cs @@ -11,73 +11,108 @@ public class AtmosConfig public List GasRegistry { get; set; } = []; /// - /// Reference ambient temperature. + /// Reference ambient temperature, in kelvins (K). /// - public float GlobalTemperature { get; set; } = 293.15f; + public float GlobalTemperature { get; set; } = AtmosConfigDefaults.GlobalTemperature; /// /// Effective temperature used for pressure and sensible-energy calculations when a gas-bearing voxel /// has a non-finite or nonpositive stored temperature. /// /// - /// This value must be finite and positive; the simulation does not normalize an invalid configured - /// temperature fallback. + /// Non-finite and nonpositive values are normalized to + /// by the simulation. /// Energy evolution uses this value as the voxel's starting temperature, then stores the resulting /// blended or transferred temperature. /// - public float DefaultTemperatureFallback { get; set; } = 293.15f; + public float DefaultTemperatureFallback { get; set; } = AtmosConfigDefaults.DefaultTemperatureFallback; /// - /// Effective molar heat capacity used when a gas is not registered or its configured - /// is non-finite or nonpositive, in joules per + /// Molar heat capacity at constant volume used when a gas is not registered or its configured + /// is non-finite or nonpositive, in joules per /// mole-kelvin (J/(mol·K)). /// /// - /// Non-finite and nonpositive fallback values are normalized to 1 J/(mol·K) by the simulation. + /// Non-finite and nonpositive fallback values are normalized to the ideal-diatomic value + /// 5R/2 by the simulation. /// - public float DefaultSpecificHeatCapacity { get; set; } = 1f; + public float DefaultMolarHeatCapacityAtConstantVolume { get; set; } = + AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; /// - /// Default temperature of space. + /// Physical volume represented by one voxel, in cubic metres (m³). /// - public float SpaceTemperature { get; set; } = 2.7f; + /// + /// Numos calculates pressure in pascals from P = nRT/V. Non-finite and nonpositive values are + /// normalized to 1 m³ by the simulation. + /// + public float VoxelVolume { get; set; } = AtmosConfigDefaults.VoxelVolume; + + /// + /// Saturation pressure associated with , in pascals (Pa). + /// + /// + /// The default is one standard atmosphere. Non-finite and nonpositive values are normalized to that + /// default by the phase-change solver. + /// + public float SaturationReferencePressure { get; set; } = + AtmosConfigDefaults.SaturationReferencePressure; /// - /// Fraction of pressure delta converted to flow per tick. + /// Per-tick Fickian mixing fraction used for gas IDs missing from . /// - public float FlowFriction { get; set; } = 0.25f; + /// Values are clamped to [0, 1]; non-finite values disable fallback diffusion. + public float DefaultDiffusionCoefficient { get; set; } = AtmosConfigDefaults.DefaultDiffusionCoefficient; /// - /// Multiplier applied to during large-delta advection. + /// Default temperature of space, in kelvins (K). + /// + public float SpaceTemperature { get; set; } = AtmosConfigDefaults.SpaceTemperature; + + /// + /// Dimensionless fraction of a pressure delta requested as bulk flow per simulation tick. + /// + /// Values are clamped to [0, 1]; non-finite values disable large-delta bulk flow. + public float BulkFlowCoefficient { get; set; } = AtmosConfigDefaults.BulkFlowCoefficient; + + /// + /// Multiplier applied to during large-delta advection. /// Used to reduce oscillation in the sim. /// - public float DampingFactor { get; set; } = 0.5f; + /// Values are clamped to [0, 1]; non-finite values disable large-delta bulk flow. + public float BulkFlowDamping { get; set; } = AtmosConfigDefaults.BulkFlowDamping; /// - /// Below this pressure delta, flow uses the directly - /// instead of * + /// Below this pressure delta, in pascals (Pa), flow uses + /// directly + /// instead of * /// - public float SnapThreshold { get; set; } = 5.0f; + /// Non-finite and negative values are normalized to zero. + public float LowPressureDeltaThreshold { get; set; } = AtmosConfigDefaults.LowPressureDeltaThreshold; /// - /// Flows below this magnitude are discarded. + /// Candidate pressure transfers below this magnitude, in pascals (Pa) per simulation tick, are discarded. /// - public float MinFlowCutoff { get; set; } = 0.1f; + /// Non-finite and negative values are normalized to zero. + public float MinimumPressureTransfer { get; set; } = AtmosConfigDefaults.MinimumPressureTransfer; /// - /// Below this pressure, voxel contents are zeroed out. + /// Below this pressure, in pascals (Pa), voxel contents are zeroed out. /// - public float VacuumThreshold { get; set; } = 1.0f; + /// Non-finite and negative values are normalized to zero. + public float VacuumThreshold { get; set; } = AtmosConfigDefaults.VacuumThreshold; /// /// Consecutive ticks below before a chunk goes to sleep. /// - public int SleepThreshold { get; set; } = 100; + /// Negative values are normalized to zero. + public int SleepThreshold { get; set; } = AtmosConfigDefaults.SleepThreshold; /// - /// Maximum pressure delta considered "at rest". + /// Maximum pressure delta considered "at rest", in pascals (Pa). /// - public float SleepEpsilon { get; set; } = 3.5f; + /// Non-finite and negative values are normalized to zero. + public float SleepEpsilon { get; set; } = AtmosConfigDefaults.SleepEpsilon; /// /// Effective thermal conductance between adjacent voxels, in joules per kelvin (J/K) per @@ -89,26 +124,31 @@ public class AtmosConfig /// in the solve, preventing negative temperatures and new temperature extrema. Non-finite or nonpositive /// values disable thermal diffusion. /// - public float ThermalConductivity { get; set; } = 0.05f; + public float ThermalConductance { get; set; } = AtmosConfigDefaults.ThermalConductance; /// - /// Rate multiplier for phase-change condensation. + /// Dimensionless fraction of supersaturated vapor condensed per thermodynamics tick. /// - public float CondensationRateFactor { get; set; } = 0.5f; + /// Values are clamped to [0, 1]; non-finite values disable condensation. + public float CondensationRateFactor { get; set; } = AtmosConfigDefaults.CondensationRateFactor; /// /// Maximum fraction of a source voxel's pressure used by the bulk-advection term for one neighbor per tick. /// - /// Passive Fickian diffusion is calculated separately and is not capped by this value. - public float CflFlowCap { get; set; } = 0.16f; + /// + /// Values are clamped to [0, 1]; non-finite values disable bulk flow. Passive Fickian diffusion is + /// calculated separately and is not capped by this value. + /// + public float MaxPressureTransferFractionPerNeighbor { get; set; } = + AtmosConfigDefaults.MaxPressureTransferFractionPerNeighbor; /// - /// Minimum accumulated flow or pressure activity required to wake a sleeping chunk. + /// Minimum accumulated pressure activity required to wake a sleeping chunk, in pascals (Pa). /// - public float AccumulatorWakeThreshold { get; set; } = 15.0f; + public float AccumulatorWakeThreshold { get; set; } = AtmosConfigDefaults.AccumulatorWakeThreshold; /// /// Maximum number of ticks that an accumulated activity value remains alive. /// - public int AccumulatorMaxAliveTicks { get; set; } = 20; + public int AccumulatorMaxAliveTicks { get; set; } = AtmosConfigDefaults.AccumulatorMaxAliveTicks; } \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosConfigDefaults.cs b/src/Numos.CoreSim/AtmosConfigDefaults.cs new file mode 100644 index 0000000..259d345 --- /dev/null +++ b/src/Numos.CoreSim/AtmosConfigDefaults.cs @@ -0,0 +1,69 @@ +namespace Numos.CoreSim; + +/// +/// Canonical default values used when constructing an . +/// +/// +/// These are model defaults rather than universal physical constants. Consumers can use this class when +/// resetting individual settings or constructing configuration user interfaces without duplicating literals. +/// +public static class AtmosConfigDefaults +{ + /// Default reference ambient temperature, in kelvins (K). + public const float GlobalTemperature = AtmosPhysicalConstants.RoomTemperature; + + /// Default fallback temperature, in kelvins (K). + public const float DefaultTemperatureFallback = AtmosPhysicalConstants.RoomTemperature; + + /// Default molar heat capacity at constant volume, in joules per mole-kelvin (J/(mol·K)). + public const float DefaultMolarHeatCapacityAtConstantVolume = + AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume; + + /// Default physical volume represented by one voxel, in cubic metres (m³). + public const float VoxelVolume = 1f; + + /// Default saturation-pressure reference, in pascals (Pa). + public const float SaturationReferencePressure = AtmosPhysicalConstants.StandardAtmosphericPressure; + + /// Default per-tick diffusion fraction for unregistered gas IDs. + public const float DefaultDiffusionCoefficient = 0.02f; + + /// Default modeled temperature of space, in kelvins (K). + public const float SpaceTemperature = 2.7f; + + /// Default fraction of a pressure delta requested as bulk flow per tick. + public const float BulkFlowCoefficient = 0.25f; + + /// Default large-delta bulk-flow damping multiplier. + public const float BulkFlowDamping = 0.5f; + + /// Default pressure-delta boundary between low- and large-delta flow, in pascals (Pa). + public const float LowPressureDeltaThreshold = 5f; + + /// Default minimum candidate pressure transfer, in pascals per tick (Pa/tick). + public const float MinimumPressureTransfer = 0.1f; + + /// Default pressure below which a voxel is treated as vacuum, in pascals (Pa). + public const float VacuumThreshold = 1f; + + /// Default consecutive quiet ticks required before a chunk sleeps. + public const int SleepThreshold = 100; + + /// Default maximum pressure delta considered at rest, in pascals (Pa). + public const float SleepEpsilon = 3.5f; + + /// Default effective per-face thermal conductance, in joules per kelvin per thermodynamics tick. + public const float ThermalConductance = 0.05f; + + /// Default fraction of supersaturated vapor condensed per thermodynamics tick. + public const float CondensationRateFactor = 0.5f; + + /// Default maximum source-pressure fraction transferred to one neighbor per tick. + public const float MaxPressureTransferFractionPerNeighbor = 0.16f; + + /// Default accumulated pressure activity required to wake a sleeping chunk, in pascals (Pa). + public const float AccumulatorWakeThreshold = 15f; + + /// Default maximum lifetime of accumulated activity, in ticks. + public const int AccumulatorMaxAliveTicks = 20; +} \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosKernel.API.cs b/src/Numos.CoreSim/AtmosKernel.API.cs index 6f6ab00..19badb0 100644 --- a/src/Numos.CoreSim/AtmosKernel.API.cs +++ b/src/Numos.CoreSim/AtmosKernel.API.cs @@ -59,9 +59,10 @@ internal bool TryGetChunkPositions( /// /// Elapsed real time, in seconds, since the previous update. /// - /// The kernel runs at . At most five ticks are processed by one call; - /// excess accumulated time is discarded to prevent an unbounded catch-up loop. Values smaller than - /// one fixed step remain in the accumulator for a later call. + /// The kernel runs at . At most + /// ticks are processed by one call; excess + /// accumulated time is discarded to prevent an unbounded catch-up loop. Values smaller than one fixed + /// step remain in the accumulator for a later call. /// internal void Update(float elapsedSeconds) { @@ -69,9 +70,10 @@ internal void Update(float elapsedSeconds) { _accumulator += elapsedSeconds; - if (_accumulator > FixedDt * MaxStepsPerFrame) + if (_accumulator > AtmosSolverConstants.FixedTimeStep * AtmosSolverConstants.MaximumStepsPerUpdate) { - _accumulator = FixedDt * MaxStepsPerFrame; + _accumulator = + AtmosSolverConstants.FixedTimeStep * AtmosSolverConstants.MaximumStepsPerUpdate; } LastBoundaryTicks = 0; @@ -80,9 +82,10 @@ internal void Update(float elapsedSeconds) var chunks = _chunkMap.Values.ToArray(); var steps = 0; - while (_accumulator >= FixedDt && steps < MaxStepsPerFrame) + while (_accumulator >= AtmosSolverConstants.FixedTimeStep && + steps < AtmosSolverConstants.MaximumStepsPerUpdate) { - _accumulator -= FixedDt; + _accumulator -= AtmosSolverConstants.FixedTimeStep; steps++; TickSimulation(chunks); } @@ -343,17 +346,17 @@ internal void SetChunkBoundaryClassification(Int3 position, VoxelClassification var dimensions = chunk.Dimensions; for (var z = 0; z < dimensions.Z; z++) - for (var y = 0; y < dimensions.Y; y++) - for (var x = 0; x < dimensions.X; x++) - { - bool isBoundary = - x == 0 || x == dimensions.X - 1 || - y == 0 || y == dimensions.Y - 1 || - dimensions.Z > 1 && (z == 0 || z == dimensions.Z - 1); - - if (isBoundary) - chunk.VoxelRoomMap[chunk.GetIndex(new Int3(x, y, z))] = classification.RoomId; - } + for (var y = 0; y < dimensions.Y; y++) + for (var x = 0; x < dimensions.X; x++) + { + bool isBoundary = + x == 0 || x == dimensions.X - 1 || + y == 0 || y == dimensions.Y - 1 || + dimensions.Z > 1 && (z == 0 || z == dimensions.Z - 1); + + if (isBoundary) + chunk.VoxelRoomMap[chunk.GetIndex(new Int3(x, y, z))] = classification.RoomId; + } RebuildActiveTopology(chunk); chunk.MarkChanged(); @@ -461,7 +464,7 @@ internal void AddGasToVoxel(Int3 position, ushort localVoxelIndex, int gasId, fl ValidateGasInjection(gasId, moles, temperature); chunk.WakeRoom(chunk.VoxelRoomMap[localVoxelIndex]); - InjectGasWithEnergy(chunk, localVoxelIndex, gasId, moles, temperature, GetSpecificHeatCapacity(gasId)); + InjectGasWithEnergy(chunk, localVoxelIndex, gasId, moles, temperature, GetMolarHeatCapacityAtConstantVolume(gasId)); } } diff --git a/src/Numos.CoreSim/AtmosKernel.cs b/src/Numos.CoreSim/AtmosKernel.cs index 53f4296..09df11b 100644 --- a/src/Numos.CoreSim/AtmosKernel.cs +++ b/src/Numos.CoreSim/AtmosKernel.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using Numos.CoreSim.Datatypes.Events; +using Numos.CoreSim.Datatypes.Primitives; using Numos.Maths; namespace Numos.CoreSim; @@ -11,14 +12,9 @@ namespace Numos.CoreSim; /// internal sealed partial class AtmosKernel : IDisposable { - /// - /// The number of fixed simulation ticks processed per simulated second. - /// - internal const float SimulationRate = 20.0f; - private const float FixedDt = 1.0f / SimulationRate; - private const int MaxStepsPerFrame = 5; - private readonly ThreadLocal _boundaryBufferPool; + private readonly ConcurrentQueue<(Int3 Key, BoundaryFlowEvent Evt)> _boundaryEvents = new(); + private readonly List<(Int3 Key, BoundaryFlowEvent Evt)> _orderedBoundaryEvents = []; // Map of GridPosition to Chunk for neighbor lookups private readonly ConcurrentDictionary _chunkMap = new(); @@ -27,7 +23,14 @@ internal sealed partial class AtmosKernel : IDisposable private readonly int _maxBoundaryEvents; private readonly ThreadLocal _precipBufferPool; private readonly object _stateGate = new(); + private readonly List _activeThermalBoundaryEdges = []; + private readonly Dictionary _thermalBoundaryEnergyDeltas = []; + private readonly HashSet _thermalBoundaryEdges = []; private readonly ThreadLocal _thermalBoundaryBufferPool; + private readonly ConcurrentQueue<(Int3 Key, ThermalBoundaryEvent Evt)> _thermalBoundaryEvents = new(); + private readonly Dictionary _thermalBoundaryIncidentConductances = []; + private readonly List _thermalBoundaryOrderedEdges = []; + private readonly Dictionary _thermalBoundaryStates = []; /// /// High-resolution timestamp ticks spent processing boundary flow since the latest elapsed-time update began. @@ -54,7 +57,10 @@ internal sealed partial class AtmosKernel : IDisposable /// The number of voxels along each chunk's local x-axis. /// The number of voxels along each chunk's local y-axis. /// The number of voxels along each chunk's local z-axis. - internal AtmosKernel(int chunkWidth = 16, int chunkHeight = 16, int chunkDepth = 16) + internal AtmosKernel( + int chunkWidth = AtmosChunkConstants.DefaultWidth, + int chunkHeight = AtmosChunkConstants.DefaultHeight, + int chunkDepth = AtmosChunkConstants.DefaultDepth) { TickCount = 0; _maxBoundaryEvents = checked(2 * @@ -96,8 +102,6 @@ private void TickSimulation(AtmosChunk[] chunks) } // 1. Parallel Advection & Fickian Diffusion - // TODO PERF reuse queue - var boundaryEvents = new ConcurrentQueue<(Int3 Key, BoundaryFlowEvent Evt)>(); Parallel.ForEach(chunks, chunk => { @@ -112,25 +116,26 @@ private void TickSimulation(AtmosChunk[] chunks) for (var i = 0; i < boundaryCount; i++) { - boundaryEvents.Enqueue((chunk.GridPosition, localBoundaryBuffer[i])); + _boundaryEvents.Enqueue((chunk.GridPosition, localBoundaryBuffer[i])); } }); // 2. Sequential Boundary Processing long boundaryFlowStart = Stopwatch.GetTimestamp(); - foreach (var (key, evt) in boundaryEvents) + _orderedBoundaryEvents.Clear(); + while (_boundaryEvents.TryDequeue(out var boundaryEvent)) + _orderedBoundaryEvents.Add(boundaryEvent); + _orderedBoundaryEvents.Sort(CompareBoundaryEvents); + foreach (var (key, evt) in _orderedBoundaryEvents) { ProcessBoundaryFlow(key, evt); } LastBoundaryTicks += Stopwatch.GetTimestamp() - boundaryFlowStart; - // 3. Parallel Thermodynamics & Clausius-Clapeyron Condensation (Run every 2nd tick) - if (TickCount % 2 == 0) + // 3. Parallel Thermodynamics & Clausius-Clapeyron condensation. + if (TickCount % AtmosSolverConstants.ThermodynamicsTickInterval == 0) { - // TODO PERF reuse queue - var thermalBoundaryEvents = new ConcurrentQueue<(Int3 Key, ThermalBoundaryEvent Evt)>(); - Parallel.ForEach(chunks, chunk => { if (!chunk.IsAwake) @@ -149,15 +154,12 @@ private void TickSimulation(AtmosChunk[] chunks) for (var i = 0; i < thermalBoundaryCount; i++) { - thermalBoundaryEvents.Enqueue((chunk.GridPosition, localThermalBuffer[i])); + _thermalBoundaryEvents.Enqueue((chunk.GridPosition, localThermalBuffer[i])); } }); - // 4. Sequential Thermal Boundary Processing - foreach (var (key, evt) in thermalBoundaryEvents) - { - ProcessThermalBoundaryFlow(key, evt); - } + // 4. Boundary thermodynamics uses the same simultaneous conservative solve as intra-chunk edges. + ProcessThermalBoundaryFlows(_thermalBoundaryEvents); } } @@ -167,8 +169,7 @@ private void TickSimulation(AtmosChunk[] chunks) /// /// The grid position of the source chunk. /// - /// The boundary flow event containing the local voxel index - /// and pressure/temperature data. + /// The boundary flow event containing the local voxel index. /// private void ProcessBoundaryFlow(Int3 sourceKey, BoundaryFlowEvent evt) { @@ -218,19 +219,9 @@ private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, ushort neighborIdx = neighborChunk.GetIndex(neighborLocalPosition); // If we're up against a solid wall in the neighbor chunk then oh well. - if (neighborChunk.VoxelRoomMap[neighborIdx] == AtmosChunk.RoomSolid) + if (neighborChunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomSolid) return; - if (!neighborChunk.IsAwake) - { - int roomToWake = neighborChunk.VoxelRoomMap[neighborIdx]; - if (roomToWake != AtmosChunk.RoomSolid && roomToWake != AtmosChunk.RoomVoid) - { - // wake up buddy you're the president now - neighborChunk.WakeRoom(roomToWake); - } - } - // Calculate the source voxel index in the source chunk, which is the voxel adjacent to the neighbor. var sourceLocalPosition = targetPosition - direction; ushort srcIdx = sourceChunk.GetIndex(sourceLocalPosition); @@ -240,104 +231,102 @@ private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, // TODO remove code dupe with CheckNeighborAdvect, // but this is a special case for boundary flow where we don't have the neighbor's pressure pre-calculated. - if (neighborChunk.VoxelRoomMap[neighborIdx] != AtmosChunk.RoomVoid) + if (neighborChunk.VoxelRoomMap[neighborIdx] != VoxelClassification.RoomVoid) { neighborPressure = neighborChunk.TotalPressure[neighborIdx]; } float pressureDelta = sourcePressure - neighborPressure; + float bulkPressureTransfer = pressureDelta > 0f + ? CalculateBulkPressureTransfer(pressureDelta, sourcePressure) + : 0f; - if (pressureDelta > 0) + // Species diffusion is independent of the total-pressure gradient and may counterflow against advection. + // Cross-chunk advection intentionally uses the same pressure-transfer limiter as intra-chunk advection. + var totalMoles = 0f; + for (var g = 0; g < sourceChunk.ActiveGasCount; g++) + totalMoles += sourceChunk.ActiveGases[g].Moles[srcIdx]; + + if (totalMoles > 0) { - // TODO DOCS update, legacy docs say that an incorrect simpler flow formula is used here however - // the same seems to be used at least for the CFL flow cap. - float flow = CalculateFlow(pressureDelta, sourcePressure); - if (flow == 0f) - return; + float temp = GetEffectiveTemperature(sourceChunk.Temperature[srcIdx]); + float invTemp = 1f / temp; + float advectedMoles = PressureToMoles(bulkPressureTransfer, temp); - var totalMoles = 0f; - for (var g = 0; g < sourceChunk.ActiveGasCount; g++) - totalMoles += sourceChunk.ActiveGases[g].Moles[srcIdx]; + bool isVoid = neighborChunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomVoid; + float neighborTemp = isVoid ? 0f : GetEffectiveTemperature(neighborChunk.Temperature[neighborIdx]); + float tempRatio = neighborTemp * invTemp; - if (totalMoles > 0) - { - float temp = GetEffectiveTemperature(sourceChunk.Temperature[srcIdx]); - float invTemp = 1f / temp; + var movedGas = false; - bool isVoid = neighborChunk.VoxelRoomMap[neighborIdx] == AtmosChunk.RoomVoid; - float neighborTemp = isVoid ? 0f : GetEffectiveTemperature(neighborChunk.Temperature[neighborIdx]); - float tempRatio = neighborTemp * invTemp; + for (var g = 0; g < sourceChunk.ActiveGasCount; g++) + { + int gasId = sourceChunk.ActiveGases[g].GasId; + float moles = sourceChunk.ActiveGases[g].Moles[srcIdx]; + float moleFraction = moles / totalMoles; - var gasRegistry = _config.GasRegistry; - var movedGas = false; + // 1. Bulk Flow (Advection) + float molesAdvected = advectedMoles * moleFraction; - for (var g = 0; g < sourceChunk.ActiveGasCount; g++) + // 2. Fickian Partial Pressure Diffusion + var neighborMoles = 0f; + if (!isVoid) { - int gasId = sourceChunk.ActiveGases[g].GasId; - float moles = sourceChunk.ActiveGases[g].Moles[srcIdx]; - float moleFraction = moles / totalMoles; - - // 1. Bulk Flow (Advection) - float molesAdvected = flow * invTemp * moleFraction; - - // 2. Fickian Partial Pressure Diffusion - var neighborMoles = 0f; - if (!isVoid) + for (var ng = 0; ng < neighborChunk.ActiveGasCount; ng++) { - for (var ng = 0; ng < neighborChunk.ActiveGasCount; ng++) + if (neighborChunk.ActiveGases[ng].GasId == gasId) { - if (neighborChunk.ActiveGases[ng].GasId == gasId) - { - neighborMoles = neighborChunk.ActiveGases[ng].Moles[neighborIdx]; - break; - } + neighborMoles = neighborChunk.ActiveGases[ng].Moles[neighborIdx]; + break; } } + } - float diffusionCoeff = gasId < gasRegistry.Count ? gasRegistry[gasId].DiffusionCoefficient : 0.02f; - var molesDiffused = 0f; - if (diffusionCoeff > 0) + float diffusionCoeff = GetDiffusionCoefficient(gasId); + var molesDiffused = 0f; + if (diffusionCoeff > 0) + { + float deltaN = moles - neighborMoles * tempRatio; + if (deltaN > 0) { - float deltaN = moles - neighborMoles * tempRatio; - if (deltaN > 0) - { - molesDiffused = deltaN * diffusionCoeff; - } + molesDiffused = deltaN * diffusionCoeff; } + } - float totalMolesToMove = molesAdvected + molesDiffused; - if (totalMolesToMove > moles) - totalMolesToMove = moles; - if (totalMolesToMove <= 0f) - continue; + float totalMolesToMove = molesAdvected + molesDiffused; + if (totalMolesToMove > moles) + totalMolesToMove = moles; + if (totalMolesToMove <= 0f) + continue; - float specificHeatCapacity = GetSpecificHeatCapacity(gasId); - float heatCapacityTransferred = totalMolesToMove * specificHeatCapacity; + float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + float heatCapacityTransferred = totalMolesToMove * molarHeatCapacityAtConstantVolume; - sourceChunk.ActiveGases[g].Moles[srcIdx] -= totalMolesToMove; - if (sourceChunk.ActiveGases[g].Moles[srcIdx] < 0) - sourceChunk.ActiveGases[g].Moles[srcIdx] = 0; - sourceChunk.TotalHeatCapacity[srcIdx] = - MathF.Max(0f, sourceChunk.TotalHeatCapacity[srcIdx] - heatCapacityTransferred); - movedGas = true; + sourceChunk.ActiveGases[g].Moles[srcIdx] -= totalMolesToMove; + if (sourceChunk.ActiveGases[g].Moles[srcIdx] < 0) + sourceChunk.ActiveGases[g].Moles[srcIdx] = 0; + sourceChunk.TotalHeatCapacity[srcIdx] = + MathF.Max(0f, sourceChunk.TotalHeatCapacity[srcIdx] - heatCapacityTransferred); + movedGas = true; - if (!isVoid) - { - InjectGasWithEnergy(neighborChunk, neighborIdx, gasId, totalMolesToMove, temp, - specificHeatCapacity); - } + if (!isVoid) + { + if (!neighborChunk.IsAwake) + neighborChunk.WakeRoom(neighborChunk.VoxelRoomMap[neighborIdx]); + InjectGasWithEnergy(neighborChunk, neighborIdx, gasId, totalMolesToMove, temp, + molarHeatCapacityAtConstantVolume); } + } - if (movedGas) - { - var remainingMoles = 0f; - for (var g = 0; g < sourceChunk.ActiveGasCount; g++) - remainingMoles += sourceChunk.ActiveGases[g].Moles[srcIdx]; + if (movedGas) + { + var remainingMoles = 0f; + for (var g = 0; g < sourceChunk.ActiveGasCount; g++) + remainingMoles += sourceChunk.ActiveGases[g].Moles[srcIdx]; - if (sourceChunk.TotalHeatCapacity[srcIdx] > 0f) - sourceChunk.Temperature[srcIdx] = temp; - sourceChunk.TotalPressure[srcIdx] = remainingMoles * temp; - } + if (sourceChunk.TotalHeatCapacity[srcIdx] > 0f) + sourceChunk.Temperature[srcIdx] = temp; + sourceChunk.TotalPressure[srcIdx] = CalculatePressure(remainingMoles, temp); } } } @@ -378,7 +367,7 @@ private void Advect(AtmosChunk chunk, BoundaryFlowEvent[] boundaryBuffer, ref in float[] scheduledOutflows = ArrayPool.Shared.Rent(gasVoxelCount); Array.Clear(scheduledOutflows, 0, gasVoxelCount); - float vacuumThreshold = _config.VacuumThreshold; + float vacuumThreshold = GetNonNegativeFinite(_config.VacuumThreshold); for (var i = 0; i < chunk.ActiveAirCount; i++) { @@ -428,8 +417,8 @@ private void Advect(AtmosChunk chunk, BoundaryFlowEvent[] boundaryBuffer, ref in ref maxPressureDelta, deltas, scheduledOutflows); } - // If the current voxel is on the boundary of the chunk and has a pressure above 1.0f... - if (currentPressure > 1.0f && + // Emit only gas-bearing boundary voxels that survive this tick's vacuum cleanup. + if (currentPressure >= vacuumThreshold && currentPressure > 0f && (localPosition.X == 0 || localPosition.X == chunk.Width - 1 || localPosition.Y == 0 || @@ -442,9 +431,7 @@ private void Advect(AtmosChunk chunk, BoundaryFlowEvent[] boundaryBuffer, ref in // Queue a boundary flow event for sequential processing later. boundaryBuffer[boundaryEventCount] = new BoundaryFlowEvent { - LocalVoxelIndex = idx, - Pressure = currentPressure, - Temperature = chunk.Temperature[idx] + LocalVoxelIndex = idx }; boundaryEventCount++; } @@ -454,8 +441,8 @@ private void Advect(AtmosChunk chunk, BoundaryFlowEvent[] boundaryBuffer, ref in ArrayPool.Shared.Return(scheduledOutflows); } - float sleepEpsilon = _config.SleepEpsilon; - int sleepThreshold = _config.SleepThreshold; + float sleepEpsilon = GetNonNegativeFinite(_config.SleepEpsilon); + int sleepThreshold = Math.Max(0, _config.SleepThreshold); if (maxPressureDelta < sleepEpsilon) { @@ -505,11 +492,11 @@ private void CheckNeighborAdvect(AtmosChunk chunk, Int3 neighborPosition, ushort int neighborRoom = chunk.VoxelRoomMap[neighborIdx]; // Back out if the neighbor voxel is solid, as we cannot flow into it. - if (neighborRoom == AtmosChunk.RoomSolid) + if (neighborRoom == VoxelClassification.RoomSolid) return; var neighborPressure = 0f; - bool isVoid = neighborRoom == AtmosChunk.RoomVoid; + bool isVoid = neighborRoom == VoxelClassification.RoomVoid; if (!isVoid) { @@ -524,72 +511,69 @@ private void CheckNeighborAdvect(AtmosChunk chunk, Int3 neighborPosition, ushort if (absDelta > maxPressureDelta) maxPressureDelta = absDelta; - // If the pressure delta is positive, we have a flow from the current voxel to the neighbor. - if (pressureDelta > 0) - { - float flow = CalculateFlow(pressureDelta, currentPressure); - if (flow == 0f) - return; + if (!IsFinitePositive(totalMoles)) + return; - // Vectorized Solver Optimization: pre-calculate factors to eliminate division in loop - float temp = GetEffectiveTemperature(chunk.Temperature[idx]); - float invTemp = 1f / temp; + float bulkPressureTransfer = pressureDelta > 0f + ? CalculateBulkPressureTransfer(pressureDelta, currentPressure) + : 0f; - float flowFactor = flow * invTemp; - float neighborTemp = isVoid ? 0f : GetEffectiveTemperature(chunk.Temperature[neighborIdx]); - float tempRatio = neighborTemp * invTemp; + // Species diffusion is independent of the total-pressure gradient and may counterflow against advection. + // Pre-calculate factors to eliminate division in the species loop. + float temp = GetEffectiveTemperature(chunk.Temperature[idx]); + float invTemp = 1f / temp; - var gasRegistry = _config.GasRegistry; + float advectedMoles = PressureToMoles(bulkPressureTransfer, temp); + float neighborTemp = isVoid ? 0f : GetEffectiveTemperature(chunk.Temperature[neighborIdx]); + float tempRatio = neighborTemp * invTemp; - for (var g = 0; g < chunk.ActiveGasCount; g++) - { - int gasId = chunk.ActiveGases[g].GasId; - float moles = chunk.ActiveGases[g].Moles[idx]; - float moleFraction = moles / totalMoles; + for (var g = 0; g < chunk.ActiveGasCount; g++) + { + int gasId = chunk.ActiveGases[g].GasId; + float moles = chunk.ActiveGases[g].Moles[idx]; + float moleFraction = moles / totalMoles; - // 1. Bulk Flow (Advection) - float molesAdvected = flowFactor * moleFraction; + // 1. Bulk Flow (Advection) + float molesAdvected = advectedMoles * moleFraction; - // 2. Vectorized Fickian Partial Pressure Diffusion - float neighborMoles = isVoid ? 0f : chunk.ActiveGases[g].Moles[neighborIdx]; + // 2. Vectorized Fickian Partial Pressure Diffusion + float neighborMoles = isVoid ? 0f : chunk.ActiveGases[g].Moles[neighborIdx]; - // Retrieve coefficient (default to 0.02f if out of bounds of registry) - float diffusionCoeff = gasId < gasRegistry.Count ? gasRegistry[gasId].DiffusionCoefficient : 0.02f; + float diffusionCoeff = GetDiffusionCoefficient(gasId); - var molesDiffused = 0f; - if (diffusionCoeff > 0) + var molesDiffused = 0f; + if (diffusionCoeff > 0) + { + // Mathematically identical to J = D * (P1 - P2) / T1 = D * (n1 - n2 * T2 / T1) + float deltaN = moles - neighborMoles * tempRatio; + if (deltaN > 0) { - // Mathematically identical to J = D * (P1 - P2) / T1 = D * (n1 - n2 * T2 / T1) - float deltaN = moles - neighborMoles * tempRatio; - if (deltaN > 0) - { - molesDiffused = deltaN * diffusionCoeff; - } + molesDiffused = deltaN * diffusionCoeff; } + } - float totalMolesToMove = molesAdvected + molesDiffused; - int outflowOffset = g * chunk.VoxelCount + idx; - float remainingMoles = MathF.Max(0f, moles - scheduledOutflows[outflowOffset]); - if (totalMolesToMove > remainingMoles) - totalMolesToMove = remainingMoles; - if (totalMolesToMove <= 0f) - continue; + float totalMolesToMove = molesAdvected + molesDiffused; + int outflowOffset = g * chunk.VoxelCount + idx; + float remainingMoles = MathF.Max(0f, moles - scheduledOutflows[outflowOffset]); + if (totalMolesToMove > remainingMoles) + totalMolesToMove = remainingMoles; + if (totalMolesToMove <= 0f) + continue; - scheduledOutflows[outflowOffset] += totalMolesToMove; - float specificHeatCapacity = GetSpecificHeatCapacity(gasId); - float energyTransferred = totalMolesToMove * specificHeatCapacity * temp; + scheduledOutflows[outflowOffset] += totalMolesToMove; + float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + float energyTransferred = totalMolesToMove * molarHeatCapacityAtConstantVolume * temp; - // Update the deltas for the current voxel and the neighbor voxel. - int offset = GetDeltaArrayOffset(g, chunk.VoxelCount); - deltas[offset + idx] -= totalMolesToMove; - deltas[idx] -= energyTransferred; + // Update the deltas for the current voxel and the neighbor voxel. + int offset = GetDeltaArrayOffset(g, chunk.VoxelCount); + deltas[offset + idx] -= totalMolesToMove; + deltas[idx] -= energyTransferred; - if (!isVoid) - { - // If the neighbor is not void, we can safely add the moles to move to the neighbor's delta. - deltas[offset + neighborIdx] += totalMolesToMove; - deltas[neighborIdx] += energyTransferred; - } + if (!isVoid) + { + // If the neighbor is not void, we can safely add the moles to move to the neighbor's delta. + deltas[offset + neighborIdx] += totalMolesToMove; + deltas[neighborIdx] += energyTransferred; } } } @@ -616,8 +600,7 @@ private void CalculateTotalPressure(AtmosChunk chunk) float temp = GetEffectiveTemperature(chunk.Temperature[idx]); - // Reduced ideal gas law: P = n \cdot T. - chunk.TotalPressure[idx] = molesInVoxel * temp; + chunk.TotalPressure[idx] = CalculatePressure(molesInVoxel, temp); } } @@ -627,32 +610,72 @@ private void CalculateHeatCapacity(AtmosChunk chunk) for (var g = 0; g < chunk.ActiveGasCount; g++) { int gasId = chunk.ActiveGases[g].GasId; - float specificHeatCapacity = GetSpecificHeatCapacity(gasId); + float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); for (var i = 0; i < chunk.ActiveAirCount; i++) { ushort idx = chunk.ActiveAirIndices[i]; if (chunk.ActiveGases[g].Moles[idx] <= 0) continue; - chunk.TotalHeatCapacity[idx] += specificHeatCapacity * chunk.ActiveGases[g].Moles[idx]; + chunk.TotalHeatCapacity[idx] += molarHeatCapacityAtConstantVolume * chunk.ActiveGases[g].Moles[idx]; } } } - private float GetSpecificHeatCapacity(int gasId) + private float GetMolarHeatCapacityAtConstantVolume(int gasId) { - float fallbackSpecificHeatCapacity = _config.DefaultSpecificHeatCapacity; - if (!IsFinitePositive(fallbackSpecificHeatCapacity)) - fallbackSpecificHeatCapacity = 1f; + float fallbackMolarHeatCapacityAtConstantVolume = _config.DefaultMolarHeatCapacityAtConstantVolume; + if (!IsFinitePositive(fallbackMolarHeatCapacityAtConstantVolume)) + fallbackMolarHeatCapacityAtConstantVolume = + AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; var gasRegistry = _config.GasRegistry; if ((uint)gasId < (uint)gasRegistry.Count) { - float specificHeatCapacity = gasRegistry[gasId].SpecificHeatCapacity; - if (IsFinitePositive(specificHeatCapacity)) - return specificHeatCapacity; + float molarHeatCapacityAtConstantVolume = gasRegistry[gasId].MolarHeatCapacityAtConstantVolume; + if (IsFinitePositive(molarHeatCapacityAtConstantVolume)) + return molarHeatCapacityAtConstantVolume; } - return fallbackSpecificHeatCapacity; + return fallbackMolarHeatCapacityAtConstantVolume; + } + + private float GetDiffusionCoefficient(int gasId) + { + var gasRegistry = _config.GasRegistry; + if ((uint)gasId < (uint)gasRegistry.Count) + { + float coefficient = gasRegistry[gasId].DiffusionCoefficient; + return ClampUnitInterval(coefficient); + } + + return ClampUnitInterval(_config.DefaultDiffusionCoefficient); + } + + private float GetVoxelVolume() + { + float volume = _config.VoxelVolume; + return IsFinitePositive(volume) ? volume : AtmosConfigDefaults.VoxelVolume; + } + + private double GetPressurePerMoleKelvin() + { + return (double)AtmosPhysicalConstants.MolarGasConstant / GetVoxelVolume(); + } + + private float CalculatePressure(float moles, float temperature) + { + double pressure = Math.Max(0d, moles) * GetEffectiveTemperature(temperature) * + GetPressurePerMoleKelvin(); + return (float)pressure; + } + + private float PressureToMoles(float pressure, float temperature) + { + if (!IsFinitePositive(pressure)) + return 0f; + + double denominator = GetPressurePerMoleKelvin() * GetEffectiveTemperature(temperature); + return double.IsFinite(denominator) && denominator > 0d ? (float)(pressure / denominator) : 0f; } private float CalculateHeatCapacityAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) @@ -664,7 +687,7 @@ private float CalculateHeatCapacityAtVoxel(AtmosChunk chunk, ushort localVoxelIn if (moles <= 0f) continue; - totalHeatCapacity += moles * GetSpecificHeatCapacity(chunk.ActiveGases[g].GasId); + totalHeatCapacity += moles * GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[g].GasId); } return totalHeatCapacity; @@ -676,24 +699,26 @@ private float CalculatePressureAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) for (var g = 0; g < chunk.ActiveGasCount; g++) totalMoles += MathF.Max(0f, chunk.ActiveGases[g].Moles[localVoxelIndex]); - return totalMoles * GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]); + return CalculatePressure(totalMoles, chunk.Temperature[localVoxelIndex]); } private float GetEffectiveTemperature(float storedTemperature) { - return float.IsFinite(storedTemperature) && storedTemperature > 0f - ? storedTemperature - : _config.DefaultTemperatureFallback; + if (IsFinitePositive(storedTemperature)) + return storedTemperature; + + float fallback = _config.DefaultTemperatureFallback; + return IsFinitePositive(fallback) ? fallback : AtmosConfigDefaults.DefaultTemperatureFallback; } private void InjectGasWithEnergy(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, - float temperature, float specificHeatCapacity) + float temperature, float molarHeatCapacityAtConstantVolume) { if (!chunk.IsAwake) return; int room = chunk.VoxelRoomMap[localVoxelIndex]; - if (room == AtmosChunk.RoomSolid || room == AtmosChunk.RoomVoid) + if (room == VoxelClassification.RoomSolid || room == VoxelClassification.RoomVoid) return; chunk.TotalHeatCapacity[localVoxelIndex] = CalculateHeatCapacityAtVoxel(chunk, localVoxelIndex); @@ -701,7 +726,8 @@ private void InjectGasWithEnergy(AtmosChunk chunk, ushort localVoxelIndex, int g (!float.IsFinite(chunk.Temperature[localVoxelIndex]) || chunk.Temperature[localVoxelIndex] <= 0f)) chunk.Temperature[localVoxelIndex] = GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]); - chunk.InjectGasToVoxel(localVoxelIndex, gasId, moles, temperature, specificHeatCapacity); + chunk.InjectGasToVoxel(localVoxelIndex, gasId, moles, temperature, molarHeatCapacityAtConstantVolume, + GetPressurePerMoleKelvin()); } /// @@ -730,12 +756,12 @@ private void ApplyDeltas(AtmosChunk chunk, float[] deltas) float moleDelta = deltas[offset + idx]; stateChanged |= moleDelta != 0f; chunk.ActiveGases[g].Moles[idx] += moleDelta; - if (chunk.ActiveGases[g].Moles[idx] < 0.0001f) // TODO unhardcode mole threshold + if (chunk.ActiveGases[g].Moles[idx] < AtmosSolverConstants.MinimumTrackedMoles) chunk.ActiveGases[g].Moles[idx] = 0f; int gasId = chunk.ActiveGases[g].GasId; - float specificHeatCapacity = GetSpecificHeatCapacity(gasId); - chunk.TotalHeatCapacity[idx] += specificHeatCapacity * chunk.ActiveGases[g].Moles[idx]; + float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + chunk.TotalHeatCapacity[idx] += molarHeatCapacityAtConstantVolume * chunk.ActiveGases[g].Moles[idx]; totalMoles += chunk.ActiveGases[g].Moles[idx]; } @@ -745,7 +771,7 @@ private void ApplyDeltas(AtmosChunk chunk, float[] deltas) chunk.Temperature[idx] = MathF.Max(0f, newTemperature); } - chunk.TotalPressure[idx] = totalMoles * GetEffectiveTemperature(chunk.Temperature[idx]); + chunk.TotalPressure[idx] = CalculatePressure(totalMoles, chunk.Temperature[idx]); } ArrayPool.Shared.Return(deltas); // TODO PERF but what if..... this was threadlocal...... @@ -789,15 +815,16 @@ private void ProcessThermodynamics(AtmosChunk chunk, PrecipitationEvent[] precip private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] thermalBoundaryBuffer, ref int thermalBoundaryCount) { + float thermalConductance = _config.ThermalConductance; + float vacuumThreshold = GetNonNegativeFinite(_config.VacuumThreshold); + if (!IsFinitePositive(thermalConductance)) + return; + double[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); double[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); Array.Clear(incidentConductances, 0, chunk.VoxelCount); Array.Clear(energyDeltas, 0, chunk.VoxelCount); - float thermalConductivity = _config.ThermalConductivity; - float vacuumThreshold = _config.VacuumThreshold; - bool canDiffuse = IsFinitePositive(thermalConductivity); - for (var i = 0; i < chunk.ActiveAirCount; i++) { ushort idx = chunk.ActiveAirIndices[i]; @@ -805,20 +832,15 @@ private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] th continue; var localPosition = chunk.GetXyzInt3(idx); - float currentTemp = GetEffectiveTemperature(chunk.Temperature[idx]); - - if (canDiffuse) + // Enumerating only positive axes visits each undirected edge exactly once. + AccumulateThermalConductance(chunk, localPosition + Int3.PosX, idx, thermalConductance, + vacuumThreshold, incidentConductances); + AccumulateThermalConductance(chunk, localPosition + Int3.PosY, idx, thermalConductance, + vacuumThreshold, incidentConductances); + if (chunk.Depth > 1) { - // Enumerating only positive axes visits each undirected edge exactly once. - AccumulateThermalConductance(chunk, localPosition + Int3.PosX, idx, thermalConductivity, + AccumulateThermalConductance(chunk, localPosition + Int3.PosZ, idx, thermalConductance, vacuumThreshold, incidentConductances); - AccumulateThermalConductance(chunk, localPosition + Int3.PosY, idx, thermalConductivity, - vacuumThreshold, incidentConductances); - if (chunk.Depth > 1) - { - AccumulateThermalConductance(chunk, localPosition + Int3.PosZ, idx, thermalConductivity, - vacuumThreshold, incidentConductances); - } } // Emit thermal boundary events for edge voxels @@ -832,31 +854,27 @@ private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] th thermalBoundaryBuffer[thermalBoundaryCount] = new ThermalBoundaryEvent { - LocalVoxelIndex = idx, - Temperature = currentTemp + LocalVoxelIndex = idx }; thermalBoundaryCount++; } } - if (canDiffuse) + // Apply all fluxes from the same temperature/capacity snapshot. The symmetric row limiter + // makes every final temperature a convex combination of the snapshot temperatures. + for (var i = 0; i < chunk.ActiveAirCount; i++) { - // Apply all fluxes from the same temperature/capacity snapshot. The symmetric row limiter - // makes every final temperature a convex combination of the snapshot temperatures. - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - var localPosition = chunk.GetXyzInt3(idx); + ushort idx = chunk.ActiveAirIndices[i]; + var localPosition = chunk.GetXyzInt3(idx); - ApplyThermalFlux(chunk, localPosition + Int3.PosX, idx, thermalConductivity, - vacuumThreshold, incidentConductances, energyDeltas); - ApplyThermalFlux(chunk, localPosition + Int3.PosY, idx, thermalConductivity, + ApplyThermalFlux(chunk, localPosition + Int3.PosX, idx, thermalConductance, + vacuumThreshold, incidentConductances, energyDeltas); + ApplyThermalFlux(chunk, localPosition + Int3.PosY, idx, thermalConductance, + vacuumThreshold, incidentConductances, energyDeltas); + if (chunk.Depth > 1) + { + ApplyThermalFlux(chunk, localPosition + Int3.PosZ, idx, thermalConductance, vacuumThreshold, incidentConductances, energyDeltas); - if (chunk.Depth > 1) - { - ApplyThermalFlux(chunk, localPosition + Int3.PosZ, idx, thermalConductivity, - vacuumThreshold, incidentConductances, energyDeltas); - } } } @@ -882,13 +900,13 @@ private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] th } private void AccumulateThermalConductance(AtmosChunk chunk, Int3 neighborPosition, ushort idx, - float thermalConductivity, float vacuumThreshold, double[] incidentConductances) + float thermalConductance, float vacuumThreshold, double[] incidentConductances) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; ushort neighborIdx = chunk.GetIndex(neighborPosition); - if (chunk.VoxelRoomMap[neighborIdx] == AtmosChunk.RoomSolid) + if (chunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomSolid) return; if (!TryGetThermalState(chunk, idx, vacuumThreshold, out _, out double currentHeatCapacity) || @@ -896,7 +914,7 @@ private void AccumulateThermalConductance(AtmosChunk chunk, Int3 neighborPositio return; double conductance = CalculateThermalConductance(currentHeatCapacity, neighborHeatCapacity, - thermalConductivity); + thermalConductance); if (conductance <= 0d) return; @@ -905,13 +923,13 @@ private void AccumulateThermalConductance(AtmosChunk chunk, Int3 neighborPositio } private void ApplyThermalFlux(AtmosChunk chunk, Int3 neighborPosition, ushort idx, - float thermalConductivity, float vacuumThreshold, double[] incidentConductances, double[] energyDeltas) + float thermalConductance, float vacuumThreshold, double[] incidentConductances, double[] energyDeltas) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; ushort neighborIdx = chunk.GetIndex(neighborPosition); - if (chunk.VoxelRoomMap[neighborIdx] == AtmosChunk.RoomSolid) + if (chunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomSolid) return; if (!TryGetThermalState(chunk, idx, vacuumThreshold, out double currentTemperature, @@ -921,7 +939,7 @@ private void ApplyThermalFlux(AtmosChunk chunk, Int3 neighborPosition, ushort id return; double conductance = CalculateThermalConductance(currentHeatCapacity, neighborHeatCapacity, - thermalConductivity); + thermalConductance); double currentIncidentConductance = incidentConductances[idx]; double neighborIncidentConductance = incidentConductances[neighborIdx]; if (conductance <= 0d || !double.IsFinite(currentIncidentConductance) || @@ -959,37 +977,32 @@ private bool TryGetThermalState(AtmosChunk chunk, ushort idx, float vacuumThresh } private static double CalculateThermalConductance(double sourceHeatCapacity, double targetHeatCapacity, - float thermalConductivity) + float thermalConductance) { if (!double.IsFinite(sourceHeatCapacity) || sourceHeatCapacity <= 0d || !double.IsFinite(targetHeatCapacity) || targetHeatCapacity <= 0d || - !IsFinitePositive(thermalConductivity)) + !IsFinitePositive(thermalConductance)) return 0d; double equilibriumConductance = sourceHeatCapacity * targetHeatCapacity / (sourceHeatCapacity + targetHeatCapacity); - double conductance = Math.Min(thermalConductivity, equilibriumConductance); + double conductance = Math.Min(thermalConductance, equilibriumConductance); return double.IsFinite(conductance) && conductance > 0d ? conductance : 0d; } - private static float CalculateHeatTransfer(float temperatureDelta, float sourceHeatCapacity, - float targetHeatCapacity, float thermalConductivity, float availableSourceEnergy) + private static bool IsFinitePositive(float value) { - if (!IsFinitePositive(temperatureDelta) || !IsFinitePositive(sourceHeatCapacity) || - !IsFinitePositive(targetHeatCapacity) || !IsFinitePositive(thermalConductivity) || - !IsFinitePositive(availableSourceEnergy)) - return 0f; + return float.IsFinite(value) && value > 0f; + } - double requestedTransfer = (double)temperatureDelta * thermalConductivity; - double equilibriumTransfer = (double)temperatureDelta * sourceHeatCapacity * targetHeatCapacity / - ((double)sourceHeatCapacity + targetHeatCapacity); - double heatTransfer = Math.Min(requestedTransfer, equilibriumTransfer); - return (float)Math.Min(heatTransfer, availableSourceEnergy); + private static float ClampUnitInterval(float value) + { + return float.IsFinite(value) ? Math.Clamp(value, 0f, 1f) : 0f; } - private static bool IsFinitePositive(float value) + private static float GetNonNegativeFinite(float value) { - return float.IsFinite(value) && value > 0f; + return float.IsFinite(value) ? MathF.Max(0f, value) : 0f; } private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBuffer, ref int precipCount) @@ -997,8 +1010,12 @@ private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBu var gasRegistry = _config.GasRegistry; Debug.Assert(gasRegistry != null, nameof(gasRegistry) + " != null"); - float condensationRateFactor = _config.CondensationRateFactor; - var P_reference = 1000f; // Reference pressure scale (R = 1) + float condensationRateFactor = ClampUnitInterval(_config.CondensationRateFactor); + if (condensationRateFactor <= 0f) + return; + float referencePressure = _config.SaturationReferencePressure; + if (!IsFinitePositive(referencePressure)) + referencePressure = AtmosConfigDefaults.SaturationReferencePressure; for (var g = 0; g < chunk.ActiveGasCount; g++) { @@ -1008,11 +1025,14 @@ private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBu var props = gasRegistry[gasId]; - if (props.CondensationPoint > 0) + if (props.CondensationEnabled) { float boilingPoint = props.BoilingPoint; - float latentHeatVap = props.LatentHeatOfVaporization; - float specificHeatCapacity = GetSpecificHeatCapacity(gasId); + float molarEnthalpyOfVaporization = props.MolarEnthalpyOfVaporization; + float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + + if (!IsFinitePositive(boilingPoint) || !IsFinitePositive(molarEnthalpyOfVaporization)) + continue; float invBoilingPoint = 1f / boilingPoint; @@ -1022,21 +1042,22 @@ private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBu float currentTemp = GetEffectiveTemperature(chunk.Temperature[idx]); float gasMoles = chunk.ActiveGases[g].Moles[idx]; - if (gasMoles > 0.01f && currentTemp > 0) + if (gasMoles > AtmosSolverConstants.MinimumMolesForCondensation && currentTemp > 0f) { // Clausius-Clapeyron calculation of saturation vapor pressure: // P_sat = P_ref * exp(-L * (1/T - 1/T_boiling)) - float exponent = -latentHeatVap * (1f / currentTemp - invBoilingPoint); - float satVaporPressure = P_reference * MathF.Exp(exponent); + float exponent = -molarEnthalpyOfVaporization / AtmosPhysicalConstants.MolarGasConstant * + (1f / currentTemp - invBoilingPoint); + float satVaporPressure = referencePressure * MathF.Exp(exponent); - float currentPartialPressure = gasMoles * currentTemp; + float currentPartialPressure = CalculatePressure(gasMoles, currentTemp); if (currentPartialPressure > satVaporPressure) { float excessPressure = currentPartialPressure - satVaporPressure; - // Moles to condense: excessPressure / T - float molesToCondense = excessPressure / currentTemp * condensationRateFactor; + float molesToCondense = PressureToMoles(excessPressure, currentTemp) * + condensationRateFactor; if (molesToCondense > gasMoles) molesToCondense = gasMoles; @@ -1052,18 +1073,21 @@ private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBu precipBuffer[precipCount] = new PrecipitationEvent { LocalVoxelIndex = idx, - LiquidID = props.LiquidId, - MolesToSpawn = molesToCondense, - InheritedTemp = currentTemp + LiquidId = props.LiquidId, + CondensedMoles = molesToCondense, + Temperature = currentTemp }; precipCount++; float oldHeatCapacity = chunk.TotalHeatCapacity[idx]; - float condensedHeatCapacity = molesToCondense * specificHeatCapacity; + float condensedHeatCapacity = molesToCondense * molarHeatCapacityAtConstantVolume; float newHeatCapacity = MathF.Max(0f, oldHeatCapacity - condensedHeatCapacity); + float molarInternalEnergyOfVaporization = MathF.Max(0f, + molarEnthalpyOfVaporization - + AtmosPhysicalConstants.MolarGasConstant * currentTemp); float remainingEnergy = currentTemp * oldHeatCapacity - currentTemp * condensedHeatCapacity + - molesToCondense * latentHeatVap; + molesToCondense * molarInternalEnergyOfVaporization; chunk.TotalHeatCapacity[idx] = newHeatCapacity; if (newHeatCapacity > 0f) @@ -1078,32 +1102,43 @@ private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBu } /// - /// Calculates the flow of gas between two voxels based on the - /// pressure difference and configuration parameters. + /// Calculates the bulk-flow pressure transfer requested between two voxels. /// /// The difference in pressure between the source and target voxels. /// The current pressure of the source voxel. - /// The calculated flow value, constrained by the configuration parameters. - private float CalculateFlow(float pressureDelta, float currentPressure) + /// The requested pressure transfer in pascals per tick. + private float CalculateBulkPressureTransfer(float pressureDelta, float currentPressure) { - float flow; - // Fast snap to CFL flow cap if the pressure difference is below the snap threshold. + if (!IsFinitePositive(pressureDelta) || !IsFinitePositive(currentPressure)) + return 0f; + + float maximumFraction = ClampUnitInterval(_config.MaxPressureTransferFractionPerNeighbor); + if (maximumFraction <= 0f) + return 0f; + + float lowPressureThreshold = float.IsFinite(_config.LowPressureDeltaThreshold) + ? MathF.Max(0f, _config.LowPressureDeltaThreshold) + : 0f; + + float pressureTransfer; + // Use the configured per-neighbor fraction directly below the low-delta threshold. // Helps with equilibrium scenarios where the pressure difference is small, and we want to avoid oscillations. - // Otherwise apply flow friction and damping factor to the flow calculation. - if (pressureDelta < _config.SnapThreshold) - flow = pressureDelta * _config.CflFlowCap; + // Otherwise apply the bulk-flow coefficient and damping factor. + if (pressureDelta < lowPressureThreshold) + pressureTransfer = pressureDelta * maximumFraction; else - flow = pressureDelta * _config.FlowFriction * _config.DampingFactor; + pressureTransfer = pressureDelta * ClampUnitInterval(_config.BulkFlowCoefficient) * + ClampUnitInterval(_config.BulkFlowDamping); - // Discard flow if below the cutoff. - if (flow < _config.MinFlowCutoff) + float minimumTransfer = float.IsFinite(_config.MinimumPressureTransfer) + ? MathF.Max(0f, _config.MinimumPressureTransfer) + : 0f; + if (!IsFinitePositive(pressureTransfer) || pressureTransfer < minimumTransfer) return 0f; - // Cap the flow to the CFL flow cap based on the current pressure to prevent excessive flow. - float configCflFlowCap = currentPressure * _config.CflFlowCap; - if (flow > configCflFlowCap) - flow = configCflFlowCap; - return flow; + // Cap the requested pressure transfer to a fraction of source pressure for this neighbor. + float maximumTransfer = currentPressure * maximumFraction; + return MathF.Min(pressureTransfer, maximumTransfer); } private static int GetDeltaArrayOffset(int g, int VoxelCount) @@ -1111,79 +1146,203 @@ private static int GetDeltaArrayOffset(int g, int VoxelCount) return (g + 1) * VoxelCount; } - private void ProcessThermalBoundaryFlow(Int3 sourceKey, ThermalBoundaryEvent evt) + /// + /// Solves every cross-chunk thermal edge from one immutable boundary snapshot. + /// + /// + /// Each physical face is deduplicated, then the same symmetric row limiter used by the intra-chunk solve + /// caps aggregate conductance at each voxel. This conserves energy, prevents temperature overshoot, and + /// makes the result independent of concurrent boundary-event order. + /// + private void ProcessThermalBoundaryFlows( + ConcurrentQueue<(Int3 Key, ThermalBoundaryEvent Evt)> boundaryEvents) + { + _thermalBoundaryEdges.Clear(); + _thermalBoundaryOrderedEdges.Clear(); + _thermalBoundaryStates.Clear(); + _thermalBoundaryIncidentConductances.Clear(); + _activeThermalBoundaryEdges.Clear(); + _thermalBoundaryEnergyDeltas.Clear(); + + float thermalConductance = _config.ThermalConductance; + if (!IsFinitePositive(thermalConductance)) + { + while (boundaryEvents.TryDequeue(out _)) + { + } + return; + } + + while (boundaryEvents.TryDequeue(out var boundaryEvent)) + { + var (sourceKey, evt) = boundaryEvent; + CollectThermalBoundaryEdges(sourceKey, evt, _thermalBoundaryEdges); + } + + if (_thermalBoundaryEdges.Count == 0) + return; + + _thermalBoundaryOrderedEdges.AddRange(_thermalBoundaryEdges); + _thermalBoundaryOrderedEdges.Sort(CompareThermalEdges); + + float vacuumThreshold = GetNonNegativeFinite(_config.VacuumThreshold); + + foreach (var edge in _thermalBoundaryOrderedEdges) + { + if (!TryGetBoundaryThermalState(edge.First, vacuumThreshold, _thermalBoundaryStates, + out var firstState) || + !TryGetBoundaryThermalState(edge.Second, vacuumThreshold, _thermalBoundaryStates, + out var secondState)) + continue; + + double conductance = CalculateThermalConductance(firstState.HeatCapacity, + secondState.HeatCapacity, thermalConductance); + if (conductance <= 0d) + continue; + + AddToDictionary(_thermalBoundaryIncidentConductances, edge.First, conductance); + AddToDictionary(_thermalBoundaryIncidentConductances, edge.Second, conductance); + _activeThermalBoundaryEdges.Add(new ThermalBoundaryConductance(edge, conductance)); + } + + foreach (var (edge, conductance) in _activeThermalBoundaryEdges) + { + ThermalBoundaryState firstState = _thermalBoundaryStates[edge.First]; + ThermalBoundaryState secondState = _thermalBoundaryStates[edge.Second]; + double firstIncident = _thermalBoundaryIncidentConductances[edge.First]; + double secondIncident = _thermalBoundaryIncidentConductances[edge.Second]; + double scale = Math.Min(1d, Math.Min(firstState.HeatCapacity / firstIncident, + secondState.HeatCapacity / secondIncident)); + double heatTransfer = scale * conductance * (firstState.Temperature - secondState.Temperature); + if (!double.IsFinite(heatTransfer) || heatTransfer == 0d) + continue; + + AddToDictionary(_thermalBoundaryEnergyDeltas, edge.First, -heatTransfer); + AddToDictionary(_thermalBoundaryEnergyDeltas, edge.Second, heatTransfer); + } + + foreach (var (address, energyDelta) in _thermalBoundaryEnergyDeltas) + { + ThermalBoundaryState state = _thermalBoundaryStates[address]; + double newTemperature = (state.Temperature * state.HeatCapacity + energyDelta) / + state.HeatCapacity; + if (!double.IsFinite(newTemperature) || newTemperature < 0d || + !_chunkMap.TryGetValue(address.ChunkPosition, out var chunk)) + continue; + + chunk.Temperature[address.LocalVoxelIndex] = (float)newTemperature; + chunk.TotalPressure[address.LocalVoxelIndex] = + CalculatePressureAtVoxel(chunk, address.LocalVoxelIndex); + chunk.MarkChanged(); + } + } + + private void CollectThermalBoundaryEdges(Int3 sourceKey, ThermalBoundaryEvent evt, + HashSet edges) { if (!_chunkMap.TryGetValue(sourceKey, out var sourceChunk)) return; - var localPosition = sourceChunk.GetXyzInt3(evt.LocalVoxelIndex); - TryThermalFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.NegX, Int3.NegX); - TryThermalFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.PosX, Int3.PosX); - TryThermalFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.NegY, Int3.NegY); - TryThermalFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.PosY, Int3.PosY); + var localPosition = sourceChunk.GetXyzInt3(evt.LocalVoxelIndex); + TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.NegX, Int3.NegX, edges); + TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.PosX, Int3.PosX, edges); + TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.NegY, Int3.NegY, edges); + TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.PosY, Int3.PosY, edges); if (sourceChunk.Depth > 1) { - TryThermalFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.NegZ, Int3.NegZ); - TryThermalFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.PosZ, Int3.PosZ); + TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.NegZ, Int3.NegZ, edges); + TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.PosZ, Int3.PosZ, edges); } } - private void TryThermalFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, - Int3 targetPosition, Int3 direction) + private void TryAddThermalBoundaryEdge(AtmosChunk sourceChunk, Int3 sourceKey, + Int3 targetPosition, Int3 direction, HashSet edges) { if (targetPosition.IsWithin(default, sourceChunk.Dimensions)) return; - var neighborPos = sourceKey + direction; - if (!_chunkMap.TryGetValue(neighborPos, out var neighborChunk)) + var neighborPosition = sourceKey + direction; + if (!_chunkMap.TryGetValue(neighborPosition, out var neighborChunk)) return; - var neighborDimensions = neighborChunk.Dimensions; - var neighborLocalPosition = (targetPosition + neighborDimensions) % neighborDimensions; - ushort neighborIdx = neighborChunk.GetIndex(neighborLocalPosition); - - if (neighborChunk.VoxelRoomMap[neighborIdx] == AtmosChunk.RoomSolid) + var neighborLocalPosition = (targetPosition + neighborChunk.Dimensions) % neighborChunk.Dimensions; + ushort neighborIndex = neighborChunk.GetIndex(neighborLocalPosition); + if (neighborChunk.VoxelRoomMap[neighborIndex] == VoxelClassification.RoomSolid) return; - var sourceLocalPosition = targetPosition - direction; - ushort srcIdx = sourceChunk.GetIndex(sourceLocalPosition); + ushort sourceIndex = sourceChunk.GetIndex(targetPosition - direction); + var source = new ThermalVoxelAddress(sourceKey, sourceIndex); + var neighbor = new ThermalVoxelAddress(neighborPosition, neighborIndex); + edges.Add(CompareThermalVoxels(source, neighbor) <= 0 + ? new ThermalBoundaryEdge(source, neighbor) + : new ThermalBoundaryEdge(neighbor, source)); + } - float sourcePressure = CalculatePressureAtVoxel(sourceChunk, srcIdx); - float neighborPressure = CalculatePressureAtVoxel(neighborChunk, neighborIdx); - sourceChunk.TotalPressure[srcIdx] = sourcePressure; - neighborChunk.TotalPressure[neighborIdx] = neighborPressure; - if (sourcePressure < _config.VacuumThreshold || neighborPressure < _config.VacuumThreshold) - return; + private bool TryGetBoundaryThermalState(ThermalVoxelAddress address, float vacuumThreshold, + Dictionary states, out ThermalBoundaryState state) + { + if (states.TryGetValue(address, out state)) + return true; - float sourceHeatCapacity = CalculateHeatCapacityAtVoxel(sourceChunk, srcIdx); - float neighborHeatCapacity = CalculateHeatCapacityAtVoxel(neighborChunk, neighborIdx); - sourceChunk.TotalHeatCapacity[srcIdx] = sourceHeatCapacity; - neighborChunk.TotalHeatCapacity[neighborIdx] = neighborHeatCapacity; - if (sourceHeatCapacity <= 0f || neighborHeatCapacity <= 0f) - return; + if (!_chunkMap.TryGetValue(address.ChunkPosition, out var chunk)) + return false; - float sourceTemp = GetEffectiveTemperature(sourceChunk.Temperature[srcIdx]); - float neighborTemp = GetEffectiveTemperature(neighborChunk.Temperature[neighborIdx]); - float tempDelta = sourceTemp - neighborTemp; - float sourceEnergy = sourceTemp * sourceHeatCapacity; - float heatTransfer = CalculateHeatTransfer(tempDelta, sourceHeatCapacity, neighborHeatCapacity, - _config.ThermalConductivity, sourceEnergy); - if (heatTransfer <= 0f) - return; + ushort idx = address.LocalVoxelIndex; + float pressure = CalculatePressureAtVoxel(chunk, idx); + float heatCapacity = CalculateHeatCapacityAtVoxel(chunk, idx); + chunk.TotalPressure[idx] = pressure; + chunk.TotalHeatCapacity[idx] = heatCapacity; + float temperature = GetEffectiveTemperature(chunk.Temperature[idx]); + if (!IsFinitePositive(heatCapacity) || !float.IsFinite(pressure) || pressure < vacuumThreshold || + !IsFinitePositive(temperature)) + return false; - float neighborEnergy = neighborTemp * neighborHeatCapacity; - sourceChunk.Temperature[srcIdx] = MathF.Max(0f, - (sourceEnergy - heatTransfer) / sourceHeatCapacity); - neighborChunk.Temperature[neighborIdx] = MathF.Max(0f, - (neighborEnergy + heatTransfer) / neighborHeatCapacity); - sourceChunk.TotalPressure[srcIdx] = CalculatePressureAtVoxel(sourceChunk, srcIdx); - neighborChunk.TotalPressure[neighborIdx] = CalculatePressureAtVoxel(neighborChunk, neighborIdx); + state = new ThermalBoundaryState(temperature, heatCapacity); + states.Add(address, state); + return true; + } - if (tempDelta > 0) - { - // need to tell numos viewer that chunk has been mutated - sourceChunk.MarkChanged(); - neighborChunk.MarkChanged(); - } + private static int CompareThermalVoxels(ThermalVoxelAddress left, ThermalVoxelAddress right) + { + int comparison = CompareChunkPositions(left.ChunkPosition, right.ChunkPosition); + return comparison != 0 ? comparison : left.LocalVoxelIndex.CompareTo(right.LocalVoxelIndex); } + + private static int CompareChunkPositions(Int3 left, Int3 right) + { + int comparison = left.X.CompareTo(right.X); + if (comparison != 0) + return comparison; + comparison = left.Y.CompareTo(right.Y); + if (comparison != 0) + return comparison; + return left.Z.CompareTo(right.Z); + } + + private static int CompareBoundaryEvents( + (Int3 Key, BoundaryFlowEvent Evt) left, + (Int3 Key, BoundaryFlowEvent Evt) right) + { + int comparison = CompareChunkPositions(left.Key, right.Key); + return comparison != 0 + ? comparison + : left.Evt.LocalVoxelIndex.CompareTo(right.Evt.LocalVoxelIndex); + } + + private static int CompareThermalEdges(ThermalBoundaryEdge left, ThermalBoundaryEdge right) + { + int comparison = CompareThermalVoxels(left.First, right.First); + return comparison != 0 ? comparison : CompareThermalVoxels(left.Second, right.Second); + } + + private static void AddToDictionary(Dictionary values, + ThermalVoxelAddress address, double value) + { + values[address] = values.GetValueOrDefault(address) + value; + } + + private readonly record struct ThermalVoxelAddress(Int3 ChunkPosition, ushort LocalVoxelIndex); + private readonly record struct ThermalBoundaryEdge(ThermalVoxelAddress First, ThermalVoxelAddress Second); + private readonly record struct ThermalBoundaryConductance(ThermalBoundaryEdge Edge, double Conductance); + private readonly record struct ThermalBoundaryState(double Temperature, double HeatCapacity); } \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosPhysicalConstants.cs b/src/Numos.CoreSim/AtmosPhysicalConstants.cs new file mode 100644 index 0000000..249114e --- /dev/null +++ b/src/Numos.CoreSim/AtmosPhysicalConstants.cs @@ -0,0 +1,28 @@ +namespace Numos.CoreSim; + +/// +/// Physical constants used by the Numos ideal-gas and phase-change models. +/// +public static class AtmosPhysicalConstants +{ + /// + /// Molar gas constant in joules per mole-kelvin (J/(mol·K)). + /// + public const float MolarGasConstant = 8.31446262f; + + /// + /// Ideal-diatomic molar heat capacity at constant volume, 5R/2, in joules per + /// mole-kelvin (J/(mol·K)). + /// + public const float IdealDiatomicMolarHeatCapacityAtConstantVolume = 2.5f * MolarGasConstant; + + /// + /// Standard atmospheric pressure in pascals (Pa). + /// + public const float StandardAtmosphericPressure = 101_325f; + + /// + /// Conventional room temperature in kelvins (K). + /// + public const float RoomTemperature = 293.15f; +} diff --git a/src/Numos.CoreSim/AtmosSolverConstants.cs b/src/Numos.CoreSim/AtmosSolverConstants.cs new file mode 100644 index 0000000..171e4d3 --- /dev/null +++ b/src/Numos.CoreSim/AtmosSolverConstants.cs @@ -0,0 +1,30 @@ +namespace Numos.CoreSim; + +/// +/// Fixed scheduling and numerical cutoffs used by the current solver implementation. +/// +/// +/// These values are intentionally separate from because they are not +/// currently user-tunable configuration. Promote a value to before exposing it as +/// a runtime option. +/// +internal static class AtmosSolverConstants +{ + /// Number of fixed simulation ticks processed per simulated second. + internal const float SimulationRate = 20f; + + /// Duration of one fixed simulation tick, in seconds. + internal const float FixedTimeStep = 1f / SimulationRate; + + /// Maximum fixed ticks consumed by one elapsed-time update. + internal const int MaximumStepsPerUpdate = 5; + + /// Number of simulation ticks between thermodynamics passes. + internal const int ThermodynamicsTickInterval = 2; + + /// Per-species amount below which residual gas is discarded, in moles (mol). + internal const float MinimumTrackedMoles = 0.0001f; + + /// Minimum vapor amount considered by the phase-change solver, in moles (mol). + internal const float MinimumMolesForCondensation = 0.01f; +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Datatypes/Events/BoundaryFlowEvent.cs b/src/Numos.CoreSim/Datatypes/Events/BoundaryFlowEvent.cs index bc984af..5d8e408 100644 --- a/src/Numos.CoreSim/Datatypes/Events/BoundaryFlowEvent.cs +++ b/src/Numos.CoreSim/Datatypes/Events/BoundaryFlowEvent.cs @@ -1,7 +1,7 @@ namespace Numos.CoreSim.Datatypes.Events; /// -/// Event that stores data on the flow of a boundary voxel for later sequential processing. +/// Identifies a boundary voxel for later sequential flow processing. /// /// /// This event is used to store data on the flow of air across a voxel that sits on the boundary of a chunk. @@ -20,14 +20,4 @@ internal struct BoundaryFlowEvent /// The location of the event in the chunk as a 1D lookup. /// public ushort LocalVoxelIndex; - - /// - /// The pressure at the boundary voxel at the time of the event. - /// - public float Pressure; - - /// - /// The temperature at the boundary voxel at the time of the event. - /// - public float Temperature; -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Datatypes/Events/PrecipitationEvent.cs b/src/Numos.CoreSim/Datatypes/Events/PrecipitationEvent.cs index dfa87c1..1e0572e 100644 --- a/src/Numos.CoreSim/Datatypes/Events/PrecipitationEvent.cs +++ b/src/Numos.CoreSim/Datatypes/Events/PrecipitationEvent.cs @@ -2,8 +2,15 @@ namespace Numos.CoreSim.Datatypes.Events; internal struct PrecipitationEvent { + /// Flat index of the voxel where condensation occurred. public ushort LocalVoxelIndex; - public int LiquidID; - public float MolesToSpawn; - public float InheritedTemp; -} \ No newline at end of file + + /// ID of the condensed-phase species. + public int LiquidId; + + /// Amount condensed, in moles (mol). + public float CondensedMoles; + + /// Temperature at condensation, in kelvins (K). + public float Temperature; +} diff --git a/src/Numos.CoreSim/Datatypes/Events/ThermalBoundaryEvent.cs b/src/Numos.CoreSim/Datatypes/Events/ThermalBoundaryEvent.cs index 032a139..d120e56 100644 --- a/src/Numos.CoreSim/Datatypes/Events/ThermalBoundaryEvent.cs +++ b/src/Numos.CoreSim/Datatypes/Events/ThermalBoundaryEvent.cs @@ -3,5 +3,4 @@ namespace Numos.CoreSim.Datatypes.Events; internal struct ThermalBoundaryEvent { public ushort LocalVoxelIndex; - public float Temperature; -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs index 202a57e..fd25e6f 100644 --- a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs +++ b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs @@ -6,7 +6,11 @@ public struct AtmosChunkSnapshot { public Int3 GridPosition; public Int3 Dimensions; + + /// Detached per-voxel pressure values, in pascals (Pa). public float[] TotalPressure; + + /// Detached per-voxel temperature values, in kelvins (K). public float[] Temperature; public GasSnapshot[] Gases; public int[] VoxelRoomMap; @@ -52,4 +56,4 @@ public readonly bool HasFields(AtmosChunkSnapshotFields fields) return (available & fields) == fields; } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosVoxelSnapshot.cs b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosVoxelSnapshot.cs index e9cd6f5..b9f46ca 100644 --- a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosVoxelSnapshot.cs +++ b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosVoxelSnapshot.cs @@ -5,11 +5,17 @@ namespace Numos.CoreSim.Datatypes.Snapshots; /// /// One gas channel sampled at a single voxel. /// -public readonly record struct VoxelGasSnapshot(int GasId, float Moles); +/// Gas registry ID. +/// Sampled amount, in moles (mol). +public readonly record struct VoxelGasSnapshot( + int GasId, + float Moles); /// /// Detached values for one voxel, intended for interaction details and tooltips. /// +/// Pressure in pascals (Pa). +/// Temperature in kelvins (K). public readonly record struct AtmosVoxelSnapshot( AtmosChunkVersion ChunkVersion, Int3 ChunkPosition, @@ -17,4 +23,4 @@ public readonly record struct AtmosVoxelSnapshot( int RoomId, float Pressure, float Temperature, - VoxelGasSnapshot[] Gases); \ No newline at end of file + VoxelGasSnapshot[] Gases); diff --git a/src/Numos.CoreSim/Datatypes/Snapshots/GasSnapshot.cs b/src/Numos.CoreSim/Datatypes/Snapshots/GasSnapshot.cs index 219a4cf..b29b820 100644 --- a/src/Numos.CoreSim/Datatypes/Snapshots/GasSnapshot.cs +++ b/src/Numos.CoreSim/Datatypes/Snapshots/GasSnapshot.cs @@ -3,5 +3,7 @@ namespace Numos.CoreSim.Datatypes.Snapshots; public struct GasSnapshot { public int GasId; + + /// Detached per-voxel amounts, in moles (mol). public float[] Moles; -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/GasAccumulator.cs b/src/Numos.CoreSim/GasAccumulator.cs index 26b25c6..d1d46f1 100644 --- a/src/Numos.CoreSim/GasAccumulator.cs +++ b/src/Numos.CoreSim/GasAccumulator.cs @@ -18,19 +18,27 @@ internal enum AccumulatorState internal struct GasAccumulator { public int GasId; + + /// Accumulated amount, in moles (mol). public float AccumulatedMoles; + + /// Mole-weighted output temperature, in kelvins (K). public float OutputTemperature; public int TicksAlive; - public void AddGas(float moles, float temp) + /// Adds one sample of this accumulator's gas species. + /// Amount to add, in moles (mol). + /// Sample temperature, in kelvins (K). + public void AddGas(float moles, float temperature) { if (AccumulatedMoles + moles > 0) { - OutputTemperature = (AccumulatedMoles * OutputTemperature + moles * temp) / (AccumulatedMoles + moles); + OutputTemperature = (AccumulatedMoles * OutputTemperature + moles * temperature) / + (AccumulatedMoles + moles); } else { - OutputTemperature = temp; + OutputTemperature = temperature; } AccumulatedMoles += moles; @@ -48,8 +56,8 @@ public void Reset() /// Determines if the accumulated gas should trigger a violent Micro Injection (wake chunk) /// or a passive Macro Diffusion (add to room node). /// - /// The calculated local pressure spike |P_spike - P_room|. - /// The constant 'Threshold of Violence' (tau_wake). + /// The calculated local pressure spike |P_spike - P_room|, in pascals. + /// The pressure threshold for waking the micro solver, in pascals. /// Maximum ticks before the accumulator times out and diffuses. public AccumulatorState EvaluateState(float currentPressureDelta, float wakeThreshold, int maxAliveTicks) { @@ -65,4 +73,4 @@ public AccumulatorState EvaluateState(float currentPressureDelta, float wakeThre return AccumulatorState.Hold; } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/GasChannel.cs b/src/Numos.CoreSim/GasChannel.cs index d9a941d..8b2f673 100644 --- a/src/Numos.CoreSim/GasChannel.cs +++ b/src/Numos.CoreSim/GasChannel.cs @@ -28,7 +28,7 @@ internal struct GasChannel public int GasId; /// - /// The amount of moles of this gas in each voxel of the chunk. + /// The amount of this gas in each voxel of the chunk, in moles (mol). /// /// /// While this is not marked as nullable, this field @@ -67,4 +67,4 @@ public void Release() Moles = null!; } } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/GasProperties.cs b/src/Numos.CoreSim/GasProperties.cs index 8ea8d82..7b6e03c 100644 --- a/src/Numos.CoreSim/GasProperties.cs +++ b/src/Numos.CoreSim/GasProperties.cs @@ -11,33 +11,34 @@ public struct GasProperties public string Name; /// - /// Effective molar heat capacity of the gas, in joules per mole-kelvin (J/(mol·K)). + /// Molar heat capacity at constant volume, in joules per mole-kelvin (J/(mol·K)). /// /// /// This value determines the sensible energy carried by gas during injection and flow, the voxel's /// total heat capacity, and the energy removed during condensation. Non-finite values and values less - /// than or equal to zero use . + /// than or equal to zero use . /// - public float SpecificHeatCapacity; + public float MolarHeatCapacityAtConstantVolume; /// - /// Temperature in kelvin above which the gas remains gaseous. + /// Normal boiling temperature, in kelvins (K), at + /// . /// public float BoilingPoint; /// - /// Temperature in kelvin below which condensation can begin. + /// Whether this species participates in the condensation model. /// - /// - /// In the sim, this is used as a hard gate for condensation, - /// it doesn't actually reflect the real life behavior. - /// - public float CondensationPoint; + public bool CondensationEnabled; /// - /// Energy released per mole during condensation, in joules per mole (J/mol). + /// Molar enthalpy of vaporization, in joules per mole (J/mol). /// - public float LatentHeatOfVaporization; + /// + /// The phase-equilibrium model uses this value in Clausius–Clapeyron. The constant-volume energy + /// balance converts it to an approximate internal-energy change, ΔU_vap = ΔH_vap - RT. + /// + public float MolarEnthalpyOfVaporization; /// /// ID of the liquid this gas condenses to. Currently unused but can be passed to a separate fluid sim. @@ -46,7 +47,8 @@ public struct GasProperties public int LiquidId; /// - /// Fickian diffusion rate, used when calculating gas mixing via partial pressures. + /// Dimensionless fraction of the per-species mole imbalance mixed per simulation tick. /// - public float DiffusionCoefficient; // Passive Fickian diffusion rate -} \ No newline at end of file + /// Values are clamped to [0, 1]; non-finite values disable diffusion for this species. + public float DiffusionCoefficient; +} diff --git a/src/Numos.CoreSim/RoomNode.cs b/src/Numos.CoreSim/RoomNode.cs index 98e4038..5c97e54 100644 --- a/src/Numos.CoreSim/RoomNode.cs +++ b/src/Numos.CoreSim/RoomNode.cs @@ -5,47 +5,82 @@ namespace Numos.CoreSim; /// internal struct RoomNode { + /// Logical room identifier. public int RoomId; + + /// Whether the room is represented by its aggregate macro state. public bool IsAsleep; - public int TotalVoxelVolume; + /// Number of voxels represented by this room. + public int VoxelCount; + + /// Physical volume of each voxel, in cubic metres (m³). + /// Non-finite and nonpositive values are normalized to 1 m³. + public float VoxelVolume; + + /// Aggregate ideal-gas pressure, in pascals (Pa). public float EquilibriumPressure; + + /// Heat-capacity-weighted average temperature, in kelvins (K). public float AverageTemperature; + /// Aggregate constant-volume heat capacity, in joules per kelvin (J/K). + public float TotalHeatCapacity; + + /// Total gas amount in the room, in moles (mol). + public float TotalMoles; + + /// Mole amount for each gas ID, in moles (mol). public float[] GasMoles; - public void AddGas(int gasId, float addedMoles, float incomingTemp) + public void AddGas(int gasId, float addedMoles, float incomingTemp, + float molarHeatCapacityAtConstantVolume) { - var currentTotalMoles = 0f; - for (var i = 0; i < GasMoles.Length; i++) - currentTotalMoles += GasMoles[i]; - - if (currentTotalMoles + addedMoles > 0) + float incomingHeatCapacity = addedMoles * molarHeatCapacityAtConstantVolume; + float newHeatCapacity = TotalHeatCapacity + incomingHeatCapacity; + float newTotalMoles = TotalMoles + addedMoles; + if (newTotalMoles > 0f && newHeatCapacity > 0f) { - AverageTemperature = (currentTotalMoles * AverageTemperature + addedMoles * incomingTemp) / - (currentTotalMoles + addedMoles); + AverageTemperature = TotalHeatCapacity > 0f && AverageTemperature == incomingTemp + ? AverageTemperature + : (float)(((double)TotalHeatCapacity * AverageTemperature + + (double)incomingHeatCapacity * incomingTemp) / newHeatCapacity); GasMoles[gasId] += addedMoles; - EquilibriumPressure = (currentTotalMoles + addedMoles) * AverageTemperature / TotalVoxelVolume; + TotalHeatCapacity = newHeatCapacity; + TotalMoles = newTotalMoles; + EquilibriumPressure = CalculatePressure(TotalMoles); } } - public void RemoveGas(int gasId, float removedMoles) + public void RemoveGas(int gasId, float removedMoles, float molarHeatCapacityAtConstantVolume) { - var currentTotalMoles = 0f; - for (var i = 0; i < GasMoles.Length; i++) - currentTotalMoles += GasMoles[i]; - float actualRemoved = removedMoles; if (GasMoles[gasId] < actualRemoved) actualRemoved = GasMoles[gasId]; - float newTotalMoles = currentTotalMoles - actualRemoved; + float newTotalMoles = MathF.Max(0f, TotalMoles - actualRemoved); GasMoles[gasId] -= actualRemoved; + TotalHeatCapacity = MathF.Max(0f, + TotalHeatCapacity - actualRemoved * molarHeatCapacityAtConstantVolume); + TotalMoles = newTotalMoles; if (newTotalMoles > 0) - EquilibriumPressure = newTotalMoles * AverageTemperature / TotalVoxelVolume; + EquilibriumPressure = CalculatePressure(newTotalMoles); else EquilibriumPressure = 0; } + + private readonly float CalculatePressure(float totalMoles) + { + if (!float.IsFinite(totalMoles) || totalMoles <= 0f || + !float.IsFinite(AverageTemperature) || AverageTemperature <= 0f || VoxelCount <= 0) + return 0f; + + float voxelVolume = float.IsFinite(VoxelVolume) && VoxelVolume > 0f + ? VoxelVolume + : AtmosConfigDefaults.VoxelVolume; + return (float)((double)totalMoles * AtmosPhysicalConstants.MolarGasConstant * AverageTemperature / + (VoxelCount * voxelVolume)); + } } \ No newline at end of file diff --git a/src/Numos.SimDrawer/DrawableData.cs b/src/Numos.SimDrawer/DrawableData.cs index f6b32da..ad18ec5 100644 --- a/src/Numos.SimDrawer/DrawableData.cs +++ b/src/Numos.SimDrawer/DrawableData.cs @@ -58,6 +58,9 @@ public enum VoxelFaceMask : byte /// /// Immutable presentation values for one voxel. It contains no API-specific mesh data. /// +/// Temperature in kelvins (K). +/// Pressure in pascals (Pa). +/// Total gas amount in moles (mol). public readonly record struct VoxelDrawData( bool IsVisible, VoxelFaceMask VisibleFaces, @@ -367,4 +370,4 @@ public bool TryPickNormalized( float v = bounds.Bottom + normalizedY * (bounds.Top - bounds.Bottom); return TryGetCell((int)MathF.Floor(u), (int)MathF.Floor(v), out cell); } -} \ No newline at end of file +} diff --git a/src/Numos.SimDrawer/Visualization.cs b/src/Numos.SimDrawer/Visualization.cs index e0e9097..bd4011e 100644 --- a/src/Numos.SimDrawer/Visualization.cs +++ b/src/Numos.SimDrawer/Visualization.cs @@ -99,6 +99,9 @@ public float GetMoles(int channel) /// /// Backend-independent values passed to a visualization method for one voxel. /// +/// Temperature in kelvins (K). +/// Pressure in pascals (Pa). +/// Total gas amount in moles (mol). public readonly record struct VoxelSample( ushort LocalIndex, int RoomId, @@ -496,4 +499,4 @@ private static ColorRgba HsvToRgb(float hue, float saturation, float value) _ => new ColorRgba(value, p, q) }; } -} \ No newline at end of file +} diff --git a/src/Numos.Viewer/SimulationViewer.Project.cs b/src/Numos.Viewer/SimulationViewer.Project.cs index 0a3ca5f..6690d98 100644 --- a/src/Numos.Viewer/SimulationViewer.Project.cs +++ b/src/Numos.Viewer/SimulationViewer.Project.cs @@ -11,10 +11,11 @@ public partial class SimulationViewer private readonly static GasProperties Oxygen = new() { Name = "Oxygen", - SpecificHeatCapacity = 1000f, - BoilingPoint = 90f, - CondensationPoint = 85f, - LatentHeatOfVaporization = 10000f, + MolarHeatCapacityAtConstantVolume = + AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume, + BoilingPoint = 90.2f, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = 6_820f, LiquidId = 0, DiffusionCoefficient = 0.1f }; @@ -22,10 +23,11 @@ public partial class SimulationViewer private readonly static GasProperties Nitrogen = new() { Name = "Nitrogen", - SpecificHeatCapacity = 1040f, - BoilingPoint = 77f, - CondensationPoint = 73f, - LatentHeatOfVaporization = 11500f, + MolarHeatCapacityAtConstantVolume = + AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume, + BoilingPoint = 77.34f, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = 5_600f, LiquidId = 1, DiffusionCoefficient = 0.08f }; @@ -167,10 +169,9 @@ private void AddProjectGas(GasProperties gas) return; } - if (!float.IsFinite(gas.SpecificHeatCapacity) || gas.SpecificHeatCapacity < 0f || + if (!float.IsFinite(gas.MolarHeatCapacityAtConstantVolume) || gas.MolarHeatCapacityAtConstantVolume < 0f || !float.IsFinite(gas.BoilingPoint) || gas.BoilingPoint < 0f || - !float.IsFinite(gas.CondensationPoint) || gas.CondensationPoint < 0f || - !float.IsFinite(gas.LatentHeatOfVaporization) || gas.LatentHeatOfVaporization < 0f || + !float.IsFinite(gas.MolarEnthalpyOfVaporization) || gas.MolarEnthalpyOfVaporization < 0f || !float.IsFinite(gas.DiffusionCoefficient) || gas.DiffusionCoefficient < 0f) { SetProjectMessage("Gas properties must be finite, non-negative values.", true); @@ -237,4 +238,4 @@ private void InjectProjectGas( SetProjectMessage(exception.Message, true); } } -} \ No newline at end of file +} diff --git a/src/Numos.Viewer/SimulationViewer.ProjectUi.cs b/src/Numos.Viewer/SimulationViewer.ProjectUi.cs index 6ec7c35..85b349c 100644 --- a/src/Numos.Viewer/SimulationViewer.ProjectUi.cs +++ b/src/Numos.Viewer/SimulationViewer.ProjectUi.cs @@ -19,8 +19,8 @@ public partial class SimulationViewer private bool _closeProjectModalOpen; private string _projectNameDraft = "Untitled Simulation"; - private int _projectChunkWidthDraft = 16; - private int _projectChunkHeightDraft = 16; + private int _projectChunkWidthDraft = AtmosChunkConstants.DefaultWidth; + private int _projectChunkHeightDraft = AtmosChunkConstants.DefaultHeight; private int _projectChunkDepthDraft = 1; private bool _includeDefaultGasesDraft = true; private string? _createProjectError; @@ -39,15 +39,16 @@ public partial class SimulationViewer private int _injectionZ; private int _injectionGasId; private float _injectionMoles = 1f; - private float _injectionTemperature = 293.15f; + private float _injectionTemperature = AtmosPhysicalConstants.RoomTemperature; private string _newGasName = "New Gas"; - private float _newGasSpecificHeatCapacity = 1000f; + private float _newGasMolarHeatCapacityAtConstantVolume = + AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume; private float _newGasBoilingPoint; - private float _newGasCondensationPoint; - private float _newGasLatentHeat; + private bool _newGasCondensationEnabled; + private float _newGasEnthalpyOfVaporization; private int _newGasLiquidId = -1; - private float _newGasDiffusionCoefficient = 0.02f; + private float _newGasDiffusionCoefficient = AtmosConfigDefaults.DefaultDiffusionCoefficient; private string? _projectMessage; private bool _projectMessageIsError; @@ -55,8 +56,12 @@ public partial class SimulationViewer private void RequestCreateProject() { _projectNameDraft = _simulation == null ? "Untitled Simulation" : $"{_projectName} Copy"; - _projectChunkWidthDraft = _chunkDimensions.X > 0 ? _chunkDimensions.X : 16; - _projectChunkHeightDraft = _chunkDimensions.Y > 0 ? _chunkDimensions.Y : 16; + _projectChunkWidthDraft = _chunkDimensions.X > 0 + ? _chunkDimensions.X + : AtmosChunkConstants.DefaultWidth; + _projectChunkHeightDraft = _chunkDimensions.Y > 0 + ? _chunkDimensions.Y + : AtmosChunkConstants.DefaultHeight; _projectChunkDepthDraft = _chunkDimensions.Z > 0 ? _chunkDimensions.Z : 1; _includeDefaultGasesDraft = true; _createProjectError = null; @@ -356,13 +361,12 @@ private void RenderProjectGasControls() ImGui.Text("Add gas definition"); ImGui.InputText("Name##new-gas", ref _newGasName, 64); ImGui.SetNextItemWidth(NumericInputWidth); - ImGui.InputFloat("Specific heat##new-gas", ref _newGasSpecificHeatCapacity); + ImGui.InputFloat("Molar Cv (J/mol-K)##new-gas", ref _newGasMolarHeatCapacityAtConstantVolume); ImGui.SetNextItemWidth(NumericInputWidth); ImGui.InputFloat("Boiling point (K)##new-gas", ref _newGasBoilingPoint); + ImGui.Checkbox("Condensation enabled##new-gas", ref _newGasCondensationEnabled); ImGui.SetNextItemWidth(NumericInputWidth); - ImGui.InputFloat("Condensation point (K)##new-gas", ref _newGasCondensationPoint); - ImGui.SetNextItemWidth(NumericInputWidth); - ImGui.InputFloat("Latent heat##new-gas", ref _newGasLatentHeat); + ImGui.InputFloat("Vaporization enthalpy (J/mol)##new-gas", ref _newGasEnthalpyOfVaporization); ImGui.SetNextItemWidth(NumericInputWidth); ImGui.InputInt("Liquid ID##new-gas", ref _newGasLiquidId); ImGui.SetNextItemWidth(NumericInputWidth); @@ -372,10 +376,10 @@ private void RenderProjectGasControls() AddProjectGas(new GasProperties { Name = _newGasName, - SpecificHeatCapacity = _newGasSpecificHeatCapacity, + MolarHeatCapacityAtConstantVolume = _newGasMolarHeatCapacityAtConstantVolume, BoilingPoint = _newGasBoilingPoint, - CondensationPoint = _newGasCondensationPoint, - LatentHeatOfVaporization = _newGasLatentHeat, + CondensationEnabled = _newGasCondensationEnabled, + MolarEnthalpyOfVaporization = _newGasEnthalpyOfVaporization, LiquidId = _newGasLiquidId, DiffusionCoefficient = _newGasDiffusionCoefficient }); diff --git a/src/Numos.Viewer/SimulationViewer.RenderUi.cs b/src/Numos.Viewer/SimulationViewer.RenderUi.cs index 5ef1bf9..c1e1954 100644 --- a/src/Numos.Viewer/SimulationViewer.RenderUi.cs +++ b/src/Numos.Viewer/SimulationViewer.RenderUi.cs @@ -485,26 +485,47 @@ private void RenderConfigurationPanel() ref defaultTemperatureFallback, 0f, 1000f, "Default fallback temperature to set when a voxel has 0 or an uninitialized temperature.")) _config.DefaultTemperatureFallback = defaultTemperatureFallback; + float voxelVolume = _config.VoxelVolume; + if (ConfigSlider("Voxel Volume (m³)", "config-voxel-volume", ref voxelVolume, 0.001f, 100f, + "Physical volume represented by each voxel. Pressure uses P = nRT/V.")) + _config.VoxelVolume = voxelVolume; + float saturationReferencePressure = _config.SaturationReferencePressure; + if (ConfigSlider("Saturation Reference Pressure (Pa)", "config-saturation-reference-pressure", + ref saturationReferencePressure, 100f, 200_000f, + "Pressure at which each gas's configured boiling point applies.")) + _config.SaturationReferencePressure = saturationReferencePressure; + float defaultMolarHeatCapacityAtConstantVolume = _config.DefaultMolarHeatCapacityAtConstantVolume; + if (ConfigSlider("Default Molar Cv", "config-default-molar-cv", + ref defaultMolarHeatCapacityAtConstantVolume, 0.01f, 10_000f, + "Fallback molar heat capacity at constant volume in J/(mol·K).")) + _config.DefaultMolarHeatCapacityAtConstantVolume = defaultMolarHeatCapacityAtConstantVolume; + float defaultDiffusionCoefficient = _config.DefaultDiffusionCoefficient; + if (ConfigSlider("Default Diffusion Coefficient", "config-default-diffusion-coefficient", + ref defaultDiffusionCoefficient, 0f, 1f, + "Fallback fraction of the species mole imbalance mixed per tick.")) + _config.DefaultDiffusionCoefficient = defaultDiffusionCoefficient; float spaceTemperature = _config.SpaceTemperature; if (ConfigSlider("Space Temperature", "config-space-temperature", ref spaceTemperature, 0f, 100f, "Default temperature of space.")) _config.SpaceTemperature = spaceTemperature; - float flowFriction = _config.FlowFriction; - if (ConfigSlider("Flow Friction", "config-flow-friction", ref flowFriction, 0f, 1f, + float bulkFlowCoefficient = _config.BulkFlowCoefficient; + if (ConfigSlider("Bulk Flow Coefficient", "config-bulk-flow-coefficient", ref bulkFlowCoefficient, 0f, 1f, "Fraction of pressure delta converted to flow per tick.")) - _config.FlowFriction = flowFriction; - float dampingFactor = _config.DampingFactor; - if (ConfigSlider("Damping Factor", "config-damping-factor", ref dampingFactor, 0f, 1f, - "Multiplier applied to Flow Friction during large-delta advection. Used to reduce oscillation in the sim.")) - _config.DampingFactor = dampingFactor; - float snapThreshold = _config.SnapThreshold; - if (ConfigSlider("Snap Threshold", "config-snap-threshold", ref snapThreshold, 0f, 100f, - "Below this pressure delta, flow uses the Cfl Flow Cap directly instead of Flow Friction multiplied by Damping Factor.")) - _config.SnapThreshold = snapThreshold; - float minFlowCutoff = _config.MinFlowCutoff; - if (ConfigSlider("Minimum Flow Cutoff", "config-min-flow-cutoff", ref minFlowCutoff, 0f, 10f, - "Flows below this magnitude are discarded.")) - _config.MinFlowCutoff = minFlowCutoff; + _config.BulkFlowCoefficient = bulkFlowCoefficient; + float bulkFlowDamping = _config.BulkFlowDamping; + if (ConfigSlider("Bulk Flow Damping", "config-bulk-flow-damping", ref bulkFlowDamping, 0f, 1f, + "Multiplier applied to the bulk-flow coefficient during large pressure deltas.")) + _config.BulkFlowDamping = bulkFlowDamping; + float lowPressureDeltaThreshold = _config.LowPressureDeltaThreshold; + if (ConfigSlider("Low-Pressure Delta Threshold (Pa)", "config-low-pressure-delta-threshold", + ref lowPressureDeltaThreshold, 0f, 100f, + "Below this pressure delta, flow uses the maximum pressure-transfer fraction directly.")) + _config.LowPressureDeltaThreshold = lowPressureDeltaThreshold; + float minimumPressureTransfer = _config.MinimumPressureTransfer; + if (ConfigSlider("Minimum Pressure Transfer (Pa/tick)", "config-minimum-pressure-transfer", + ref minimumPressureTransfer, 0f, 10f, + "Candidate pressure transfers below this magnitude are discarded.")) + _config.MinimumPressureTransfer = minimumPressureTransfer; float vacuumThreshold = _config.VacuumThreshold; if (ConfigSlider("Vacuum Threshold", "config-vacuum-threshold", ref vacuumThreshold, 0f, 100f, "Below this pressure, voxel contents are zeroed out.")) @@ -517,20 +538,21 @@ private void RenderConfigurationPanel() if (ConfigSlider("Sleep Epsilon", "config-sleep-epsilon", ref sleepEpsilon, 0f, 100f, "Maximum pressure delta considered at rest.")) _config.SleepEpsilon = sleepEpsilon; - float thermalConductivity = _config.ThermalConductivity; - if (ConfigSlider("Thermal Conductivity", "config-thermal-conductivity", ref thermalConductivity, 0f, 1f, - "Fraction of temperature delta transferred per neighbor per tick.")) - _config.ThermalConductivity = thermalConductivity; + float thermalConductance = _config.ThermalConductance; + if (ConfigSlider("Thermal Conductance", "config-thermal-conductance", ref thermalConductance, 0f, 1f, + "Per-face energy conductance in J/K per thermodynamics tick.")) + _config.ThermalConductance = thermalConductance; float condensationRateFactor = _config.CondensationRateFactor; if (ConfigSlider("Condensation Rate Factor", "config-condensation-rate-factor", ref condensationRateFactor, 0f, 1f, "Rate multiplier for phase-change condensation.")) _config.CondensationRateFactor = condensationRateFactor; - float cflFlowCap = _config.CflFlowCap; - if (ConfigSlider("CFL Flow Cap", "config-cfl-flow-cap", ref cflFlowCap, 0f, 1f, - "Rate multiplier for phase-change condensation.")) - _config.CflFlowCap = cflFlowCap; + float maxPressureTransferFraction = _config.MaxPressureTransferFractionPerNeighbor; + if (ConfigSlider("Max Pressure Transfer / Neighbor", "config-max-pressure-transfer-fraction", + ref maxPressureTransferFraction, 0f, 1f, + "Maximum source-pressure fraction requested as bulk flow to one neighbor per tick.")) + _config.MaxPressureTransferFractionPerNeighbor = maxPressureTransferFraction; float accumulatorWakeThreshold = _config.AccumulatorWakeThreshold; if (ConfigSlider("Accumulator Wake Threshold", "config-accumulator-wake-threshold", ref accumulatorWakeThreshold, 0f, 100f, @@ -556,17 +578,21 @@ private void ResetConfigurationValues() var defaults = new AtmosConfig(); _config.GlobalTemperature = defaults.GlobalTemperature; _config.DefaultTemperatureFallback = defaults.DefaultTemperatureFallback; + _config.DefaultMolarHeatCapacityAtConstantVolume = defaults.DefaultMolarHeatCapacityAtConstantVolume; + _config.VoxelVolume = defaults.VoxelVolume; + _config.SaturationReferencePressure = defaults.SaturationReferencePressure; + _config.DefaultDiffusionCoefficient = defaults.DefaultDiffusionCoefficient; _config.SpaceTemperature = defaults.SpaceTemperature; - _config.FlowFriction = defaults.FlowFriction; - _config.DampingFactor = defaults.DampingFactor; - _config.SnapThreshold = defaults.SnapThreshold; - _config.MinFlowCutoff = defaults.MinFlowCutoff; + _config.BulkFlowCoefficient = defaults.BulkFlowCoefficient; + _config.BulkFlowDamping = defaults.BulkFlowDamping; + _config.LowPressureDeltaThreshold = defaults.LowPressureDeltaThreshold; + _config.MinimumPressureTransfer = defaults.MinimumPressureTransfer; _config.VacuumThreshold = defaults.VacuumThreshold; _config.SleepThreshold = defaults.SleepThreshold; _config.SleepEpsilon = defaults.SleepEpsilon; - _config.ThermalConductivity = defaults.ThermalConductivity; + _config.ThermalConductance = defaults.ThermalConductance; _config.CondensationRateFactor = defaults.CondensationRateFactor; - _config.CflFlowCap = defaults.CflFlowCap; + _config.MaxPressureTransferFractionPerNeighbor = defaults.MaxPressureTransferFractionPerNeighbor; _config.AccumulatorWakeThreshold = defaults.AccumulatorWakeThreshold; _config.AccumulatorMaxAliveTicks = defaults.AccumulatorMaxAliveTicks; } @@ -943,7 +969,7 @@ private void DrawCellSelectionDetails(VoxelAddress address, int? sliceU = null, } ImGui.Text($"Temperature: {details.Temperature:F2} K"); - ImGui.Text($"Pressure: {details.Pressure:F2}"); + ImGui.Text($"Pressure: {details.Pressure:F2} Pa"); GetGasSummary(details.Gases, out float totalMoles, out int primaryGasId); ImGui.Text($"Total Moles: {totalMoles:F2}"); ImGui.Text($"Primary Gas: {FormatGas(primaryGasId)}"); @@ -1077,8 +1103,8 @@ private void RenderSolutionDetails() { float maxPressure = snapshot.TotalPressure.Max(); float avgPressure = snapshot.TotalPressure.Where(p => p > 0).DefaultIfEmpty(0).Average(); - ImGui.Text($"Max Pressure: {maxPressure:F2}"); - ImGui.Text($"Avg Pressure: {avgPressure:F2}"); + ImGui.Text($"Max Pressure: {maxPressure:F2} Pa"); + ImGui.Text($"Avg Pressure: {avgPressure:F2} Pa"); } if (snapshot.Temperature is { Length: > 0 }) @@ -1092,4 +1118,4 @@ private void RenderSolutionDetails() } } } -} \ No newline at end of file +} diff --git a/tests/Numos.API.Tests/AtmosChunkVersionTests.cs b/tests/Numos.API.Tests/AtmosChunkVersionTests.cs index f190c79..cfd88df 100644 --- a/tests/Numos.API.Tests/AtmosChunkVersionTests.cs +++ b/tests/Numos.API.Tests/AtmosChunkVersionTests.cs @@ -267,7 +267,7 @@ public void TryGetChunkSnapshot_ThermalFlowIntoSleepingNeighbor_AdvancesNeighbor { var config = new AtmosConfig { - ThermalConductivity = 0.1f, + ThermalConductance = 0.1f, VacuumThreshold = 0.1f }; using var simulation = new AtmosSimulation(config, 1, 1, 1); @@ -294,4 +294,4 @@ public void TryGetChunkSnapshot_ThermalFlowIntoSleepingNeighbor_AdvancesNeighbor Assert.That(after.Temperature[0], Is.GreaterThan(before.Temperature[0])); }); } -} \ No newline at end of file +} diff --git a/tests/Numos.API.Tests/AtmosSimulationContractTests.cs b/tests/Numos.API.Tests/AtmosSimulationContractTests.cs index 51162b3..ed09e90 100644 --- a/tests/Numos.API.Tests/AtmosSimulationContractTests.cs +++ b/tests/Numos.API.Tests/AtmosSimulationContractTests.cs @@ -65,17 +65,17 @@ public void Constructor_RetainsLiveConfigurationInstance() { var config = new AtmosConfig { - FlowFriction = 0.2f, + BulkFlowCoefficient = 0.2f, SleepThreshold = 12 }; using var simulation = new AtmosSimulation(config, 2, 3, 4); - config.FlowFriction = 0.4f; + config.BulkFlowCoefficient = 0.4f; Assert.Multiple(() => { Assert.That(simulation.Config, Is.SameAs(config)); - Assert.That(simulation.Config.FlowFriction, Is.EqualTo(0.4f)); + Assert.That(simulation.Config.BulkFlowCoefficient, Is.EqualTo(0.4f)); Assert.That(simulation.Config.SleepThreshold, Is.EqualTo(12)); }); } @@ -335,19 +335,20 @@ public void ClassificationControlsWhetherGasCanBeAdded() Assert.That(snapshot.Gases, Has.Length.EqualTo(1)); Assert.That(snapshot.Gases[0].Moles, Is.EqualTo(new[] { 0f, 0f, 2f })); Assert.That(snapshot.Temperature, Is.EqualTo(new[] { 0f, 0f, 300f })); - Assert.That(snapshot.TotalPressure, Is.EqualTo(new[] { 0f, 0f, 600f })); + Assert.That(snapshot.TotalPressure, + Is.EqualTo(new[] { 0f, 0f, ExpectedPressure(2f, 300f) }).Within(0.001f)); }); } [Test] - public void AddGasToVoxel_MixesUnequalSpecificHeatCapacitiesBySensibleEnergy() + public void AddGasToVoxel_MixesUnequalMolarHeatCapacitiesBySensibleEnergy() { var config = new AtmosConfig { GasRegistry = [ - new GasProperties { Name = "Light", SpecificHeatCapacity = 1f }, - new GasProperties { Name = "Heavy", SpecificHeatCapacity = 4f } + new GasProperties { Name = "Light", MolarHeatCapacityAtConstantVolume = 1f }, + new GasProperties { Name = "Heavy", MolarHeatCapacityAtConstantVolume = 4f } ] }; using var simulation = new AtmosSimulation(config, 1, 1, 1); @@ -361,19 +362,20 @@ public void AddGasToVoxel_MixesUnequalSpecificHeatCapacitiesBySensibleEnergy() Assert.Multiple(() => { Assert.That(snapshot.Temperature[0], Is.EqualTo(180f).Within(0.0001f)); - Assert.That(snapshot.TotalPressure[0], Is.EqualTo(360f).Within(0.0001f)); + Assert.That(snapshot.TotalPressure[0], + Is.EqualTo(ExpectedPressure(2f, 180f)).Within(0.001f)); Assert.That(snapshot.Gases.Select(gas => gas.Moles[0]), Is.EqualTo(new[] { 1f, 1f })); }); } [Test] - public void AddGasToVoxel_LiveSpecificHeatCapacityChangeRevaluesExistingMixture() + public void AddGasToVoxel_LiveMolarHeatCapacityAtConstantVolumeChangeRevaluesExistingMixture() { var config = new AtmosConfig { GasRegistry = [ - new GasProperties { Name = "Variable", SpecificHeatCapacity = 1f } + new GasProperties { Name = "Variable", MolarHeatCapacityAtConstantVolume = 1f } ] }; using var simulation = new AtmosSimulation(config, 1, 1, 1); @@ -381,7 +383,7 @@ public void AddGasToVoxel_LiveSpecificHeatCapacityChangeRevaluesExistingMixture( simulation.SetChunkClassification(chunk, new VoxelClassification(7)); simulation.AddGasToVoxel(chunk, 0, 0, 0, 0, 1f, 100f); var gas = config.GasRegistry[0]; - gas.SpecificHeatCapacity = 4f; + gas.MolarHeatCapacityAtConstantVolume = 4f; config.GasRegistry[0] = gas; simulation.AddGasToVoxel(chunk, 0, 0, 0, 0, 1f, 200f); @@ -390,20 +392,21 @@ public void AddGasToVoxel_LiveSpecificHeatCapacityChangeRevaluesExistingMixture( Assert.Multiple(() => { Assert.That(snapshot.Temperature[0], Is.EqualTo(150f).Within(0.0001f)); - Assert.That(snapshot.TotalPressure[0], Is.EqualTo(300f).Within(0.0001f)); + Assert.That(snapshot.TotalPressure[0], + Is.EqualTo(ExpectedPressure(2f, 150f)).Within(0.001f)); Assert.That(snapshot.Gases[0].Moles[0], Is.EqualTo(2f)); }); } [Test] - public void AddGasToVoxel_LiveDefaultSpecificHeatCapacityChangeRevaluesExistingFallbackGas() + public void AddGasToVoxel_LiveDefaultMolarHeatCapacityAtConstantVolumeChangeRevaluesExistingFallbackGas() { var config = new AtmosConfig { GasRegistry = [ - new GasProperties { Name = "Fallback", SpecificHeatCapacity = 0f }, - new GasProperties { Name = "Registered", SpecificHeatCapacity = 1f } + new GasProperties { Name = "Fallback", MolarHeatCapacityAtConstantVolume = 0f }, + new GasProperties { Name = "Registered", MolarHeatCapacityAtConstantVolume = 1f } ] }; using var simulation = new AtmosSimulation(config, 1, 1, 1); @@ -411,27 +414,28 @@ public void AddGasToVoxel_LiveDefaultSpecificHeatCapacityChangeRevaluesExistingF simulation.SetChunkClassification(chunk, new VoxelClassification(7)); simulation.AddGasToVoxel(chunk, 0, 0, 0, 0, 1f, 100f); - config.DefaultSpecificHeatCapacity = 4f; + config.DefaultMolarHeatCapacityAtConstantVolume = 4f; simulation.AddGasToVoxel(chunk, 0, 0, 0, 1, 1f, 200f); var snapshot = simulation.GetChunkSnapshot(chunk); Assert.Multiple(() => { Assert.That(snapshot.Temperature[0], Is.EqualTo(120f).Within(0.0001f)); - Assert.That(snapshot.TotalPressure[0], Is.EqualTo(240f).Within(0.0001f)); + Assert.That(snapshot.TotalPressure[0], + Is.EqualTo(ExpectedPressure(2f, 120f)).Within(0.001f)); Assert.That(snapshot.Gases.Select(gas => gas.Moles[0]), Is.EqualTo(new[] { 1f, 1f })); }); } [Test] - public void AddGasToVoxel_UnregisteredGasUsesConfiguredDefaultSpecificHeatCapacity() + public void AddGasToVoxel_UnregisteredGasUsesConfiguredDefaultMolarHeatCapacityAtConstantVolume() { var config = new AtmosConfig { - DefaultSpecificHeatCapacity = 4f, + DefaultMolarHeatCapacityAtConstantVolume = 4f, GasRegistry = [ - new GasProperties { Name = "Registered", SpecificHeatCapacity = 1f } + new GasProperties { Name = "Registered", MolarHeatCapacityAtConstantVolume = 1f } ] }; using var simulation = new AtmosSimulation(config, 1, 1, 1); @@ -445,7 +449,8 @@ public void AddGasToVoxel_UnregisteredGasUsesConfiguredDefaultSpecificHeatCapaci Assert.Multiple(() => { Assert.That(snapshot.Temperature[0], Is.EqualTo(120f).Within(0.0001f)); - Assert.That(snapshot.TotalPressure[0], Is.EqualTo(240f).Within(0.0001f)); + Assert.That(snapshot.TotalPressure[0], + Is.EqualTo(ExpectedPressure(2f, 120f)).Within(0.001f)); }); } @@ -453,15 +458,15 @@ public void AddGasToVoxel_UnregisteredGasUsesConfiguredDefaultSpecificHeatCapaci [TestCase(-2f)] [TestCase(float.NaN)] [TestCase(float.PositiveInfinity)] - public void AddGasToVoxel_InvalidDefaultSpecificHeatCapacityUsesUnitFallback( - float configuredDefaultSpecificHeatCapacity) + public void AddGasToVoxel_InvalidDefaultMolarHeatCapacityAtConstantVolumeUsesDiatomicFallback( + float configuredDefaultMolarHeatCapacityAtConstantVolume) { var config = new AtmosConfig { - DefaultSpecificHeatCapacity = configuredDefaultSpecificHeatCapacity, + DefaultMolarHeatCapacityAtConstantVolume = configuredDefaultMolarHeatCapacityAtConstantVolume, GasRegistry = [ - new GasProperties { Name = "Registered", SpecificHeatCapacity = 4f } + new GasProperties { Name = "Registered", MolarHeatCapacityAtConstantVolume = 4f } ] }; using var simulation = new AtmosSimulation(config, 1, 1, 1); @@ -474,8 +479,11 @@ public void AddGasToVoxel_InvalidDefaultSpecificHeatCapacityUsesUnitFallback( var snapshot = simulation.GetChunkSnapshot(chunk); Assert.Multiple(() => { - Assert.That(snapshot.Temperature[0], Is.EqualTo(180f).Within(0.0001f)); - Assert.That(snapshot.TotalPressure[0], Is.EqualTo(360f).Within(0.0001f)); + float fallback = AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume; + float expectedTemperature = (fallback * 100f + 4f * 200f) / (fallback + 4f); + Assert.That(snapshot.Temperature[0], Is.EqualTo(expectedTemperature).Within(0.0001f)); + Assert.That(snapshot.TotalPressure[0], + Is.EqualTo(ExpectedPressure(2f, expectedTemperature)).Within(0.001f)); }); } @@ -493,7 +501,8 @@ public void AddGasToVoxel_FirstInjectionReplacesNaNEmptyVoxelTemperature() Assert.Multiple(() => { Assert.That(snapshot.Temperature[0], Is.EqualTo(250f)); - Assert.That(snapshot.TotalPressure[0], Is.EqualTo(500f)); + Assert.That(snapshot.TotalPressure[0], + Is.EqualTo(ExpectedPressure(2f, 250f)).Within(0.001f)); Assert.That(snapshot.Gases[0].Moles[0], Is.EqualTo(2f)); }); } @@ -577,7 +586,8 @@ public void GetChunkSnapshot_ReturnsDeepDetachedCopies() Assert.That(first.IsSnapshotValid, Is.True); Assert.That(second.IsSnapshotValid, Is.True); Assert.That(second.GridPosition, Is.EqualTo(chunk.Position)); - Assert.That(second.TotalPressure[0], Is.EqualTo(900f)); + Assert.That(second.TotalPressure[0], + Is.EqualTo(ExpectedPressure(3f, 300f)).Within(0.001f)); Assert.That(second.Temperature[0], Is.EqualTo(300f)); Assert.That(second.VoxelRoomMap[0], Is.EqualTo(9)); Assert.That(second.Gases.Select(gas => gas.GasId), Is.EqualTo(new[] { 3, 7 })); @@ -676,4 +686,40 @@ private static void AssertEveryInvalidCoordinateThrows(Action ope Throws.TypeOf() .With.Property(nameof(ArgumentOutOfRangeException.ParamName)).EqualTo("z")); } -} \ No newline at end of file + + [Test] + public void AddGasToVoxel_ConfiguredVoxelVolumeControlsPressureInPascals() + { + var config = new AtmosConfig { VoxelVolume = 2f }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + + simulation.AddGasToVoxel(chunk, 0, 0, 0, 0, 3f, 400f); + + Assert.That(simulation.GetChunkSnapshot(chunk).TotalPressure[0], + Is.EqualTo(ExpectedPressure(3f, 400f, 2f)).Within(0.001f)); + } + + [TestCase(0f)] + [TestCase(-1f)] + [TestCase(float.NaN)] + [TestCase(float.PositiveInfinity)] + public void AddGasToVoxel_InvalidVoxelVolumeUsesOneCubicMetreFallback(float voxelVolume) + { + var config = new AtmosConfig { VoxelVolume = voxelVolume }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + + simulation.AddGasToVoxel(chunk, 0, 0, 0, 0, 1f, 300f); + + Assert.That(simulation.GetChunkSnapshot(chunk).TotalPressure[0], + Is.EqualTo(ExpectedPressure(1f, 300f)).Within(0.001f)); + } + + private static float ExpectedPressure(float moles, float temperature, float volume = 1f) + { + return (float)((double)moles * AtmosPhysicalConstants.MolarGasConstant * temperature / volume); + } +} diff --git a/tests/Numos.API.Tests/AtmosSimulationTests.cs b/tests/Numos.API.Tests/AtmosSimulationTests.cs index 42c4aea..fcab18f 100644 --- a/tests/Numos.API.Tests/AtmosSimulationTests.cs +++ b/tests/Numos.API.Tests/AtmosSimulationTests.cs @@ -124,7 +124,7 @@ public void Tick_DelegatesToKernelUsingTheFacadeConfiguration() var config = new AtmosConfig { VacuumThreshold = 0f, - MinFlowCutoff = 0f, + MinimumPressureTransfer = 0f, SleepThreshold = int.MaxValue }; using var simulation = new AtmosSimulation(config, 2, 1, 1); diff --git a/tests/Numos.CoreSim.IntegrationTests/CrossChunkFlowTests.cs b/tests/Numos.CoreSim.IntegrationTests/CrossChunkFlowTests.cs index b8c9df7..3ae1052 100644 --- a/tests/Numos.CoreSim.IntegrationTests/CrossChunkFlowTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/CrossChunkFlowTests.cs @@ -81,14 +81,44 @@ public void BoundaryFlow_TransfersEveryGasInSourceProportions() } [Test] - public void BoundaryFlow_WithUnequalSpecificHeatCapacities_ConservesThermalEnergy() + public void BoundaryDiffusion_WakesTargetWhenBulkFlowIsDisabled() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.MaxPressureTransferFractionPerNeighbor = 0f; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.DiffusionCoefficient = 0.1f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = CreateIsolatedVoxel(simulation, default, 0, 0, 0, SimTestHelpers.RoomId); + var target = CreateIsolatedVoxel(simulation, Int3.PosX, 0, 0, 0, SimTestHelpers.RoomId + 1); + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + + simulation.Tick(); + + var sourceSnapshot = simulation.GetChunkSnapshot(source); + var targetSnapshot = simulation.GetChunkSnapshot(target); + Assert.Multiple(() => + { + Assert.That(SimTestHelpers.Moles(sourceSnapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(0.9f).Within(SimTestHelpers.Tolerance)); + Assert.That(SimTestHelpers.Moles(targetSnapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(0.1f).Within(SimTestHelpers.Tolerance)); + Assert.That(targetSnapshot.IsAwake, Is.True); + Assert.That(SimTestHelpers.TotalMoles(sourceSnapshot, targetSnapshot), + Is.EqualTo(1f).Within(SimTestHelpers.Tolerance)); + }); + } + + [Test] + public void BoundaryFlow_WithUnequalMolarHeatCapacities_ConservesThermalEnergy() { var config = SimTestHelpers.CreateDeterministicConfig(); var first = config.GasRegistry[SimTestHelpers.FirstGasId]; - first.SpecificHeatCapacity = 1f; + first.MolarHeatCapacityAtConstantVolume = 1f; config.GasRegistry[SimTestHelpers.FirstGasId] = first; var second = config.GasRegistry[SimTestHelpers.SecondGasId]; - second.SpecificHeatCapacity = 4f; + second.MolarHeatCapacityAtConstantVolume = 4f; config.GasRegistry[SimTestHelpers.SecondGasId] = second; using var simulation = new AtmosSimulation(config, 1, 1, 1); var source = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -267,4 +297,4 @@ private static AtmosChunkHandle CreateIsolatedVoxel(AtmosSimulation simulation, simulation.SetVoxelTemperature(chunk, x, y, z, SimTestHelpers.DefaultTemperature); return chunk; } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs b/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs index 1ad4f1d..82aab48 100644 --- a/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs @@ -7,6 +7,29 @@ namespace Numos.CoreSim.IntegrationTests; [TestFixture] public sealed class IntraChunkFlowTests { + [TestCase(0.5f)] + [TestCase(2f)] + public void BulkFlow_ConvertsPressureBackToMolesForConfiguredVoxelVolume(float voxelVolume) + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.VoxelVolume = voxelVolume; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); + SimTestHelpers.SetAllTemperatures(simulation, chunk, 2, 1, 1); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 2f, 300f); + + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(1.75f).Within(SimTestHelpers.Tolerance)); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 1), + Is.EqualTo(0.25f).Within(SimTestHelpers.Tolerance)); + }); + } + [Test] public void LargePressureDelta_UsesDampedFrictionFlow() { @@ -31,7 +54,7 @@ public void LargePressureDelta_UsesDampedFrictionFlow() } [Test] - public void SmallPressureDelta_UsesCflSnapFlow() + public void SmallPressureDelta_UsesPerNeighborFlowFraction() { var config = SimTestHelpers.CreateDeterministicConfig(); using var simulation = new AtmosSimulation(config, 2, 1, 1); @@ -52,7 +75,7 @@ public void SmallPressureDelta_UsesCflSnapFlow() } [Test] - public void PressureDeltaAtSnapThreshold_UsesDampedFrictionFlow() + public void PressureDeltaAtLowPressureDeltaThreshold_UsesDampedFrictionFlow() { var config = SimTestHelpers.CreateDeterministicConfig(); using var simulation = new AtmosSimulation(config, 2, 1, 1); @@ -76,8 +99,8 @@ public void PressureDeltaAtSnapThreshold_UsesDampedFrictionFlow() public void MinimumFlowCutoff_DiscardsSubCutoffFlow() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.CflFlowCap = 0.01f; - config.MinFlowCutoff = 0.05f; + config.MaxPressureTransferFractionPerNeighbor = 0.01f; + config.MinimumPressureTransfer = 0.05f; using var simulation = new AtmosSimulation(config, 2, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); SimTestHelpers.SetAllTemperatures(simulation, chunk, 2, 1, 1, 4f); @@ -97,8 +120,8 @@ public void MinimumFlowCutoff_DiscardsSubCutoffFlow() public void FlowExactlyAtMinimumCutoff_IsNotDiscarded() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.CflFlowCap = 0.01f; - config.MinFlowCutoff = 0.04f; + config.MaxPressureTransferFractionPerNeighbor = 0.01f; + config.MinimumPressureTransfer = 0.04f; using var simulation = new AtmosSimulation(config, 2, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); SimTestHelpers.SetAllTemperatures(simulation, chunk, 2, 1, 1, 4f); @@ -117,13 +140,13 @@ public void FlowExactlyAtMinimumCutoff_IsNotDiscarded() } [Test] - public void CflCap_LimitsAggressiveFrictionFlow() + public void PerNeighborCap_LimitsAggressiveFrictionFlow() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 1f; - config.DampingFactor = 1f; - config.SnapThreshold = 0f; - config.CflFlowCap = 0.1f; + config.BulkFlowCoefficient = 1f; + config.BulkFlowDamping = 1f; + config.LowPressureDeltaThreshold = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0.1f; using var simulation = new AtmosSimulation(config, 2, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); SimTestHelpers.SetAllTemperatures(simulation, chunk, 2, 1, 1, 100f); @@ -166,14 +189,14 @@ public void BulkFlow_PreservesMultiGasMoleFractionsAndTotalMass() } [Test] - public void BulkFlow_WithUnequalSpecificHeatCapacities_ConservesThermalEnergy() + public void BulkFlow_WithUnequalMolarHeatCapacities_ConservesThermalEnergy() { var config = SimTestHelpers.CreateDeterministicConfig(); var first = config.GasRegistry[SimTestHelpers.FirstGasId]; - first.SpecificHeatCapacity = 1f; + first.MolarHeatCapacityAtConstantVolume = 1f; config.GasRegistry[SimTestHelpers.FirstGasId] = first; var second = config.GasRegistry[SimTestHelpers.SecondGasId]; - second.SpecificHeatCapacity = 4f; + second.MolarHeatCapacityAtConstantVolume = 4f; config.GasRegistry[SimTestHelpers.SecondGasId] = second; using var simulation = new AtmosSimulation(config, 2, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -288,6 +311,65 @@ public void DiffusionCoefficient_AddsSpeciesSpecificTransfer() }); } + [Test] + public void Diffusion_CounterflowsAgainstTotalPressureWhenBulkFlowIsDisabled() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.MaxPressureTransferFractionPerNeighbor = 0f; + var first = config.GasRegistry[SimTestHelpers.FirstGasId]; + first.DiffusionCoefficient = 0.1f; + config.GasRegistry[SimTestHelpers.FirstGasId] = first; + var second = config.GasRegistry[SimTestHelpers.SecondGasId]; + second.DiffusionCoefficient = 0.1f; + config.GasRegistry[SimTestHelpers.SecondGasId] = second; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + SimTestHelpers.SetAllTemperatures(simulation, chunk, 2, 1, 1); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 300f); + simulation.AddGasToVoxel(chunk, 1, 0, 0, SimTestHelpers.SecondGasId, 2f, 300f); + + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(0.9f).Within(SimTestHelpers.Tolerance)); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 1), + Is.EqualTo(0.1f).Within(SimTestHelpers.Tolerance)); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.SecondGasId, 0), + Is.EqualTo(0.2f).Within(SimTestHelpers.Tolerance)); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.SecondGasId, 1), + Is.EqualTo(1.8f).Within(SimTestHelpers.Tolerance)); + Assert.That(SimTestHelpers.TotalMoles(snapshot), + Is.EqualTo(3f).Within(SimTestHelpers.Tolerance)); + }); + } + + [Test] + public void DiffusionCoefficient_AboveOneIsClampedToOne() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.MaxPressureTransferFractionPerNeighbor = 0f; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.DiffusionCoefficient = 2f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + SimTestHelpers.SetAllTemperatures(simulation, chunk, 2, 1, 1); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 300f); + + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), Is.Zero); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 1), + Is.EqualTo(1f).Within(SimTestHelpers.Tolerance)); + }); + } + [Test] public void TwoDimensionalChunk_FlowsOnlyToFourVonNeumannNeighbors() { @@ -435,4 +517,4 @@ public void VacuumCleanup_UsesStrictPressureThreshold(float initialMoles, float Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), Is.EqualTo(expectedMoles).Within(SimTestHelpers.Tolerance)); } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs b/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs index efdbb6c..0dfd1c0 100644 --- a/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs +++ b/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs @@ -12,22 +12,27 @@ internal static class SimTestHelpers internal const int RoomId = 1; internal const float DefaultTemperature = 300f; internal const float Tolerance = 0.0001f; + internal const float EnergyTolerance = 0.001f; internal static AtmosConfig CreateDeterministicConfig() { return new AtmosConfig { DefaultTemperatureFallback = DefaultTemperature, - FlowFriction = 0.25f, - DampingFactor = 0.5f, - SnapThreshold = 5f, - MinFlowCutoff = 0f, + DefaultMolarHeatCapacityAtConstantVolume = 1f, + // Preserve the historical reduced-pressure scale in solver tests while production defaults use SI. + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + SaturationReferencePressure = 1000f, + BulkFlowCoefficient = 0.25f, + BulkFlowDamping = 0.5f, + LowPressureDeltaThreshold = 5f, + MinimumPressureTransfer = 0f, VacuumThreshold = 0f, SleepThreshold = int.MaxValue, SleepEpsilon = 0f, - ThermalConductivity = 0.05f, + ThermalConductance = 0.05f, CondensationRateFactor = 0.5f, - CflFlowCap = 0.16f, + MaxPressureTransferFractionPerNeighbor = 0.16f, GasRegistry = [ new GasProperties { Name = "First", DiffusionCoefficient = 0f }, @@ -83,10 +88,10 @@ internal static float TotalThermalEnergy(AtmosConfig config, params AtmosChunkSnapshot[] snapshots) { var totalEnergy = 0f; - float fallbackSpecificHeatCapacity = float.IsFinite(config.DefaultSpecificHeatCapacity) && - config.DefaultSpecificHeatCapacity > 0f - ? config.DefaultSpecificHeatCapacity - : 1f; + float fallbackMolarHeatCapacityAtConstantVolume = float.IsFinite(config.DefaultMolarHeatCapacityAtConstantVolume) && + config.DefaultMolarHeatCapacityAtConstantVolume > 0f + ? config.DefaultMolarHeatCapacityAtConstantVolume + : AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume; foreach (var snapshot in snapshots) { for (var index = 0; index < snapshot.Temperature.Length; index++) @@ -94,15 +99,15 @@ internal static float TotalThermalEnergy(AtmosConfig config, var heatCapacity = 0f; foreach (var gas in snapshot.Gases) { - float configuredSpecificHeatCapacity = gas.GasId >= 0 && + float configuredMolarHeatCapacityAtConstantVolume = gas.GasId >= 0 && gas.GasId < config.GasRegistry.Count - ? config.GasRegistry[gas.GasId].SpecificHeatCapacity - : fallbackSpecificHeatCapacity; - float specificHeatCapacity = float.IsFinite(configuredSpecificHeatCapacity) && - configuredSpecificHeatCapacity > 0f - ? configuredSpecificHeatCapacity - : fallbackSpecificHeatCapacity; - heatCapacity += gas.Moles[index] * specificHeatCapacity; + ? config.GasRegistry[gas.GasId].MolarHeatCapacityAtConstantVolume + : fallbackMolarHeatCapacityAtConstantVolume; + float molarHeatCapacityAtConstantVolume = float.IsFinite(configuredMolarHeatCapacityAtConstantVolume) && + configuredMolarHeatCapacityAtConstantVolume > 0f + ? configuredMolarHeatCapacityAtConstantVolume + : fallbackMolarHeatCapacityAtConstantVolume; + heatCapacity += gas.Moles[index] * molarHeatCapacityAtConstantVolume; } float storedTemperature = snapshot.Temperature[index]; @@ -115,4 +120,4 @@ internal static float TotalThermalEnergy(AtmosConfig config, return totalEnergy; } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs index c46401f..59ed32e 100644 --- a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs @@ -11,8 +11,8 @@ public sealed class ThermodynamicsIntegrationTests public void IntraChunkThermalDiffusion_RunsOnlyOnEvenTicks() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; using var simulation = new AtmosSimulation(config, 2, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); simulation.SetVoxelTemperature(chunk, 0, 0, 0, 400f); @@ -65,8 +65,8 @@ public void IntraChunkThermalDiffusion_IsBlockedBySolidVoxel() public void IntraChunkThermalDiffusion_IgnoresVacuumVoxel() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; config.VacuumThreshold = 1f; using var simulation = new AtmosSimulation(config, 2, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -85,8 +85,8 @@ public void IntraChunkThermalDiffusion_IgnoresVacuumVoxel() public void CrossChunkThermalDiffusion_TransfersHeatAcrossBoundaryOnEvenTick() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; using var simulation = new AtmosSimulation(config, 1, 1, 1); var hot = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); var cold = SimTestHelpers.CreateOpenChunk(simulation, new Int3(1, 0, 0)); @@ -117,10 +117,10 @@ public void CrossChunkThermalDiffusion_TransfersHeatAcrossBoundaryOnEvenTick() public void CrossChunkThermalDiffusion_LowCapacityVoxelsStopAtEquilibrium() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; - gas.SpecificHeatCapacity = 0.01f; + gas.MolarHeatCapacityAtConstantVolume = 0.01f; config.GasRegistry[SimTestHelpers.FirstGasId] = gas; using var simulation = new AtmosSimulation(config, 1, 1, 1); var hot = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -146,17 +146,55 @@ public void CrossChunkThermalDiffusion_LowCapacityVoxelsStopAtEquilibrium() }); } + [Test] + public void CrossChunkThermalDiffusion_MultipleFacesUseOneSymmetricSnapshot() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; + config.ThermalConductance = 1f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var center = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); + Int3[] neighborPositions = [Int3.NegX, Int3.PosX, Int3.NegY, Int3.PosY]; + AtmosChunkHandle[] neighbors = neighborPositions + .Select(position => SimTestHelpers.CreateOpenChunk(simulation, position)) + .ToArray(); + simulation.AddGasToVoxel(center, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 400f); + foreach (var neighbor in neighbors) + simulation.AddGasToVoxel(neighbor, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 200f); + + simulation.Tick(); + var initialSnapshots = neighbors.Select(simulation.GetChunkSnapshot) + .Prepend(simulation.GetChunkSnapshot(center)) + .ToArray(); + float initialEnergy = SimTestHelpers.TotalThermalEnergy(config, initialSnapshots); + simulation.Tick(); + + var centerSnapshot = simulation.GetChunkSnapshot(center); + var neighborSnapshots = neighbors.Select(simulation.GetChunkSnapshot).ToArray(); + var finalSnapshots = neighborSnapshots.Prepend(centerSnapshot).ToArray(); + Assert.Multiple(() => + { + Assert.That(centerSnapshot.Temperature[0], + Is.EqualTo(200f).Within(SimTestHelpers.Tolerance)); + Assert.That(neighborSnapshots.Select(snapshot => snapshot.Temperature[0]), + Is.All.EqualTo(250f).Within(SimTestHelpers.Tolerance)); + Assert.That(SimTestHelpers.TotalThermalEnergy(config, finalSnapshots), + Is.EqualTo(initialEnergy).Within(SimTestHelpers.Tolerance)); + }); + } + [Test] public void BoundaryGasFlow_OnEvenTickUpdatesCapacitiesBeforeThermalDiffusion() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; var first = config.GasRegistry[SimTestHelpers.FirstGasId]; - first.SpecificHeatCapacity = 1f; + first.MolarHeatCapacityAtConstantVolume = 1f; config.GasRegistry[SimTestHelpers.FirstGasId] = first; var second = config.GasRegistry[SimTestHelpers.SecondGasId]; - second.SpecificHeatCapacity = 4f; + second.MolarHeatCapacityAtConstantVolume = 4f; config.GasRegistry[SimTestHelpers.SecondGasId] = second; using var simulation = new AtmosSimulation(config, 1, 1, 1); var source = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -170,8 +208,8 @@ public void BoundaryGasFlow_OnEvenTickUpdatesCapacitiesBeforeThermalDiffusion() float initialEnergy = SimTestHelpers.TotalThermalEnergy(config, initialSource, initialTarget); simulation.Tick(); - config.FlowFriction = 0.25f; - config.CflFlowCap = 0.16f; + config.BulkFlowCoefficient = 0.25f; + config.MaxPressureTransferFractionPerNeighbor = 0.16f; simulation.Tick(); var sourceSnapshot = simulation.GetChunkSnapshot(source); @@ -185,7 +223,7 @@ public void BoundaryGasFlow_OnEvenTickUpdatesCapacitiesBeforeThermalDiffusion() Assert.That(SimTestHelpers.TotalMoles(sourceSnapshot, targetSnapshot), Is.EqualTo(initialMoles).Within(SimTestHelpers.Tolerance)); Assert.That(SimTestHelpers.TotalThermalEnergy(config, sourceSnapshot, targetSnapshot), - Is.EqualTo(initialEnergy).Within(SimTestHelpers.Tolerance)); + Is.EqualTo(initialEnergy).Within(SimTestHelpers.EnergyTolerance)); }); } @@ -201,8 +239,8 @@ public void CrossChunkThermalDiffusion_MapsEveryFaceToTheOppositeNeighborFace( int targetX, int targetY, int targetZ) { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; using var simulation = new AtmosSimulation(config, 3, 3, 3); var source = CreateIsolatedVoxel(simulation, new Int3(0, 0, 0), sourceX, sourceY, sourceZ, SimTestHelpers.RoomId, 400f); @@ -233,10 +271,10 @@ public void CrossChunkThermalDiffusion_MapsEveryFaceToTheOppositeNeighborFace( public void IntraChunkThermalDiffusion_LowCapacityVoxelsStopAtEquilibrium() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; - gas.SpecificHeatCapacity = 0.01f; + gas.MolarHeatCapacityAtConstantVolume = 0.01f; config.GasRegistry[SimTestHelpers.FirstGasId] = gas; using var simulation = new AtmosSimulation(config, 2, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -265,11 +303,11 @@ public void IntraChunkThermalDiffusion_AggregateOutflowCannotExceedSourceEnergy( { const int size = 3; var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; config.VacuumThreshold = 1f; var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; - gas.SpecificHeatCapacity = 0.01f; + gas.MolarHeatCapacityAtConstantVolume = 0.01f; config.GasRegistry[SimTestHelpers.FirstGasId] = gas; using var simulation = new AtmosSimulation(config, size, size, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -299,8 +337,8 @@ public void DepthOneChunks_DoNotTreatZAsAThermalFlowPlaneWhenAnotherEdgeEmitsAnE const int width = 3; const int height = 3; var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; using var simulation = new AtmosSimulation(config, width, height, 1); var hot = CreateIsolatedVoxel(simulation, new Int3(0, 0, 0), 0, 1, 0, SimTestHelpers.RoomId, 400f); @@ -351,8 +389,8 @@ public void CrossChunkThermalDiffusion_IsBlockedBySolidNeighbor() public void CrossChunkThermalDiffusion_IgnoresVacuumNeighbor() { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; config.VacuumThreshold = 1f; using var simulation = new AtmosSimulation(config, 1, 1, 1); var hot = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -374,7 +412,7 @@ public void CrossChunkThermalDiffusion_IgnoresVacuumNeighbor() } [Test] - public void Condensation_RunsOnEvenTickAndReleasesLatentHeat() + public void Condensation_RunsOnEvenTickAndReleasesInternalPhaseChangeEnergy() { var config = CreateCondensationConfig(); using var simulation = new AtmosSimulation(config, 1, 1, 1); @@ -401,13 +439,13 @@ public void Condensation_RunsOnEvenTickAndReleasesLatentHeat() [TestCase(0f)] [TestCase(-2f)] - public void Condensation_NonPositiveSpecificHeatCapacityUsesConfiguredFallback( - float configuredSpecificHeatCapacity) + public void Condensation_NonPositiveMolarHeatCapacityAtConstantVolumeUsesConfiguredFallback( + float configuredMolarHeatCapacityAtConstantVolume) { var config = CreateCondensationConfig(); - config.DefaultSpecificHeatCapacity = 2f; + config.DefaultMolarHeatCapacityAtConstantVolume = 2f; var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; - gas.SpecificHeatCapacity = configuredSpecificHeatCapacity; + gas.MolarHeatCapacityAtConstantVolume = configuredMolarHeatCapacityAtConstantVolume; config.GasRegistry[SimTestHelpers.FirstGasId] = gas; using var simulation = new AtmosSimulation(config, 1, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -427,11 +465,11 @@ public void Condensation_NonPositiveSpecificHeatCapacityUsesConfiguredFallback( } [Test] - public void Condensation_RequiresPositiveCondensationPointGate() + public void Condensation_RequiresEnabledGate() { var config = CreateCondensationConfig(); var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; - gas.CondensationPoint = 0f; + gas.CondensationEnabled = false; config.GasRegistry[SimTestHelpers.FirstGasId] = gas; using var simulation = new AtmosSimulation(config, 1, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); @@ -467,6 +505,49 @@ public void Condensation_DoesNotOccurAtSaturationPressure() }); } + [Test] + public void CondensationRateFactor_AboveOneIsClampedToOne() + { + var config = CreateCondensationConfig(); + config.CondensationRateFactor = 2f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 10f, 200f); + + simulation.Tick(); + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(5f).Within(SimTestHelpers.Tolerance)); + } + + [Test] + public void Condensation_ClausiusClapeyronExponentUsesMolarGasConstant() + { + const float temperature = 150f; + const float initialMoles = 6f; + var config = CreateCondensationConfig(); + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.MolarEnthalpyOfVaporization = 1000f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, initialMoles, temperature); + + simulation.Tick(); + simulation.Tick(); + + double exponent = -gas.MolarEnthalpyOfVaporization / AtmosPhysicalConstants.MolarGasConstant * + (1d / temperature - 1d / gas.BoilingPoint); + double saturationPressure = config.SaturationReferencePressure * Math.Exp(exponent); + double expectedCondensedMoles = + (initialMoles * temperature - saturationPressure) / temperature * config.CondensationRateFactor; + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(initialMoles - expectedCondensedMoles).Within(SimTestHelpers.Tolerance)); + } + [Test] public void Condensation_SkipsGasMissingFromRegistry() { @@ -494,10 +575,11 @@ private static AtmosConfig CreateCondensationConfig() new GasProperties { Name = "Condensable", - SpecificHeatCapacity = 5f, + MolarHeatCapacityAtConstantVolume = 5f, BoilingPoint = 200f, - CondensationPoint = 1f, - LatentHeatOfVaporization = 10f, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = + AtmosPhysicalConstants.MolarGasConstant * 200f + 10f, LiquidId = 12, DiffusionCoefficient = 0f } @@ -514,4 +596,4 @@ private static AtmosChunkHandle CreateIsolatedVoxel(AtmosSimulation simulation, simulation.SetVoxelTemperature(chunk, x, y, z, temperature); return chunk; } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/TopologyAndSymmetryTests.cs b/tests/Numos.CoreSim.IntegrationTests/TopologyAndSymmetryTests.cs index ad09977..8af289e 100644 --- a/tests/Numos.CoreSim.IntegrationTests/TopologyAndSymmetryTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/TopologyAndSymmetryTests.cs @@ -149,9 +149,9 @@ .. neighbors.Select(position => SimTestHelpers.Index(position.X, position.Y, 0, [TestCase(float.NegativeInfinity)] [TestCase(0f)] [TestCase(-0.05f)] - public void ThermalDiffusion_NonFiniteOrNonPositiveConductivityIsNoOp(float thermalConductivity) + public void ThermalDiffusion_NonFiniteOrNonPositiveConductanceIsNoOp(float thermalConductance) { - var config = CreateThermalOnlyConfig(thermalConductivity); + var config = CreateThermalOnlyConfig(thermalConductance); using var simulation = new AtmosSimulation(config, 2, 1, 1); var chunk = SimTestHelpers.CreateOpenChunk(simulation, new Int3(0, 0, 0)); simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 400f); @@ -182,14 +182,14 @@ private static float[] RunThermalDiffusion(float[] initialTemperatures) return simulation.GetChunkSnapshot(chunk).Temperature; } - private static AtmosConfig CreateThermalOnlyConfig(float thermalConductivity) + private static AtmosConfig CreateThermalOnlyConfig(float thermalConductance) { var config = SimTestHelpers.CreateDeterministicConfig(); - config.FlowFriction = 0f; - config.CflFlowCap = 0f; - config.ThermalConductivity = thermalConductivity; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; + config.ThermalConductance = thermalConductance; var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; - gas.SpecificHeatCapacity = 1f; + gas.MolarHeatCapacityAtConstantVolume = 1f; config.GasRegistry[SimTestHelpers.FirstGasId] = gas; return config; } @@ -213,4 +213,4 @@ private static float[] RunSingleTick(float[] initialMoles) .Select(index => SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, index)) .ToArray(); } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs b/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs index fa8b5bd..34e010a 100644 --- a/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs @@ -1,3 +1,5 @@ +using Numos.CoreSim.Datatypes.Primitives; + namespace Numos.CoreSim.Tests; [TestFixture] @@ -18,7 +20,7 @@ public void InjectGasToVoxel_WhenChunkIsSleeping_DoesNothing() { var chunk = CreateChunk(1, 1, 1); - chunk.InjectGasToVoxel(0, 3, 2f, 300f); + chunk.InjectGasToVoxel(0, 3, 2f, 300f, 1f, 1f); Assert.Multiple(() => { @@ -28,8 +30,8 @@ public void InjectGasToVoxel_WhenChunkIsSleeping_DoesNothing() }); } - [TestCase(AtmosChunk.RoomSolid)] - [TestCase(AtmosChunk.RoomVoid)] + [TestCase(VoxelClassification.RoomSolid)] + [TestCase(VoxelClassification.RoomVoid)] public void InjectGasToVoxel_WhenVoxelCannotHoldGas_DoesNothing(int classification) { var chunk = CreateChunk(2, 1, 1); @@ -37,7 +39,7 @@ public void InjectGasToVoxel_WhenVoxelCannotHoldGas_DoesNothing(int classificati chunk.VoxelRoomMap[1] = classification; chunk.WakeRoom(7); - chunk.InjectGasToVoxel(1, 3, 2f, 300f); + chunk.InjectGasToVoxel(1, 3, 2f, 300f, 1f, 1f); Assert.Multiple(() => { @@ -53,7 +55,7 @@ public void InjectGasToVoxel_FirstInjectionCreatesChannelAndUpdatesVoxelState() var chunk = CreateAwakeChunk(1); chunk.SleepTimer = 9; - chunk.InjectGasToVoxel(0, 3, 2f, 300f); + chunk.InjectGasToVoxel(0, 3, 2f, 300f, 1f, 1f); Assert.Multiple(() => { @@ -71,9 +73,9 @@ public void InjectGasToVoxel_FirstInjectionCreatesChannelAndUpdatesVoxelState() public void InjectGasToVoxel_ExistingGasReusesChannelAndWeightsTemperatureByMoles() { var chunk = CreateAwakeChunk(1); - chunk.InjectGasToVoxel(0, 3, 2f, 300f); + chunk.InjectGasToVoxel(0, 3, 2f, 300f, 1f, 1f); - chunk.InjectGasToVoxel(0, 3, 1f, 600f); + chunk.InjectGasToVoxel(0, 3, 1f, 600f, 1f, 1f); Assert.Multiple(() => { @@ -88,9 +90,9 @@ public void InjectGasToVoxel_ExistingGasReusesChannelAndWeightsTemperatureByMole public void InjectGasToVoxel_DifferentGasCreatesChannelAndUsesTotalMixtureForTemperature() { var chunk = CreateAwakeChunk(1); - chunk.InjectGasToVoxel(0, 3, 2f, 300f); + chunk.InjectGasToVoxel(0, 3, 2f, 300f, 1f, 1f); - chunk.InjectGasToVoxel(0, 8, 1f, 600f); + chunk.InjectGasToVoxel(0, 8, 1f, 600f, 1f, 1f); Assert.Multiple(() => { @@ -109,8 +111,8 @@ public void InjectGasToVoxel_SameGasInDifferentVoxelsSharesChannelButNotValues() { var chunk = CreateAwakeChunk(2); - chunk.InjectGasToVoxel(0, 5, 1f, 250f); - chunk.InjectGasToVoxel(1, 5, 2f, 350f); + chunk.InjectGasToVoxel(0, 5, 1f, 250f, 1f, 1f); + chunk.InjectGasToVoxel(1, 5, 2f, 350f, 1f, 1f); Assert.Multiple(() => { @@ -126,9 +128,9 @@ public void InjectGasToVoxel_ThrowsBeforeExceedingGasChannelCapacity() { var chunk = CreateAwakeChunk(1); for (var gasId = 0; gasId < chunk.ActiveGases.Length; gasId++) - chunk.InjectGasToVoxel(0, gasId, 1f, 300f); + chunk.InjectGasToVoxel(0, gasId, 1f, 300f, 1f, 1f); - Assert.That(() => chunk.InjectGasToVoxel(0, chunk.ActiveGases.Length, 1f, 300f), + Assert.That(() => chunk.InjectGasToVoxel(0, chunk.ActiveGases.Length, 1f, 300f, 1f, 1f), Throws.Exception.With.Message.EqualTo("Maximum unique gas channels reached for this chunk!")); Assert.That(chunk.ActiveGasCount, Is.EqualTo(chunk.ActiveGases.Length)); } diff --git a/tests/Numos.CoreSim.Tests/AtmosChunkSnapshotTests.cs b/tests/Numos.CoreSim.Tests/AtmosChunkSnapshotTests.cs index a601f25..edf5d5a 100644 --- a/tests/Numos.CoreSim.Tests/AtmosChunkSnapshotTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosChunkSnapshotTests.cs @@ -1,3 +1,4 @@ +using Numos.CoreSim.Datatypes.Primitives; using Numos.CoreSim.Datatypes.Snapshots; using Numos.Maths; @@ -43,8 +44,8 @@ public void GetNetworkSnapshot_DeepCopiesChunkAndGasStorage() chunk.Initialize(new Int3(4, -5, 6), 2, 1, 1); chunk.VoxelRoomMap.Fill(7); chunk.WakeRoom(7); - chunk.InjectGasToVoxel(0, 3, 2f, 300f); - chunk.InjectGasToVoxel(1, 8, 1f, 400f); + chunk.InjectGasToVoxel(0, 3, 2f, 300f, 1f, 1f); + chunk.InjectGasToVoxel(1, 8, 1f, 400f, 1f, 1f); var snapshot = chunk.GetNetworkSnapshot(); @@ -62,7 +63,7 @@ public void GetNetworkSnapshot_DeepCopiesChunkAndGasStorage() snapshot.TotalPressure[0] = -1f; snapshot.Temperature[0] = -1f; - snapshot.VoxelRoomMap[0] = AtmosChunk.RoomSolid; + snapshot.VoxelRoomMap[0] = VoxelClassification.RoomSolid; snapshot.Gases[0].Moles[0] = -1f; snapshot.Gases[0].GasId = 99; var freshSnapshot = chunk.GetNetworkSnapshot(); diff --git a/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs b/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs index 9baf735..1ab61ca 100644 --- a/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs @@ -22,7 +22,7 @@ public void Constructor_InitializesStorageAndState() Assert.That(chunk.ActiveAirIndices, Has.Length.EqualTo(24).And.All.Zero); Assert.That(chunk.TotalPressure.ToArray(), Has.Length.EqualTo(24).And.All.Zero); Assert.That(chunk.Temperature.ToArray(), Has.Length.EqualTo(24).And.All.Zero); - Assert.That(chunk.ActiveGases, Has.Length.EqualTo(16)); + Assert.That(chunk.ActiveGases, Has.Length.EqualTo(AtmosChunkConstants.MaximumGasChannelsPerChunk)); Assert.That(chunk.ActiveRoomIds, Has.Length.EqualTo(5).And.All.Zero); Assert.That(chunk.ActiveAirCount, Is.Zero); Assert.That(chunk.ActiveGasCount, Is.Zero); @@ -137,8 +137,8 @@ public void EnsureInitialized_ReusesCorrectlySizedStorageWithoutClearingIt() }); } - [TestCase(AtmosChunk.RoomSolid)] - [TestCase(AtmosChunk.RoomVoid)] + [TestCase(VoxelClassification.RoomSolid)] + [TestCase(VoxelClassification.RoomVoid)] public void WakeRoom_IgnoresReservedNonAirClassifications(int roomId) { var chunk = new AtmosChunk(2, 1, 1) @@ -162,13 +162,13 @@ public void WakeRoom_ActivatesUnassignedVoxels() { var chunk = new AtmosChunk(4, 1, 1); - chunk.WakeRoom(AtmosChunk.RoomUnassigned); + chunk.WakeRoom(VoxelClassification.RoomUnassigned); Assert.Multiple(() => { Assert.That(chunk.IsAwake, Is.True); Assert.That(chunk.ActiveRoomCount, Is.EqualTo(1)); - Assert.That(chunk.ActiveRoomIds[0], Is.EqualTo(AtmosChunk.RoomUnassigned)); + Assert.That(chunk.ActiveRoomIds[0], Is.EqualTo(VoxelClassification.RoomUnassigned)); Assert.That(chunk.ActiveAirCount, Is.EqualTo(4)); Assert.That(chunk.ActiveAirIndices.Take(chunk.ActiveAirCount), Is.EqualTo(new ushort[] { 0, 1, 2, 3 })); }); @@ -178,7 +178,7 @@ public void WakeRoom_ActivatesUnassignedVoxels() public void WakeRoom_BuildsAscendingUnionOfAllActiveRooms() { var chunk = new AtmosChunk(6, 1, 1); - int[] roomIds = [1, 2, 3, 1, 2, AtmosChunk.RoomSolid]; + int[] roomIds = [1, 2, 3, 1, 2, VoxelClassification.RoomSolid]; chunk.VoxelRoomMap.CopyFrom(roomIds); chunk.WakeRoom(1); @@ -256,7 +256,7 @@ public void RebuildActiveAirIndices_ReflectsTopologyChangesWithoutDuplicates() var chunk = new AtmosChunk(5, 1, 1); chunk.VoxelRoomMap.Fill(4); chunk.WakeRoom(4); - chunk.VoxelRoomMap[1] = AtmosChunk.RoomSolid; + chunk.VoxelRoomMap[1] = VoxelClassification.RoomSolid; chunk.VoxelRoomMap[3] = 9; chunk.RebuildActiveAirIndices(); @@ -272,7 +272,7 @@ public void RebuildActiveAirIndices_ReflectsTopologyChangesWithoutDuplicates() public void Sleep_MarksChunkAsNotAwakeWithoutDiscardingTopology() { var chunk = new AtmosChunk(2, 1, 1); - chunk.WakeRoom(AtmosChunk.RoomUnassigned); + chunk.WakeRoom(VoxelClassification.RoomUnassigned); chunk.Sleep(); @@ -284,14 +284,4 @@ public void Sleep_MarksChunkAsNotAwakeWithoutDiscardingTopology() }); } - [Test] - public void RoomConstants_MatchPublicVoxelClassificationConstants() - { - Assert.Multiple(() => - { - Assert.That(AtmosChunk.RoomUnassigned, Is.EqualTo(VoxelClassification.RoomUnassigned)); - Assert.That(AtmosChunk.RoomSolid, Is.EqualTo(VoxelClassification.RoomSolid)); - Assert.That(AtmosChunk.RoomVoid, Is.EqualTo(VoxelClassification.RoomVoid)); - }); - } } \ No newline at end of file diff --git a/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs b/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs index aed956a..942697c 100644 --- a/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs @@ -11,22 +11,35 @@ public void Constructor_UsesDocumentedSimulationDefaults() Assert.Multiple(() => { Assert.That(config.GasRegistry, Is.Not.Null.And.Empty); - Assert.That(config.GlobalTemperature, Is.EqualTo(293.15f)); - Assert.That(config.DefaultTemperatureFallback, Is.EqualTo(293.15f)); - Assert.That(config.DefaultSpecificHeatCapacity, Is.EqualTo(1f)); - Assert.That(config.SpaceTemperature, Is.EqualTo(2.7f)); - Assert.That(config.FlowFriction, Is.EqualTo(0.25f)); - Assert.That(config.DampingFactor, Is.EqualTo(0.5f)); - Assert.That(config.SnapThreshold, Is.EqualTo(5f)); - Assert.That(config.MinFlowCutoff, Is.EqualTo(0.1f)); - Assert.That(config.VacuumThreshold, Is.EqualTo(1f)); - Assert.That(config.SleepThreshold, Is.EqualTo(100)); - Assert.That(config.SleepEpsilon, Is.EqualTo(3.5f)); - Assert.That(config.ThermalConductivity, Is.EqualTo(0.05f)); - Assert.That(config.CondensationRateFactor, Is.EqualTo(0.5f)); - Assert.That(config.CflFlowCap, Is.EqualTo(0.16f)); - Assert.That(config.AccumulatorWakeThreshold, Is.EqualTo(15f)); - Assert.That(config.AccumulatorMaxAliveTicks, Is.EqualTo(20)); + Assert.That(config.GlobalTemperature, Is.EqualTo(AtmosConfigDefaults.GlobalTemperature)); + Assert.That(config.DefaultTemperatureFallback, + Is.EqualTo(AtmosConfigDefaults.DefaultTemperatureFallback)); + Assert.That(config.DefaultMolarHeatCapacityAtConstantVolume, + Is.EqualTo(AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume)); + Assert.That(config.VoxelVolume, Is.EqualTo(AtmosConfigDefaults.VoxelVolume)); + Assert.That(config.SaturationReferencePressure, + Is.EqualTo(AtmosConfigDefaults.SaturationReferencePressure)); + Assert.That(config.DefaultDiffusionCoefficient, + Is.EqualTo(AtmosConfigDefaults.DefaultDiffusionCoefficient)); + Assert.That(config.SpaceTemperature, Is.EqualTo(AtmosConfigDefaults.SpaceTemperature)); + Assert.That(config.BulkFlowCoefficient, Is.EqualTo(AtmosConfigDefaults.BulkFlowCoefficient)); + Assert.That(config.BulkFlowDamping, Is.EqualTo(AtmosConfigDefaults.BulkFlowDamping)); + Assert.That(config.LowPressureDeltaThreshold, + Is.EqualTo(AtmosConfigDefaults.LowPressureDeltaThreshold)); + Assert.That(config.MinimumPressureTransfer, + Is.EqualTo(AtmosConfigDefaults.MinimumPressureTransfer)); + Assert.That(config.VacuumThreshold, Is.EqualTo(AtmosConfigDefaults.VacuumThreshold)); + Assert.That(config.SleepThreshold, Is.EqualTo(AtmosConfigDefaults.SleepThreshold)); + Assert.That(config.SleepEpsilon, Is.EqualTo(AtmosConfigDefaults.SleepEpsilon)); + Assert.That(config.ThermalConductance, Is.EqualTo(AtmosConfigDefaults.ThermalConductance)); + Assert.That(config.CondensationRateFactor, + Is.EqualTo(AtmosConfigDefaults.CondensationRateFactor)); + Assert.That(config.MaxPressureTransferFractionPerNeighbor, + Is.EqualTo(AtmosConfigDefaults.MaxPressureTransferFractionPerNeighbor)); + Assert.That(config.AccumulatorWakeThreshold, + Is.EqualTo(AtmosConfigDefaults.AccumulatorWakeThreshold)); + Assert.That(config.AccumulatorMaxAliveTicks, + Is.EqualTo(AtmosConfigDefaults.AccumulatorMaxAliveTicks)); }); } diff --git a/tests/Numos.CoreSim.Tests/RoomNodeTests.cs b/tests/Numos.CoreSim.Tests/RoomNodeTests.cs index 2a96853..964d9be 100644 --- a/tests/Numos.CoreSim.Tests/RoomNodeTests.cs +++ b/tests/Numos.CoreSim.Tests/RoomNodeTests.cs @@ -8,13 +8,14 @@ public void AddGas_ToEmptyRoom_SetsTemperatureMolesAndPressure() { var room = CreateRoom(10, 3); - room.AddGas(1, 4f, 300f); + room.AddGas(1, 4f, 300f, 1f); Assert.Multiple(() => { Assert.That(room.GasMoles, Is.EqualTo(new[] { 0f, 4f, 0f })); Assert.That(room.AverageTemperature, Is.EqualTo(300f)); - Assert.That(room.EquilibriumPressure, Is.EqualTo(120f)); + Assert.That(room.EquilibriumPressure, + Is.EqualTo(4f * AtmosPhysicalConstants.MolarGasConstant * 300f / 10f).Within(0.001f)); }); } @@ -23,14 +24,15 @@ public void AddGas_ToExistingMixture_UsesMoleWeightedTemperatureAndTotalMoles() { var room = CreateRoom(2, 3); - room.AddGas(0, 2f, 300f); - room.AddGas(2, 1f, 600f); + room.AddGas(0, 2f, 300f, 1f); + room.AddGas(2, 1f, 600f, 1f); Assert.Multiple(() => { Assert.That(room.GasMoles, Is.EqualTo(new[] { 2f, 0f, 1f })); Assert.That(room.AverageTemperature, Is.EqualTo(400f).Within(0.0001f)); - Assert.That(room.EquilibriumPressure, Is.EqualTo(600f).Within(0.0001f)); + Assert.That(room.EquilibriumPressure, + Is.EqualTo(3f * AtmosPhysicalConstants.MolarGasConstant * 400f / 2f).Within(0.001f)); }); } @@ -38,16 +40,17 @@ public void AddGas_ToExistingMixture_UsesMoleWeightedTemperatureAndTotalMoles() public void RemoveGas_RemovesRequestedMolesWithoutChangingTemperature() { var room = CreateRoom(4, 2); - room.AddGas(0, 2f, 300f); - room.AddGas(1, 2f, 300f); + room.AddGas(0, 2f, 300f, 1f); + room.AddGas(1, 2f, 300f, 1f); - room.RemoveGas(0, 1f); + room.RemoveGas(0, 1f, 1f); Assert.Multiple(() => { Assert.That(room.GasMoles, Is.EqualTo(new[] { 1f, 2f })); Assert.That(room.AverageTemperature, Is.EqualTo(300f)); - Assert.That(room.EquilibriumPressure, Is.EqualTo(225f)); + Assert.That(room.EquilibriumPressure, + Is.EqualTo(3f * AtmosPhysicalConstants.MolarGasConstant * 300f / 4f).Within(0.001f)); }); } @@ -55,16 +58,17 @@ public void RemoveGas_RemovesRequestedMolesWithoutChangingTemperature() public void RemoveGas_MoreThanAvailable_ClampsToSpeciesMoles() { var room = CreateRoom(2, 2); - room.AddGas(0, 1f, 300f); - room.AddGas(1, 3f, 300f); + room.AddGas(0, 1f, 300f, 1f); + room.AddGas(1, 3f, 300f, 1f); - room.RemoveGas(0, 10f); + room.RemoveGas(0, 10f, 1f); Assert.Multiple(() => { Assert.That(room.GasMoles, Is.EqualTo(new[] { 0f, 3f })); Assert.That(room.AverageTemperature, Is.EqualTo(300f)); - Assert.That(room.EquilibriumPressure, Is.EqualTo(450f)); + Assert.That(room.EquilibriumPressure, + Is.EqualTo(3f * AtmosPhysicalConstants.MolarGasConstant * 300f / 2f).Within(0.001f)); }); } @@ -72,9 +76,9 @@ public void RemoveGas_MoreThanAvailable_ClampsToSpeciesMoles() public void RemoveGas_LastMoles_SetsPressureToZeroAndRetainsTemperature() { var room = CreateRoom(1, 3); - room.AddGas(2, 2f, 250f); + room.AddGas(2, 2f, 250f, 1f); - room.RemoveGas(2, 2f); + room.RemoveGas(2, 2f, 1f); Assert.Multiple(() => { @@ -84,14 +88,44 @@ public void RemoveGas_LastMoles_SetsPressureToZeroAndRetainsTemperature() }); } - private static RoomNode CreateRoom(int volume, int gasCount) + [Test] + public void AddGas_VoxelVolumeControlsAggregatePressure() + { + var room = CreateRoom(2, 1); + room.VoxelVolume = 0.5f; + + room.AddGas(0, 1f, 300f, 1f); + + Assert.That(room.EquilibriumPressure, + Is.EqualTo(AtmosPhysicalConstants.MolarGasConstant * 300f).Within(0.001f)); + } + + [Test] + public void AddGas_UsesHeatCapacityWeightedTemperature() + { + var room = CreateRoom(1, 2); + room.AddGas(0, 1f, 100f, 1f); + + room.AddGas(1, 1f, 200f, 4f); + + Assert.Multiple(() => + { + Assert.That(room.AverageTemperature, Is.EqualTo(180f).Within(0.0001f)); + Assert.That(room.TotalHeatCapacity, Is.EqualTo(5f)); + Assert.That(room.EquilibriumPressure, + Is.EqualTo(2f * AtmosPhysicalConstants.MolarGasConstant * 180f).Within(0.001f)); + }); + } + + private static RoomNode CreateRoom(int voxelCount, int gasCount) { return new RoomNode { RoomId = 7, IsAsleep = true, - TotalVoxelVolume = volume, + VoxelCount = voxelCount, + VoxelVolume = 1f, GasMoles = new float[gasCount] }; } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.Tests/VoxelClassificationTests.cs b/tests/Numos.CoreSim.Tests/VoxelClassificationTests.cs index dc648c7..195ee78 100644 --- a/tests/Numos.CoreSim.Tests/VoxelClassificationTests.cs +++ b/tests/Numos.CoreSim.Tests/VoxelClassificationTests.cs @@ -66,14 +66,4 @@ public void RecordEquality_UsesRoomId() }); } - [Test] - public void ReservedConstants_MatchInternalChunkClassifications() - { - Assert.Multiple(() => - { - Assert.That(VoxelClassification.RoomUnassigned, Is.EqualTo(AtmosChunk.RoomUnassigned)); - Assert.That(VoxelClassification.RoomSolid, Is.EqualTo(AtmosChunk.RoomSolid)); - Assert.That(VoxelClassification.RoomVoid, Is.EqualTo(AtmosChunk.RoomVoid)); - }); - } } \ No newline at end of file From baac26d6ae5f73686129383f0b1ef06621b9da83 Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:35:15 -0500 Subject: [PATCH 02/14] Added gas mixture simulation capabilities to AtmosKernel and AtmosSimulation. --- README.md | 1 + docs/atmospherics_technical_documentation.md | 44 +- src/Numos.API/AtmosSimulation.GasMixtures.cs | 621 ++++++++++++++++++ src/Numos.API/AtmosSimulation.cs | 22 +- src/Numos.API/GasMixture.cs | 98 +++ src/Numos.API/GasMixtureState.cs | 44 ++ src/Numos.API/IGasMixture.cs | 111 ++++ src/Numos.API/VoxelGasMixture.cs | 61 ++ src/Numos.CoreSim/AtmosChunk.cs | 48 +- src/Numos.CoreSim/AtmosChunkConstants.cs | 5 +- src/Numos.CoreSim/AtmosKernel.GasMixtures.cs | 245 +++++++ tests/Numos.API.Tests/GasMixtureTests.cs | 411 ++++++++++++ .../AtmosChunkInjectionTests.cs | 15 +- .../AtmosChunkTopologyTests.cs | 2 +- 14 files changed, 1686 insertions(+), 42 deletions(-) create mode 100644 src/Numos.API/AtmosSimulation.GasMixtures.cs create mode 100644 src/Numos.API/GasMixture.cs create mode 100644 src/Numos.API/GasMixtureState.cs create mode 100644 src/Numos.API/IGasMixture.cs create mode 100644 src/Numos.API/VoxelGasMixture.cs create mode 100644 src/Numos.CoreSim/AtmosKernel.GasMixtures.cs create mode 100644 tests/Numos.API.Tests/GasMixtureTests.cs diff --git a/README.md b/README.md index 70f6c61..9c8a1ac 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The project will follow a regular semantic versioning structure when I feel comf - Singlethreaded cross-chunk boundary flow - Ideal-gas pressure in pascals (`P = nRT/V`) with configurable, uniform voxel volume - Sensible internal-energy transport using per-species molar heat capacity at constant volume +- Simulation-owned `IGasMixture` containers and sandboxed live voxel mixtures for canisters, pumps, and tools - Attempts at being trimmable and Native AOT-compatible ## Bug Reports & Contributions diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index c908450..7a6f845 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -19,6 +19,7 @@ - 3.4 [Room Nodes (Macro Layer)](#34-room-nodes-macro-layer) - 3.5 [Gas Properties Registry](#35-gas-properties-registry) - 3.6 [Configuration Parameters](#36-configuration-parameters) + - 3.7 [Container and Voxel Gas Mixtures](#37-container-and-voxel-gas-mixtures) 4. [Simulation Loop](#4-simulation-loop) - 4.1 [Fixed Timestep Accumulator](#41-fixed-timestep-accumulator) - 4.2 [Phase 1 — Pressure Advection](#42-phase-1--pressure-advection) @@ -168,7 +169,7 @@ Key properties: - **Lazy allocation**: A `GasChannel` is only created when that gas type is first introduced to a chunk via `InjectGasToVoxel`. A chunk containing only oxygen will have one channel; a chunk containing oxygen, nitrogen, and plasma will have three. - **ArrayPool rental**: The `Moles` array is rented from `System.Buffers.ArrayPool` and cleared to zero on allocation. This avoids GC pressure from repeated allocations. The array must be explicitly returned via `Release()`. -- **Fixed capacity**: The `ActiveGases` array has a fixed capacity (default 16 slots). If more than 16 unique gas types are injected into a single chunk, the system throws an exception. There is no resize logic. +- **Growable channel table**: `ActiveGases` begins with `AtmosChunkConstants.InitialGasChannelCapacity` slots (currently 16) and doubles only when another distinct gas ID reaches the chunk. Existing per-gas mole arrays remain untouched, preserving the structure-of-arrays solver layout while permitting arbitrary gas IDs and counts. > [!NOTE] > The `ArrayPool` may return an array larger than requested. Only the first `VoxelCount` entries are used. Implementations should clear only the requested range. @@ -233,7 +234,7 @@ All tunable simulation parameters are centralized in a configuration object: The literals backing these defaults are exposed through `AtmosConfigDefaults`, while immutable SI and reference condition values are exposed through `AtmosPhysicalConstants`. Internal fixed-step scheduling values and numerical cutoffs live in `AtmosSolverConstants`; they are deliberately not presented as runtime configuration. Default chunk -dimensions and hard chunk capacities are exposed through `AtmosChunkConstants`, while reserved room IDs have a +dimensions and initial chunk capacities are exposed through `AtmosChunkConstants`, while reserved room IDs have a single definition in `VoxelClassification`. | Parameter | Default | Description | @@ -256,6 +257,45 @@ single definition in `VoxelClassification`. | `CondensationRateFactor` | 0.5 | Dimensionless fraction of supersaturated vapor condensed per thermodynamics tick. Finite values are clamped to [0, 1]; non-finite values disable condensation. | | `MaxPressureTransferFractionPerNeighbor` | 0.16 | Maximum fraction of a voxel's pressure requested as bulk flow to one neighbor per tick. Finite values are clamped to [0, 1]; non-finite values disable bulk flow. | +### 3.7 Container and Voxel Gas Mixtures + +`IGasMixture` provides one public interaction model for portable containers and individual voxels while preserving +the solver's structure-of-arrays layout: + +- `AtmosSimulation.CreateGasMixture(volume, temperature)` returns a concrete `GasMixture` with independent sparse + storage. Its `Volume` can be changed, and it is suitable for canisters, tanks, pipes, pumps, or temporary parcels. +- `AtmosSimulation.GetVoxelGasMixture(...)` returns an `IGasMixture` capability over one live voxel. It does not + contain or expose spans, gas-channel arrays, or references into pooled solver memory. +- Every mixture retains its owning `AtmosSimulation`. Transfers require both endpoints to have the same owner, so + gas IDs and molar heat capacities are interpreted through one live configuration. +- `IGasMixture` is a common capability surface rather than an extension point. Transfer endpoints must be mixtures + created by `AtmosSimulation`; external implementations are rejected before either endpoint changes. +- A voxel capability records the chunk generation at creation. Removing and recreating a chunk at the same position + makes the old capability stale instead of silently retargeting it to unrelated state. +- Voxel reads and mutations enter the simulation state lock. Multi-endpoint transfers capture and validate both + results before committing, preventing simulation ticks from observing a half-applied transfer. +- Solid and void voxels can be inspected but reject mutation. Disposing the owner invalidates both container and + voxel mixtures. + +The common surface exposes volume, temperature, pressure, total moles, sparse gas lookup, snapshots, proportional +removal, and transfer operations. `SetMoles` and `AdjustMoles` intentionally preserve the stored temperature for +low-level tooling parity. `AddGas` and transfers instead conserve sensible internal energy using each gas's effective +constant-volume molar heat capacity. Pressure is always derived from `P = nRT/V` rather than being independently +mutable. + +```csharp +var canister = simulation.CreateGasMixture(volume: 0.07f, temperature: 293.15f); +canister.AddGas(oxygenId, moles: 2f, temperature: 293.15f); + +IGasMixture voxel = simulation.GetVoxelGasMixture(chunk, x: 4, y: 3, z: 0); +float moved = canister.TransferTo(voxel, moles: 0.5f); +GasMixture sample = voxel.RemoveRatio(0.1f); +``` + +The API follows the useful container semantics of +[SS14's `GasMixture`](https://github.com/space-wizards/space-station-14/blob/master/Content.Shared/Atmos/GasMixture.cs) +while replacing its globally sized per-mixture gas array with sparse container storage and locked SoA voxel access. + --- ## 4. Simulation Loop diff --git a/src/Numos.API/AtmosSimulation.GasMixtures.cs b/src/Numos.API/AtmosSimulation.GasMixtures.cs new file mode 100644 index 0000000..a089632 --- /dev/null +++ b/src/Numos.API/AtmosSimulation.GasMixtures.cs @@ -0,0 +1,621 @@ +using JetBrains.Annotations; +using Numos.CoreSim; + +namespace Numos.API; + +public sealed partial class AtmosSimulation +{ + private readonly object _mixtureGate = new(); + + /// Creates an empty, independently stored gas mixture owned by this simulation. + /// Container volume in cubic metres (m³). + /// Initial temperature in kelvins (K). + /// + /// The volume is not positive and finite, or the temperature is negative or non-finite. + /// + /// The simulation has been disposed. + [PublicAPI] + public GasMixture CreateGasMixture( + float volume, + float temperature = AtmosPhysicalConstants.RoomTemperature) + { + ValidateVolume(volume); + ValidateTemperature(temperature); + lock (_mixtureGate) + { + ThrowIfDisposed(); + return new GasMixture(this, new GasMixtureState(volume, temperature)); + } + } + + /// Creates sandboxed live access to one voxel's gas mixture. + /// + /// The returned capability never exposes solver arrays. It is bound to the current chunk generation and + /// becomes stale if that chunk is removed or replaced at the same grid position. + /// + [PublicAPI] + public IGasMixture GetVoxelGasMixture(AtmosChunkHandle chunk, ushort localVoxelIndex) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + var identity = _kernel.GetVoxelMixtureIdentity(chunk.Position, localVoxelIndex); + return new VoxelGasMixture(this, chunk.Position, identity.Generation, identity.LocalVoxelIndex); + } + } + + /// Creates sandboxed live access to one voxel addressed by local coordinates. + [PublicAPI] + public IGasMixture GetVoxelGasMixture(AtmosChunkHandle chunk, int x, int y, int z) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + var identity = _kernel.GetVoxelMixtureIdentity(chunk.Position, x, y, z); + return new VoxelGasMixture(this, chunk.Position, identity.Generation, identity.LocalVoxelIndex); + } + } + + internal float GetMixtureVolume(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).Volume; + internal float GetMixtureTemperature(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).Temperature; + internal float GetMixturePressure(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).Pressure; + internal float GetMixtureTotalMoles(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).TotalMoles; + internal int GetMixtureActiveGasCount(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).ActiveGasCount; + + internal float GetMixtureMoles(IInternalGasMixture mixture, int gasId) + { + ArgumentOutOfRangeException.ThrowIfNegative(gasId); + lock (_mixtureGate) + { + ThrowIfDisposed(); + ValidateOwnedMixture(mixture, nameof(mixture)); + return mixture switch + { + GasMixture owned => owned.State.Moles.GetValueOrDefault(gasId), + VoxelGasMixture voxel => _kernel.GetVoxelMixtureMoles( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex, + gasId), + _ => throw CreateUnsupportedMixtureException(nameof(mixture)) + }; + } + } + + internal void SetMixtureVolume(GasMixture mixture, float volume) + { + ValidateVolume(volume); + lock (_mixtureGate) + { + ThrowIfDisposed(); + ValidateOwnedMixture(mixture, nameof(mixture)); + var state = mixture.CaptureState(); + state.Volume = volume; + ValidateState(state); + mixture.ApplyState(state); + } + } + + internal void SetMixtureTemperature(IInternalGasMixture mixture, float temperature) + { + ValidateTemperature(temperature); + MutateMixture(mixture, state => state.Temperature = temperature); + } + + internal void SetMixtureMoles(IInternalGasMixture mixture, int gasId, float moles) + { + ValidateGasId(gasId); + ValidateNonnegativeFinite(moles, nameof(moles)); + MutateMixture(mixture, state => SetStateMoles(state, gasId, moles)); + } + + internal void AdjustMixtureMoles(IInternalGasMixture mixture, int gasId, float deltaMoles) + { + ValidateGasId(gasId); + if (!float.IsFinite(deltaMoles)) + throw new ArgumentOutOfRangeException(nameof(deltaMoles), deltaMoles, "Mole adjustment must be finite."); + + MutateMixture(mixture, state => + { + double adjusted = state.Moles.GetValueOrDefault(gasId) + (double)deltaMoles; + if (!double.IsFinite(adjusted) || adjusted > float.MaxValue) + throw new InvalidOperationException("The adjusted gas amount exceeds the supported range."); + SetStateMoles(state, gasId, (float)Math.Max(0d, adjusted)); + }); + } + + internal void AddGasToMixture(IInternalGasMixture mixture, int gasId, float moles, float temperature) + { + ValidateGasId(gasId); + ValidatePositiveFinite(moles, nameof(moles)); + ValidateTemperature(temperature); + + MutateMixture(mixture, destination => + { + var incoming = new GasMixtureState(destination.Volume, temperature); + incoming.Moles.Add(gasId, moles); + MergeStates(destination, incoming); + }); + } + + internal void ClearMixture(IInternalGasMixture mixture) + { + MutateMixture(mixture, static state => state.Moles.Clear()); + } + + internal GasMixture RemoveFromMixture(IInternalGasMixture mixture, float moles) + { + ValidateNonnegativeFinite(moles, nameof(moles)); + return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => + { + ValidateOwnedMixture(mixture, nameof(mixture)); + var source = CaptureMixtureState(mixture); + float totalMoles = source.TotalMoles; + float ratio = totalMoles > 0f ? MathF.Min(1f, moles / totalMoles) : 0f; + return RemoveRatioCore(mixture, source, ratio); + }); + } + + internal GasMixture RemoveRatioFromMixture(IInternalGasMixture mixture, float ratio) + { + ValidateFinite(ratio, nameof(ratio)); + return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => + { + ValidateOwnedMixture(mixture, nameof(mixture)); + return RemoveRatioCore(mixture, CaptureMixtureState(mixture), Math.Clamp(ratio, 0f, 1f)); + }); + } + + internal GasMixture RemoveVolumeFromMixture(IInternalGasMixture mixture, float volume) + { + ValidateNonnegativeFinite(volume, nameof(volume)); + return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => + { + ValidateOwnedMixture(mixture, nameof(mixture)); + var source = CaptureMixtureState(mixture); + float ratio = Math.Clamp(volume / source.Volume, 0f, 1f); + return RemoveRatioCore(mixture, source, ratio); + }); + } + + internal float TransferMixture(IInternalGasMixture source, IGasMixture destination, float moles) + { + ValidateNonnegativeFinite(moles, nameof(moles)); + var internalDestination = GetOwnedMixture(destination, nameof(destination)); + return ExecuteTransfer(source, internalDestination, state => + { + float totalMoles = state.TotalMoles; + return totalMoles > 0f ? MathF.Min(1f, moles / totalMoles) : 0f; + }); + } + + internal float TransferMixtureRatio(IInternalGasMixture source, IGasMixture destination, float ratio) + { + ValidateFinite(ratio, nameof(ratio)); + var internalDestination = GetOwnedMixture(destination, nameof(destination)); + return ExecuteTransfer(source, internalDestination, _ => Math.Clamp(ratio, 0f, 1f)); + } + + internal GasMixture CloneMixture(IInternalGasMixture mixture) + { + return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => + { + ValidateOwnedMixture(mixture, nameof(mixture)); + return new GasMixture(this, CaptureMixtureState(mixture)); + }); + } + + internal GasMixtureSnapshot GetMixtureSnapshot(IInternalGasMixture mixture) + { + return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => + { + ValidateOwnedMixture(mixture, nameof(mixture)); + var state = CaptureMixtureState(mixture); + var gases = new GasMixtureGas[state.ActiveGasCount]; + var index = 0; + foreach (var (gasId, moles) in state.Moles) + gases[index++] = new GasMixtureGas(gasId, moles); + + return new GasMixtureSnapshot( + state.Volume, + state.Temperature, + CalculateMixturePressure(state), + state.TotalMoles, + gases); + }); + } + + private MixtureMetrics GetMixtureMetrics(IInternalGasMixture mixture) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + ValidateOwnedMixture(mixture, nameof(mixture)); + if (mixture is GasMixture owned) + { + var state = owned.State; + return new MixtureMetrics( + state.Volume, + state.Temperature, + CalculateMixturePressure(state), + state.TotalMoles, + state.ActiveGasCount); + } + + if (mixture is VoxelGasMixture voxel) + { + var metrics = _kernel.GetVoxelMixtureMetrics( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex); + return new MixtureMetrics( + metrics.Volume, + metrics.Temperature, + metrics.Pressure, + metrics.TotalMoles, + metrics.ActiveGasCount); + } + + throw CreateUnsupportedMixtureException(nameof(mixture)); + } + } + + private void MutateMixture(IInternalGasMixture mixture, Action mutation) + { + ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => + { + ValidateOwnedMixture(mixture, nameof(mixture)); + ValidateMixtureCanMutate(mixture); + var state = CaptureMixtureState(mixture); + mutation(state); + ValidateState(state); + ApplyMixtureState(mixture, state); + return true; + }); + } + + private GasMixture RemoveRatioCore(IInternalGasMixture mixture, GasMixtureState source, float ratio) + { + var removed = new GasMixtureState(source.Volume, source.Temperature); + if (ratio <= 0f || source.ActiveGasCount == 0) + return new GasMixture(this, removed); + + ValidateMixtureCanMutate(mixture); + foreach (var (gasId, sourceMoles) in source.Moles.ToArray()) + { + float removedMoles = sourceMoles * ratio; + float remainingMoles = sourceMoles - removedMoles; + if (removedMoles > 0f) + removed.Moles.Add(gasId, removedMoles); + SetStateMoles(source, gasId, remainingMoles); + } + + ValidateState(source); + ValidateState(removed); + ApplyMixtureState(mixture, source); + return new GasMixture(this, removed); + } + + private float ExecuteTransfer( + IInternalGasMixture source, + IInternalGasMixture destination, + Func getRatio) + { + ValidateOwnedMixture(source, nameof(source)); + if (IsSameMixture(source, destination)) + return 0f; + + bool usesVoxel = source is VoxelGasMixture || destination is VoxelGasMixture; + return ExecuteMixtureTransaction(usesVoxel, () => + { + ValidateOwnedMixture(source, nameof(source)); + ValidateOwnedMixture(destination, nameof(destination)); + + var sourceState = CaptureMixtureState(source); + float ratio = getRatio(sourceState); + if (ratio <= 0f || sourceState.ActiveGasCount == 0) + return 0f; + + ValidateMixturesCanMutate(source, destination); + var destinationState = CaptureMixtureState(destination); + var removed = RemoveRatioFromState(sourceState, ratio); + MergeStates(destinationState, removed); + ValidateState(sourceState); + ValidateState(destinationState); + + ApplyMixtureState(source, sourceState); + ApplyMixtureState(destination, destinationState); + return removed.TotalMoles; + }); + } + + private TResult ExecuteMixtureTransaction(bool usesVoxel, Func transaction) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + if (!usesVoxel) + return transaction(); + + TResult result = default!; + _kernel.ExecuteMixtureTransaction(() => result = transaction()); + return result; + } + } + + private GasMixtureState CaptureMixtureState(IInternalGasMixture mixture) + { + if (mixture is GasMixture owned) + return owned.CaptureState(); + + if (mixture is VoxelGasMixture voxel) + { + var voxelState = _kernel.CaptureVoxelMixture( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex); + var state = new GasMixtureState(voxelState.Volume, voxelState.Temperature); + foreach (var (gasId, moles) in voxelState.Gases) + state.Moles.Add(gasId, moles); + return state; + } + + throw CreateUnsupportedMixtureException(nameof(mixture)); + } + + private void ApplyMixtureState(IInternalGasMixture mixture, GasMixtureState state) + { + if (mixture is GasMixture owned) + { + owned.ApplyState(state); + return; + } + + if (mixture is VoxelGasMixture voxel) + { + _kernel.ReplaceVoxelMixture( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex, + state.Temperature, + state.ToGasArray()); + return; + } + + throw CreateUnsupportedMixtureException(nameof(mixture)); + } + + private void ValidateMixtureCanMutate(IInternalGasMixture mixture) + { + if (mixture is not VoxelGasMixture voxel) + return; + _kernel.ValidateVoxelMixtureMutations([GetVoxelAddress(voxel)]); + } + + private void ValidateMixturesCanMutate( + IInternalGasMixture first, + IInternalGasMixture second) + { + if (first is VoxelGasMixture firstVoxel && second is VoxelGasMixture secondVoxel) + { + _kernel.ValidateVoxelMixtureMutations( + [GetVoxelAddress(firstVoxel), GetVoxelAddress(secondVoxel)]); + return; + } + + ValidateMixtureCanMutate(first); + ValidateMixtureCanMutate(second); + } + + private static VoxelGasMixtureAddress GetVoxelAddress(VoxelGasMixture mixture) + { + return new VoxelGasMixtureAddress( + mixture.ChunkPosition, + mixture.ChunkGeneration, + mixture.LocalVoxelIndex); + } + + private void MergeStates(GasMixtureState destination, GasMixtureState incoming) + { + if (incoming.ActiveGasCount == 0) + return; + + double destinationHeatCapacity = CalculateMixtureHeatCapacity(destination); + double incomingHeatCapacity = CalculateMixtureHeatCapacity(incoming); + double combinedHeatCapacity = destinationHeatCapacity + incomingHeatCapacity; + float mixedTemperature = combinedHeatCapacity > 0d + ? (float)((destinationHeatCapacity * GetEffectiveMixtureTemperature(destination.Temperature) + + incomingHeatCapacity * GetEffectiveMixtureTemperature(incoming.Temperature)) / + combinedHeatCapacity) + : incoming.Temperature; + + foreach (var (gasId, incomingMoles) in incoming.Moles) + { + double combinedMoles = destination.Moles.GetValueOrDefault(gasId) + (double)incomingMoles; + if (!double.IsFinite(combinedMoles) || combinedMoles > float.MaxValue) + throw new InvalidOperationException("A merged gas amount exceeds the supported range."); + destination.Moles[gasId] = (float)combinedMoles; + } + + if (!float.IsFinite(mixedTemperature) || mixedTemperature < 0f) + throw new InvalidOperationException("The merged mixture temperature is outside the supported range."); + destination.Temperature = mixedTemperature; + } + + private double CalculateMixtureHeatCapacity(GasMixtureState state) + { + double total = 0d; + foreach (var (gasId, moles) in state.Moles) + total += (double)moles * GetMolarHeatCapacityAtConstantVolume(gasId); + if (!double.IsFinite(total)) + throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); + return total; + } + + private float GetMolarHeatCapacityAtConstantVolume(int gasId) + { + float fallback = Config.DefaultMolarHeatCapacityAtConstantVolume; + if (!float.IsFinite(fallback) || fallback <= 0f) + fallback = AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; + + var registry = Config.GasRegistry; + if ((uint)gasId < (uint)registry.Count) + { + float configured = registry[gasId].MolarHeatCapacityAtConstantVolume; + if (float.IsFinite(configured) && configured > 0f) + return configured; + } + + return fallback; + } + + private float GetEffectiveMixtureTemperature(float temperature) + { + if (float.IsFinite(temperature) && temperature > 0f) + return temperature; + float fallback = Config.DefaultTemperatureFallback; + return float.IsFinite(fallback) && fallback > 0f + ? fallback + : AtmosConfigDefaults.DefaultTemperatureFallback; + } + + private float CalculateMixturePressure(GasMixtureState state) + { + float totalMoles = state.TotalMoles; + if (totalMoles <= 0f) + return 0f; + double pressure = (double)totalMoles * AtmosPhysicalConstants.MolarGasConstant * + GetEffectiveMixtureTemperature(state.Temperature) / state.Volume; + return (float)pressure; + } + + private static GasMixtureState RemoveRatioFromState(GasMixtureState source, float ratio) + { + ratio = Math.Clamp(ratio, 0f, 1f); + var removed = new GasMixtureState(source.Volume, source.Temperature); + foreach (var (gasId, sourceMoles) in source.Moles.ToArray()) + { + float removedMoles = sourceMoles * ratio; + float remainingMoles = sourceMoles - removedMoles; + if (removedMoles > 0f) + removed.Moles.Add(gasId, removedMoles); + SetStateMoles(source, gasId, remainingMoles); + } + + return removed; + } + + private static void SetStateMoles(GasMixtureState state, int gasId, float moles) + { + if (moles > 0f) + state.Moles[gasId] = moles; + else + state.Moles.Remove(gasId); + } + + private void ValidateState(GasMixtureState state) + { + ValidateVolume(state.Volume); + ValidateTemperature(state.Temperature); + double total = 0d; + foreach (var (gasId, moles) in state.Moles) + { + ValidateGasId(gasId); + ValidatePositiveFinite(moles, nameof(state)); + total += moles; + } + + if (!double.IsFinite(total) || total > float.MaxValue) + throw new InvalidOperationException("The mixture's total moles exceed the supported range."); + + double heatCapacity = CalculateMixtureHeatCapacity(state); + if (heatCapacity > float.MaxValue) + throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); + + double pressure = total * AtmosPhysicalConstants.MolarGasConstant * + GetEffectiveMixtureTemperature(state.Temperature) / state.Volume; + if (!double.IsFinite(pressure) || pressure > float.MaxValue) + throw new InvalidOperationException("The mixture's pressure exceeds the supported range."); + } + + private IInternalGasMixture GetOwnedMixture(IGasMixture mixture, string parameterName) + { + ArgumentNullException.ThrowIfNull(mixture, parameterName); + if (mixture is not IInternalGasMixture internalMixture) + throw CreateUnsupportedMixtureException(parameterName); + ValidateOwnedMixture(internalMixture, parameterName); + return internalMixture; + } + + private void ValidateOwnedMixture(IInternalGasMixture mixture, string parameterName) + { + if (!ReferenceEquals(mixture.Owner, this)) + { + throw new ArgumentException( + "Gas mixtures must be owned by the same AtmosSimulation.", + parameterName); + } + } + + private static bool IsSameMixture(IInternalGasMixture left, IInternalGasMixture right) + { + if (ReferenceEquals(left, right)) + return true; + return left is VoxelGasMixture leftVoxel && right is VoxelGasMixture rightVoxel && + leftVoxel.ChunkPosition == rightVoxel.ChunkPosition && + leftVoxel.ChunkGeneration == rightVoxel.ChunkGeneration && + leftVoxel.LocalVoxelIndex == rightVoxel.LocalVoxelIndex; + } + + private static ArgumentException CreateUnsupportedMixtureException(string parameterName) + { + return new ArgumentException( + "Only GasMixture instances and voxel mixtures created by AtmosSimulation are supported.", + parameterName); + } + + private static void ValidateGasId(int gasId) + { + ArgumentOutOfRangeException.ThrowIfNegative(gasId); + } + + private static void ValidateVolume(float volume) + { + if (!float.IsFinite(volume) || volume <= 0f) + throw new ArgumentOutOfRangeException(nameof(volume), volume, "Volume must be positive and finite."); + } + + private static void ValidateTemperature(float temperature) + { + if (!float.IsFinite(temperature) || temperature < 0f) + { + throw new ArgumentOutOfRangeException(nameof(temperature), temperature, + "Temperature must be nonnegative and finite."); + } + } + + private static void ValidatePositiveFinite(float value, string parameterName) + { + if (!float.IsFinite(value) || value <= 0f) + throw new ArgumentOutOfRangeException(parameterName, value, "Value must be positive and finite."); + } + + private static void ValidateNonnegativeFinite(float value, string parameterName) + { + if (!float.IsFinite(value) || value < 0f) + throw new ArgumentOutOfRangeException(parameterName, value, "Value must be nonnegative and finite."); + } + + private static void ValidateFinite(float value, string parameterName) + { + if (!float.IsFinite(value)) + throw new ArgumentOutOfRangeException(parameterName, value, "Value must be finite."); + } + + private readonly record struct MixtureMetrics( + float Volume, + float Temperature, + float Pressure, + float TotalMoles, + int ActiveGasCount); +} \ No newline at end of file diff --git a/src/Numos.API/AtmosSimulation.cs b/src/Numos.API/AtmosSimulation.cs index cbc35e3..ec532a6 100644 --- a/src/Numos.API/AtmosSimulation.cs +++ b/src/Numos.API/AtmosSimulation.cs @@ -15,7 +15,7 @@ namespace Numos.API; /// worker-local buffers. Unless otherwise noted, members that access kernel state throw /// after disposal. /// -public sealed class AtmosSimulation : IDisposable +public sealed partial class AtmosSimulation : IDisposable { /// /// The fixed simulation rate, in ticks per second. @@ -178,11 +178,14 @@ public long LastBoundaryTicks [PublicAPI] public void Dispose() { - if (_disposed) - return; + lock (_mixtureGate) + { + if (_disposed) + return; - _kernel.Dispose(); - _disposed = true; + _kernel.Dispose(); + _disposed = true; + } } /// @@ -229,10 +232,13 @@ public void Update(float elapsedSeconds, AtmosConfig config) [PublicAPI] public void SetAtmosConfig(AtmosConfig config) { - ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(config); - Config = config; - _kernel.SetAtmosConfig(config); + lock (_mixtureGate) + { + ThrowIfDisposed(); + Config = config; + _kernel.SetAtmosConfig(config); + } } /// diff --git a/src/Numos.API/GasMixture.cs b/src/Numos.API/GasMixture.cs new file mode 100644 index 0000000..217a0db --- /dev/null +++ b/src/Numos.API/GasMixture.cs @@ -0,0 +1,98 @@ +using JetBrains.Annotations; + +namespace Numos.API; + +/// +/// A simulation-owned gas mixture with independent sparse backing storage, suitable for canisters and other +/// portable or arbitrary-volume containers. +/// +/// +/// Instances are created through . They retain their owning +/// simulation so energy calculations use the same gas registry and configuration as voxel mixtures. +/// +[PublicAPI] +public sealed class GasMixture : IInternalGasMixture +{ + private GasMixtureState _state; + + internal GasMixture(AtmosSimulation owner, GasMixtureState state) + { + Owner = owner; + _state = state; + } + + /// + public AtmosSimulation Owner { get; } + + /// + public float Volume + { + get => Owner.GetMixtureVolume(this); + set => Owner.SetMixtureVolume(this, value); + } + + /// + public float Temperature + { + get => Owner.GetMixtureTemperature(this); + set => Owner.SetMixtureTemperature(this, value); + } + + /// + public float Pressure => Owner.GetMixturePressure(this); + + /// + public float TotalMoles => Owner.GetMixtureTotalMoles(this); + + /// + public int ActiveGasCount => Owner.GetMixtureActiveGasCount(this); + + /// + public float GetMoles(int gasId) => Owner.GetMixtureMoles(this, gasId); + + /// + public void SetMoles(int gasId, float moles) => Owner.SetMixtureMoles(this, gasId, moles); + + /// + public void AdjustMoles(int gasId, float deltaMoles) => + Owner.AdjustMixtureMoles(this, gasId, deltaMoles); + + /// + public void AddGas(int gasId, float moles, float temperature) => + Owner.AddGasToMixture(this, gasId, moles, temperature); + + /// + public void Clear() => Owner.ClearMixture(this); + + /// + public GasMixture Remove(float moles) => Owner.RemoveFromMixture(this, moles); + + /// + public GasMixture RemoveRatio(float ratio) => Owner.RemoveRatioFromMixture(this, ratio); + + /// + public GasMixture RemoveVolume(float volume) => Owner.RemoveVolumeFromMixture(this, volume); + + /// + public float TransferTo(IGasMixture destination, float moles) => + Owner.TransferMixture(this, destination, moles); + + /// + public float TransferRatioTo(IGasMixture destination, float ratio) => + Owner.TransferMixtureRatio(this, destination, ratio); + + /// + public GasMixture Clone() => Owner.CloneMixture(this); + + /// + public GasMixtureSnapshot GetSnapshot() => Owner.GetMixtureSnapshot(this); + + internal GasMixtureState CaptureState() => _state.Clone(); + + internal GasMixtureState State => _state; + + internal void ApplyState(GasMixtureState state) + { + _state = state.Clone(); + } +} \ No newline at end of file diff --git a/src/Numos.API/GasMixtureState.cs b/src/Numos.API/GasMixtureState.cs new file mode 100644 index 0000000..a8ec9e1 --- /dev/null +++ b/src/Numos.API/GasMixtureState.cs @@ -0,0 +1,44 @@ +namespace Numos.API; + +internal sealed class GasMixtureState +{ + internal GasMixtureState(float volume, float temperature) + { + Volume = volume; + Temperature = temperature; + } + + internal float Volume { get; set; } + internal float Temperature { get; set; } + internal SortedDictionary Moles { get; } = []; + + internal int ActiveGasCount => Moles.Count; + + internal float TotalMoles + { + get + { + double total = 0d; + foreach (float moles in Moles.Values) + total += moles; + return (float)total; + } + } + + internal GasMixtureState Clone() + { + var clone = new GasMixtureState(Volume, Temperature); + foreach (var (gasId, moles) in Moles) + clone.Moles.Add(gasId, moles); + return clone; + } + + internal KeyValuePair[] ToGasArray() + { + var gases = new KeyValuePair[Moles.Count]; + var index = 0; + foreach (var gas in Moles) + gases[index++] = gas; + return gases; + } +} \ No newline at end of file diff --git a/src/Numos.API/IGasMixture.cs b/src/Numos.API/IGasMixture.cs new file mode 100644 index 0000000..71f987f --- /dev/null +++ b/src/Numos.API/IGasMixture.cs @@ -0,0 +1,111 @@ +using JetBrains.Annotations; + +namespace Numos.API; + +/// +/// A mutable, simulation-owned gas volume. +/// +/// +/// Implementations may own detached container storage or represent a live voxel. Members never expose the +/// solver's backing arrays or spans. Every live-voxel operation is serialized with simulation ticks. This is a +/// common capability surface, not an extension point: transfer endpoints must be instances created by +/// . +/// +[PublicAPI] +public interface IGasMixture +{ + /// The simulation that owns this mixture and resolves its gas properties. + AtmosSimulation Owner { get; } + + /// The represented volume, in cubic metres (m³). + float Volume { get; } + + /// The stored temperature, in kelvins (K). + float Temperature { get; set; } + + /// The ideal-gas pressure, in pascals (Pa). + float Pressure { get; } + + /// The total amount of gas, in moles (mol). + float TotalMoles { get; } + + /// The number of gas IDs with a positive amount. + int ActiveGasCount { get; } + + /// Gets one gas amount, returning zero when the gas is absent. + float GetMoles(int gasId); + + /// Sets one gas amount without changing the stored temperature. + void SetMoles(int gasId, float moles); + + /// Adjusts one gas amount, clamping the result to zero, without changing the stored temperature. + void AdjustMoles(int gasId, float deltaMoles); + + /// Adds gas and mixes its sensible internal energy into this mixture. + void AddGas(int gasId, float moles, float temperature); + + /// Removes every gas while retaining this mixture's volume and temperature. + void Clear(); + + /// Removes up to the requested total amount in the mixture's current proportions. + GasMixture Remove(float moles); + + /// Removes a fraction of every gas in the mixture. + GasMixture RemoveRatio(float ratio); + + /// Removes the fraction of gas corresponding to a fraction of this mixture's volume. + GasMixture RemoveVolume(float volume); + + /// Transfers up to the requested total amount to another mixture owned by the same simulation. + /// The amount actually transferred, in moles (mol). + float TransferTo(IGasMixture destination, float moles); + + /// Transfers a fraction of every gas to another mixture owned by the same simulation. + /// The amount actually transferred, in moles (mol). + float TransferRatioTo(IGasMixture destination, float ratio); + + /// Creates an owned, detached copy that remains associated with the same simulation. + GasMixture Clone(); + + /// Captures a detached, deterministic snapshot of this mixture. + GasMixtureSnapshot GetSnapshot(); +} + +/// A gas ID and its positive amount in a mixture snapshot. +/// Simulation gas ID. +/// Amount in moles (mol). +public readonly record struct GasMixtureGas(int GasId, float Moles); + +/// Detached scalar and composition values captured from an . +/// Volume in cubic metres (m³). +/// Stored temperature in kelvins (K). +/// Ideal-gas pressure in pascals (Pa). +/// Total amount in moles (mol). +/// Positive gas amounts ordered by gas ID. +public readonly record struct GasMixtureSnapshot( + float Volume, + float Temperature, + float Pressure, + float TotalMoles, + GasMixtureGas[] Gases) +{ + /// Gets one captured gas amount, returning zero when the gas is absent. + public float GetMoles(int gasId) + { + ArgumentOutOfRangeException.ThrowIfNegative(gasId); + var gases = Gases ?? []; + for (var index = 0; index < gases.Length; index++) + { + if (gases[index].GasId == gasId) + return gases[index].Moles; + if (gases[index].GasId > gasId) + break; + } + + return 0f; + } +} + +internal interface IInternalGasMixture : IGasMixture +{ +} \ No newline at end of file diff --git a/src/Numos.API/VoxelGasMixture.cs b/src/Numos.API/VoxelGasMixture.cs new file mode 100644 index 0000000..0e428be --- /dev/null +++ b/src/Numos.API/VoxelGasMixture.cs @@ -0,0 +1,61 @@ +using Numos.Maths; + +namespace Numos.API; + +/// +/// Generation-bound, sandboxed access to one voxel's structure-of-arrays gas state. +/// +internal sealed class VoxelGasMixture : IInternalGasMixture +{ + internal VoxelGasMixture( + AtmosSimulation owner, + Int3 chunkPosition, + long chunkGeneration, + ushort localVoxelIndex) + { + Owner = owner; + ChunkPosition = chunkPosition; + ChunkGeneration = chunkGeneration; + LocalVoxelIndex = localVoxelIndex; + } + + public AtmosSimulation Owner { get; } + public float Volume => Owner.GetMixtureVolume(this); + + public float Temperature + { + get => Owner.GetMixtureTemperature(this); + set => Owner.SetMixtureTemperature(this, value); + } + + public float Pressure => Owner.GetMixturePressure(this); + public float TotalMoles => Owner.GetMixtureTotalMoles(this); + public int ActiveGasCount => Owner.GetMixtureActiveGasCount(this); + + internal Int3 ChunkPosition { get; } + internal long ChunkGeneration { get; } + internal ushort LocalVoxelIndex { get; } + + public float GetMoles(int gasId) => Owner.GetMixtureMoles(this, gasId); + public void SetMoles(int gasId, float moles) => Owner.SetMixtureMoles(this, gasId, moles); + + public void AdjustMoles(int gasId, float deltaMoles) => + Owner.AdjustMixtureMoles(this, gasId, deltaMoles); + + public void AddGas(int gasId, float moles, float temperature) => + Owner.AddGasToMixture(this, gasId, moles, temperature); + + public void Clear() => Owner.ClearMixture(this); + public GasMixture Remove(float moles) => Owner.RemoveFromMixture(this, moles); + public GasMixture RemoveRatio(float ratio) => Owner.RemoveRatioFromMixture(this, ratio); + public GasMixture RemoveVolume(float volume) => Owner.RemoveVolumeFromMixture(this, volume); + + public float TransferTo(IGasMixture destination, float moles) => + Owner.TransferMixture(this, destination, moles); + + public float TransferRatioTo(IGasMixture destination, float ratio) => + Owner.TransferMixtureRatio(this, destination, ratio); + + public GasMixture Clone() => Owner.CloneMixture(this); + public GasMixtureSnapshot GetSnapshot() => Owner.GetMixtureSnapshot(this); +} \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosChunk.cs b/src/Numos.CoreSim/AtmosChunk.cs index 3e20eea..db47514 100644 --- a/src/Numos.CoreSim/AtmosChunk.cs +++ b/src/Numos.CoreSim/AtmosChunk.cs @@ -198,7 +198,7 @@ public void EnsureInitialized() EnsureInitialized(ref TotalHeatCapacity, dimensions); EnsureInitialized(ref Temperature, dimensions); if (ActiveGases == null) - ActiveGases = new GasChannel[AtmosChunkConstants.MaximumGasChannelsPerChunk]; + ActiveGases = new GasChannel[AtmosChunkConstants.InitialGasChannelCapacity]; if (ActiveRoomIds == null || ActiveRoomIds.Length != MaxActiveRooms) ActiveRoomIds = new int[MaxActiveRooms]; } @@ -392,29 +392,7 @@ public void InjectGasToVoxel(ushort localVoxelIndex, int gasId, float molesToAdd float currentHeatCapacity = TotalHeatCapacity[localVoxelIndex]; - int targetChannelIndex = -1; - for (var i = 0; i < ActiveGasCount; i++) - { - if (ActiveGases[i].GasId == gasId) - { - targetChannelIndex = i; - break; - } - } - - if (targetChannelIndex == -1) - { - if (ActiveGasCount >= ActiveGases.Length) - { - throw new Exception("Maximum unique gas channels reached for this chunk!"); - } - - ActiveGases[ActiveGasCount] = new GasChannel(); - ActiveGases[ActiveGasCount].Initialize(gasId, VoxelCount); - - targetChannelIndex = ActiveGasCount; - ActiveGasCount++; - } + int targetChannelIndex = GetOrCreateGasChannel(gasId); ActiveGases[targetChannelIndex].Moles[localVoxelIndex] += molesToAdd; @@ -441,6 +419,28 @@ public void InjectGasToVoxel(ushort localVoxelIndex, int gasId, float molesToAdd MarkChanged(); } + internal int GetOrCreateGasChannel(int gasId) + { + for (var index = 0; index < ActiveGasCount; index++) + { + if (ActiveGases[index].GasId == gasId) + return index; + } + + if (ActiveGasCount == ActiveGases.Length) + { + int newLength = checked(Math.Max(ActiveGases.Length * 2, ActiveGasCount + 1)); + Array.Resize(ref ActiveGases, newLength); + } + + int channelIndex = ActiveGasCount; + var channel = new GasChannel(); + channel.Initialize(gasId, VoxelCount); + ActiveGases[channelIndex] = channel; + ActiveGasCount++; + return channelIndex; + } + /// /// Creates a snapshot of the chunk's current network state. /// diff --git a/src/Numos.CoreSim/AtmosChunkConstants.cs b/src/Numos.CoreSim/AtmosChunkConstants.cs index 404321a..d2bf805 100644 --- a/src/Numos.CoreSim/AtmosChunkConstants.cs +++ b/src/Numos.CoreSim/AtmosChunkConstants.cs @@ -17,8 +17,9 @@ public static class AtmosChunkConstants /// Default maximum number of simultaneously active rooms in a chunk. public const int DefaultMaxActiveRooms = 64; - /// Maximum number of distinct gas channels supported by one chunk. - public const int MaximumGasChannelsPerChunk = 16; + /// Initial number of distinct gas-channel slots allocated by a chunk. + /// The channel table grows when a mixture introduces additional gas IDs. + public const int InitialGasChannelCapacity = 16; /// Maximum voxel count representable by the chunk's unsigned 16-bit flat indices. public const int MaximumVoxelCount = ushort.MaxValue; diff --git a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs new file mode 100644 index 0000000..a14eb37 --- /dev/null +++ b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs @@ -0,0 +1,245 @@ +using Numos.CoreSim.Datatypes.Primitives; +using Numos.Maths; + +namespace Numos.CoreSim; + +internal readonly record struct VoxelGasMixtureMetrics( + float Volume, + float Temperature, + float Pressure, + float TotalMoles, + int ActiveGasCount); + +internal readonly record struct VoxelGasMixtureState( + float Volume, + float Temperature, + KeyValuePair[] Gases); + +internal readonly record struct VoxelGasMixtureAddress( + Int3 ChunkPosition, + long ChunkGeneration, + ushort LocalVoxelIndex); + +internal sealed partial class AtmosKernel +{ + internal void ExecuteMixtureTransaction(Action transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + lock (_stateGate) + { + transaction(); + } + } + + internal (long Generation, ushort LocalVoxelIndex) GetVoxelMixtureIdentity( + Int3 position, + ushort localVoxelIndex) + { + lock (_stateGate) + { + var chunk = GetChunk(position); + ValidateVoxelIndex(chunk, localVoxelIndex); + return (chunk.Version.Generation, localVoxelIndex); + } + } + + internal (long Generation, ushort LocalVoxelIndex) GetVoxelMixtureIdentity( + Int3 position, + int x, + int y, + int z) + { + lock (_stateGate) + { + var chunk = GetChunk(position); + ushort localVoxelIndex = GetValidatedVoxelIndex(chunk, x, y, z); + return (chunk.Version.Generation, localVoxelIndex); + } + } + + internal VoxelGasMixtureMetrics GetVoxelMixtureMetrics( + Int3 position, + long generation, + ushort localVoxelIndex) + { + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + double totalMoles = 0d; + var activeGasCount = 0; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + float moles = MathF.Max(0f, chunk.ActiveGases[gas].Moles[localVoxelIndex]); + if (moles <= 0f) + continue; + totalMoles += moles; + activeGasCount++; + } + + float storedTemperature = chunk.Temperature[localVoxelIndex]; + float pressure = CalculatePressure((float)totalMoles, storedTemperature); + return new VoxelGasMixtureMetrics( + GetVoxelVolume(), + storedTemperature, + pressure, + (float)totalMoles, + activeGasCount); + } + } + + internal float GetVoxelMixtureMoles( + Int3 position, + long generation, + ushort localVoxelIndex, + int gasId) + { + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + if (chunk.ActiveGases[gas].GasId == gasId) + return MathF.Max(0f, chunk.ActiveGases[gas].Moles[localVoxelIndex]); + } + + return 0f; + } + } + + internal VoxelGasMixtureState CaptureVoxelMixture( + Int3 position, + long generation, + ushort localVoxelIndex) + { + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + var gases = new KeyValuePair[chunk.ActiveGasCount]; + var gasCount = 0; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + float moles = MathF.Max(0f, chunk.ActiveGases[gas].Moles[localVoxelIndex]); + if (moles <= 0f) + continue; + gases[gasCount++] = new KeyValuePair(chunk.ActiveGases[gas].GasId, moles); + } + + if (gasCount != gases.Length) + Array.Resize(ref gases, gasCount); + Array.Sort(gases, static (left, right) => left.Key.CompareTo(right.Key)); + + return new VoxelGasMixtureState( + GetVoxelVolume(), + chunk.Temperature[localVoxelIndex], + gases); + } + } + + internal void ValidateVoxelMixtureMutations(VoxelGasMixtureAddress[] addresses) + { + ArgumentNullException.ThrowIfNull(addresses); + lock (_stateGate) + { + var requiredRooms = new Dictionary<(Int3 Position, long Generation), HashSet>(); + foreach (var address in addresses) + { + var chunk = GetMixtureChunk( + address.ChunkPosition, + address.ChunkGeneration, + address.LocalVoxelIndex); + int roomId = chunk.VoxelRoomMap[address.LocalVoxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + throw new InvalidOperationException("Solid and void voxels cannot contain a gas mixture."); + + var key = (address.ChunkPosition, address.ChunkGeneration); + if (!requiredRooms.TryGetValue(key, out var rooms)) + { + rooms = []; + if (chunk.IsAwake) + { + for (var room = 0; room < chunk.ActiveRoomCount; room++) + rooms.Add(chunk.ActiveRoomIds[room]); + } + requiredRooms.Add(key, rooms); + } + + rooms.Add(roomId); + if (rooms.Count > chunk.MaxActiveRooms) + { + throw new InvalidOperationException( + "The gas-mixture transaction would exceed the chunk's active-room capacity."); + } + } + } + } + + internal void ReplaceVoxelMixture( + Int3 position, + long generation, + ushort localVoxelIndex, + float temperature, + KeyValuePair[] gases) + { + ArgumentNullException.ThrowIfNull(gases); + if (!float.IsFinite(temperature) || temperature < 0f) + throw new ArgumentOutOfRangeException(nameof(temperature)); + + var previousGasId = -1; + double totalMoles = 0d; + foreach (var (gasId, moles) in gases) + { + if (gasId < 0) + throw new ArgumentOutOfRangeException(nameof(gases), "Gas IDs must be nonnegative."); + if (gasId <= previousGasId) + throw new ArgumentException("Gas IDs must be unique and ordered.", nameof(gases)); + if (!float.IsFinite(moles) || moles <= 0f) + throw new ArgumentOutOfRangeException(nameof(gases), "Gas amounts must be positive and finite."); + previousGasId = gasId; + totalMoles += moles; + } + if (!double.IsFinite(totalMoles) || totalMoles > float.MaxValue) + throw new InvalidOperationException("The mixture's total moles exceed the supported range."); + + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + int roomId = chunk.VoxelRoomMap[localVoxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + throw new InvalidOperationException("Solid and void voxels cannot contain a gas mixture."); + + double totalHeatCapacity = 0d; + foreach (var (gasId, moles) in gases) + totalHeatCapacity += (double)moles * GetMolarHeatCapacityAtConstantVolume(gasId); + if (!double.IsFinite(totalHeatCapacity) || totalHeatCapacity > float.MaxValue) + throw new InvalidOperationException("The mixture's total heat capacity exceeds the supported range."); + + chunk.WakeRoom(roomId); + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + chunk.ActiveGases[gas].Moles[localVoxelIndex] = 0f; + + foreach (var (gasId, moles) in gases) + { + int gasChannel = chunk.GetOrCreateGasChannel(gasId); + chunk.ActiveGases[gasChannel].Moles[localVoxelIndex] = moles; + } + + chunk.Temperature[localVoxelIndex] = temperature; + chunk.TotalHeatCapacity[localVoxelIndex] = (float)totalHeatCapacity; + chunk.TotalPressure[localVoxelIndex] = CalculatePressure((float)totalMoles, temperature); + chunk.MarkChanged(); + } + } + + private AtmosChunk GetMixtureChunk(Int3 position, long generation, ushort localVoxelIndex) + { + var chunk = GetChunk(position); + if (chunk.Version.Generation != generation) + { + throw new InvalidOperationException( + "The voxel gas mixture is stale because its original chunk was unregistered or replaced."); + } + + ValidateVoxelIndex(chunk, localVoxelIndex); + return chunk; + } +} \ No newline at end of file diff --git a/tests/Numos.API.Tests/GasMixtureTests.cs b/tests/Numos.API.Tests/GasMixtureTests.cs new file mode 100644 index 0000000..2938012 --- /dev/null +++ b/tests/Numos.API.Tests/GasMixtureTests.cs @@ -0,0 +1,411 @@ +using Numos.CoreSim; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.Maths; + +namespace Numos.API.Tests; + +[TestFixture] +public sealed class GasMixtureTests +{ + [Test] + public void CreateGasMixture_ValidatesVolumeAndTemperature() + { + using var simulation = CreateSimulation(); + + Assert.Multiple(() => + { + Assert.That(() => simulation.CreateGasMixture(0f), Throws.TypeOf()); + Assert.That(() => simulation.CreateGasMixture(float.PositiveInfinity), + Throws.TypeOf()); + Assert.That(() => simulation.CreateGasMixture(1f, -1f), + Throws.TypeOf()); + Assert.That(() => simulation.CreateGasMixture(1f, float.NaN), + Throws.TypeOf()); + }); + } + + [Test] + public void OwnedMixture_SupportsSparseArbitraryGasIdsAndDeterministicSnapshots() + { + using var simulation = CreateSimulation(); + var mixture = simulation.CreateGasMixture(2f, 300f); + + mixture.SetMoles(20, 1.5f); + mixture.SetMoles(3, 2f); + mixture.AdjustMoles(20, -0.5f); + + var snapshot = mixture.GetSnapshot(); + float expectedPressure = 3f * AtmosPhysicalConstants.MolarGasConstant * 300f / 2f; + + Assert.Multiple(() => + { + Assert.That(mixture.Owner, Is.SameAs(simulation)); + Assert.That(mixture.ActiveGasCount, Is.EqualTo(2)); + Assert.That(mixture.TotalMoles, Is.EqualTo(3f)); + Assert.That(mixture.GetMoles(999), Is.Zero); + Assert.That(mixture.Pressure, Is.EqualTo(expectedPressure).Within(0.001f)); + Assert.That(snapshot.Gases.Select(static gas => gas.GasId), Is.EqualTo(new[] { 3, 20 })); + Assert.That(snapshot.GetMoles(3), Is.EqualTo(2f)); + Assert.That(snapshot.GetMoles(20), Is.EqualTo(1f)); + }); + + snapshot.Gases[0] = new GasMixtureGas(3, 100f); + Assert.That(mixture.GetMoles(3), Is.EqualTo(2f)); + } + + [Test] + public void OwnedMixture_VolumeChangesPressureWithoutChangingContents() + { + using var simulation = CreateSimulation(); + var mixture = simulation.CreateGasMixture(1f, 300f); + mixture.SetMoles(0, 2f); + float initialPressure = mixture.Pressure; + + mixture.Volume = 4f; + + Assert.Multiple(() => + { + Assert.That(mixture.Volume, Is.EqualTo(4f)); + Assert.That(mixture.Pressure, Is.EqualTo(initialPressure / 4f).Within(0.001f)); + Assert.That(mixture.TotalMoles, Is.EqualTo(2f)); + Assert.That(() => mixture.Volume = float.NaN, Throws.TypeOf()); + }); + } + + [Test] + public void AddGas_MixesTemperatureByConstantVolumeHeatCapacity() + { + using var simulation = CreateSimulation(); + var mixture = simulation.CreateGasMixture(1f, 300f); + mixture.SetMoles(0, 1f); + + mixture.AddGas(1, 1f, 600f); + + Assert.Multiple(() => + { + Assert.That(mixture.TotalMoles, Is.EqualTo(2f)); + Assert.That(mixture.GetMoles(0), Is.EqualTo(1f)); + Assert.That(mixture.GetMoles(1), Is.EqualTo(1f)); + Assert.That(mixture.Temperature, Is.EqualTo(500f).Within(0.0001f)); + }); + } + + [Test] + public void EnergyOperationsUseOwnersCurrentGasRegistry() + { + using var simulation = CreateSimulation(); + var mixture = simulation.CreateGasMixture(1f, 300f); + mixture.SetMoles(0, 1f); + simulation.SetAtmosConfig(new AtmosConfig + { + GasRegistry = + [ + new GasProperties { Name = "Updated", MolarHeatCapacityAtConstantVolume = 20f }, + new GasProperties { Name = "Incoming", MolarHeatCapacityAtConstantVolume = 10f } + ] + }); + + mixture.AddGas(1, 1f, 600f); + + Assert.That(mixture.Temperature, Is.EqualTo(400f).Within(0.0001f)); + } + + [Test] + public void RemoveRatio_PreservesCompositionTemperatureAndOwner() + { + using var simulation = CreateSimulation(); + var source = simulation.CreateGasMixture(3f, 350f); + source.SetMoles(0, 2f); + source.SetMoles(7, 6f); + + var removed = source.RemoveRatio(0.25f); + + Assert.Multiple(() => + { + Assert.That(source.GetMoles(0), Is.EqualTo(1.5f)); + Assert.That(source.GetMoles(7), Is.EqualTo(4.5f)); + Assert.That(removed.GetMoles(0), Is.EqualTo(0.5f)); + Assert.That(removed.GetMoles(7), Is.EqualTo(1.5f)); + Assert.That(removed.Volume, Is.EqualTo(3f)); + Assert.That(removed.Temperature, Is.EqualTo(350f)); + Assert.That(removed.Owner, Is.SameAs(simulation)); + Assert.That(source.TotalMoles + removed.TotalMoles, Is.EqualTo(8f)); + }); + } + + [Test] + public void CloneAndClear_DoNotShareBackingState() + { + using var simulation = CreateSimulation(); + var source = simulation.CreateGasMixture(2f, 320f); + source.SetMoles(4, 3f); + + var clone = source.Clone(); + source.Clear(); + + Assert.Multiple(() => + { + Assert.That(source.TotalMoles, Is.Zero); + Assert.That(source.Temperature, Is.EqualTo(320f)); + Assert.That(clone.GetMoles(4), Is.EqualTo(3f)); + Assert.That(clone.Volume, Is.EqualTo(2f)); + }); + } + + [Test] + public void TransferTo_ConservesMolesAndSensibleEnergy() + { + using var simulation = CreateSimulation(); + var source = simulation.CreateGasMixture(1f, 600f); + source.SetMoles(1, 2f); + var destination = simulation.CreateGasMixture(1f, 300f); + destination.SetMoles(0, 1f); + + float transferred = source.TransferTo(destination, 1f); + + Assert.Multiple(() => + { + Assert.That(transferred, Is.EqualTo(1f)); + Assert.That(source.GetMoles(1), Is.EqualTo(1f)); + Assert.That(source.Temperature, Is.EqualTo(600f)); + Assert.That(destination.GetMoles(0), Is.EqualTo(1f)); + Assert.That(destination.GetMoles(1), Is.EqualTo(1f)); + Assert.That(destination.Temperature, Is.EqualTo(500f).Within(0.0001f)); + Assert.That(source.TotalMoles + destination.TotalMoles, Is.EqualTo(3f)); + }); + } + + [Test] + public void TransferTo_DifferentOwnerIsRejectedWithoutMutation() + { + using var firstSimulation = CreateSimulation(); + using var secondSimulation = CreateSimulation(); + var source = firstSimulation.CreateGasMixture(1f, 300f); + source.SetMoles(0, 2f); + var destination = secondSimulation.CreateGasMixture(1f, 300f); + + Assert.That(() => source.TransferTo(destination, 1f), Throws.ArgumentException); + Assert.Multiple(() => + { + Assert.That(source.TotalMoles, Is.EqualTo(2f)); + Assert.That(destination.TotalMoles, Is.Zero); + }); + } + + [Test] + public void VoxelMixture_MutatesLiveSoaStateWithoutExposingStorage() + { + using var simulation = CreateSimulation(voxelVolume: 2f); + var chunk = simulation.CreateAndRegisterChunk(default); + var before = simulation.GetChunkSnapshot(chunk).Version; + var mixture = simulation.GetVoxelGasMixture(chunk, 0); + + mixture.AddGas(0, 2f, 300f); + mixture.SetMoles(7, 1f); + mixture.AdjustMoles(0, -0.5f); + + var voxel = simulation.GetVoxelSnapshot(chunk, 0); + float expectedPressure = 2.5f * AtmosPhysicalConstants.MolarGasConstant * 300f / 2f; + + Assert.Multiple(() => + { + Assert.That(mixture.Volume, Is.EqualTo(2f)); + Assert.That(mixture.TotalMoles, Is.EqualTo(2.5f)); + Assert.That(mixture.ActiveGasCount, Is.EqualTo(2)); + Assert.That(mixture.Pressure, Is.EqualTo(expectedPressure).Within(0.001f)); + Assert.That(voxel.Pressure, Is.EqualTo(expectedPressure).Within(0.001f)); + Assert.That(voxel.ChunkVersion, Is.Not.EqualTo(before)); + Assert.That(voxel.Gases.Single(gas => gas.GasId == 0).Moles, Is.EqualTo(1.5f)); + Assert.That(voxel.Gases.Single(gas => gas.GasId == 7).Moles, Is.EqualTo(1f)); + }); + } + + [Test] + public void VoxelMixture_ChannelTableGrowsPastInitialCapacity() + { + using var simulation = CreateSimulation(); + var chunk = simulation.CreateAndRegisterChunk(default); + var mixture = simulation.GetVoxelGasMixture(chunk, 0); + mixture.Temperature = 300f; + + int gasCount = AtmosChunkConstants.InitialGasChannelCapacity + 5; + for (var gasId = 0; gasId < gasCount; gasId++) + mixture.SetMoles(gasId, 1f); + + Assert.Multiple(() => + { + Assert.That(mixture.ActiveGasCount, Is.EqualTo(gasCount)); + Assert.That(mixture.TotalMoles, Is.EqualTo(gasCount)); + Assert.That(mixture.GetSnapshot().Gases.Select(static gas => gas.GasId), + Is.EqualTo(Enumerable.Range(0, gasCount))); + }); + } + + [Test] + public void VoxelMixture_IsBoundToOriginalChunkGeneration() + { + using var simulation = CreateSimulation(); + var position = new Int3(2, 3, 4); + var original = simulation.CreateAndRegisterChunk(position); + var stale = simulation.GetVoxelGasMixture(original, 0); + Assert.That(simulation.UnregisterChunk(original), Is.True); + var replacement = simulation.CreateAndRegisterChunk(position); + + Assert.That(() => stale.GetSnapshot(), Throws.InvalidOperationException); + Assert.That(simulation.GetVoxelGasMixture(replacement, 0).TotalMoles, Is.Zero); + } + + [Test] + public void TransferTo_NonGasVoxelIsRejectedAtomically() + { + using var simulation = CreateSimulation(); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetVoxelClassification(chunk, 0, VoxelClassification.RoomSolid); + var source = simulation.CreateGasMixture(1f, 300f); + source.SetMoles(0, 2f); + var destination = simulation.GetVoxelGasMixture(chunk, 0); + + Assert.That(() => source.TransferTo(destination, 1f), Throws.InvalidOperationException); + Assert.Multiple(() => + { + Assert.That(source.TotalMoles, Is.EqualTo(2f)); + Assert.That(destination.TotalMoles, Is.Zero); + }); + } + + [Test] + public void TransferTo_ActiveRoomCapacityFailureIsAtomic() + { + using var simulation = new AtmosSimulation(CreateSimulationConfig(), 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default, maxActiveRooms: 1); + simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 1, new VoxelClassification(2)); + var source = simulation.GetVoxelGasMixture(chunk, 0); + var destination = simulation.GetVoxelGasMixture(chunk, 1); + source.AddGas(0, 2f, 300f); + + Assert.That(() => source.TransferTo(destination, 1f), Throws.InvalidOperationException); + Assert.Multiple(() => + { + Assert.That(source.TotalMoles, Is.EqualTo(2f)); + Assert.That(destination.TotalMoles, Is.Zero); + }); + } + + [Test] + public void ContainerAndVoxelTransfersAreBidirectionalAndConservative() + { + using var simulation = CreateSimulation(); + var chunk = simulation.CreateAndRegisterChunk(default); + var voxel = simulation.GetVoxelGasMixture(chunk, 0, 0, 0); + var canister = simulation.CreateGasMixture(1f, 400f); + canister.SetMoles(0, 4f); + + Assert.That(canister.TransferTo(voxel, 3f), Is.EqualTo(3f)); + var sample = voxel.Remove(1f); + + Assert.Multiple(() => + { + Assert.That(canister.TotalMoles, Is.EqualTo(1f)); + Assert.That(voxel.TotalMoles, Is.EqualTo(2f)); + Assert.That(sample.TotalMoles, Is.EqualTo(1f)); + Assert.That(canister.TotalMoles + voxel.TotalMoles + sample.TotalMoles, Is.EqualTo(4f)); + Assert.That(voxel.Temperature, Is.EqualTo(400f)); + Assert.That(sample.Temperature, Is.EqualTo(400f)); + }); + } + + [Test] + public void TwoCapabilitiesForSameVoxelDoNotTransferToThemselves() + { + using var simulation = CreateSimulation(); + var chunk = simulation.CreateAndRegisterChunk(default); + var first = simulation.GetVoxelGasMixture(chunk, 0); + var second = simulation.GetVoxelGasMixture(chunk, 0); + first.AddGas(0, 2f, 300f); + + Assert.That(first.TransferTo(second, 1f), Is.Zero); + Assert.That(first.TotalMoles, Is.EqualTo(2f)); + } + + [Test] + public void MixtureOperationsAreSerializedAcrossThreads() + { + using var simulation = CreateSimulation(); + var mixture = simulation.CreateGasMixture(1f, 300f); + + Parallel.For(0, 1000, _ => mixture.AdjustMoles(0, 1f)); + + Assert.That(mixture.GetMoles(0), Is.EqualTo(1000f)); + } + + [Test] + public void SimulationDisposalInvalidatesOwnedAndVoxelMixtures() + { + var simulation = CreateSimulation(); + var owned = simulation.CreateGasMixture(1f, 300f); + var chunk = simulation.CreateAndRegisterChunk(default); + var voxel = simulation.GetVoxelGasMixture(chunk, 0); + + simulation.Dispose(); + + Assert.Multiple(() => + { + Assert.That(() => _ = owned.TotalMoles, Throws.TypeOf()); + Assert.That(() => _ = voxel.TotalMoles, Throws.TypeOf()); + Assert.That(owned.Owner, Is.SameAs(simulation)); + Assert.That(voxel.Owner, Is.SameAs(simulation)); + }); + } + + [Test] + public void TransferTo_RejectsExternalInterfaceImplementations() + { + using var simulation = CreateSimulation(); + var source = simulation.CreateGasMixture(1f, 300f); + source.SetMoles(0, 1f); + + Assert.That(() => source.TransferTo(new ExternalMixture(simulation), 1f), Throws.ArgumentException); + Assert.That(source.TotalMoles, Is.EqualTo(1f)); + } + + private static AtmosSimulation CreateSimulation(float voxelVolume = 1f) + { + return new AtmosSimulation(CreateSimulationConfig(voxelVolume), 1, 1, 1); + } + + private static AtmosConfig CreateSimulationConfig(float voxelVolume = 1f) + { + return new AtmosConfig + { + VoxelVolume = voxelVolume, + GasRegistry = + [ + new GasProperties { Name = "Light", MolarHeatCapacityAtConstantVolume = 10f }, + new GasProperties { Name = "Heavy", MolarHeatCapacityAtConstantVolume = 20f } + ] + }; + } + + private sealed class ExternalMixture(AtmosSimulation owner) : IGasMixture + { + public AtmosSimulation Owner { get; } = owner; + public float Volume => 1f; + public float Temperature { get; set; } + public float Pressure => 0f; + public float TotalMoles => 0f; + public int ActiveGasCount => 0; + public float GetMoles(int gasId) => 0f; + public void SetMoles(int gasId, float moles) => throw new NotSupportedException(); + public void AdjustMoles(int gasId, float deltaMoles) => throw new NotSupportedException(); + public void AddGas(int gasId, float moles, float temperature) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public GasMixture Remove(float moles) => throw new NotSupportedException(); + public GasMixture RemoveRatio(float ratio) => throw new NotSupportedException(); + public GasMixture RemoveVolume(float volume) => throw new NotSupportedException(); + public float TransferTo(IGasMixture destination, float moles) => throw new NotSupportedException(); + public float TransferRatioTo(IGasMixture destination, float ratio) => throw new NotSupportedException(); + public GasMixture Clone() => throw new NotSupportedException(); + public GasMixtureSnapshot GetSnapshot() => throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs b/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs index 34e010a..e53a84b 100644 --- a/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs @@ -124,15 +124,20 @@ public void InjectGasToVoxel_SameGasInDifferentVoxelsSharesChannelButNotValues() } [Test] - public void InjectGasToVoxel_ThrowsBeforeExceedingGasChannelCapacity() + public void InjectGasToVoxel_GrowsGasChannelTableWhenInitialCapacityIsExceeded() { var chunk = CreateAwakeChunk(1); - for (var gasId = 0; gasId < chunk.ActiveGases.Length; gasId++) + int initialCapacity = chunk.ActiveGases.Length; + for (var gasId = 0; gasId <= initialCapacity; gasId++) chunk.InjectGasToVoxel(0, gasId, 1f, 300f, 1f, 1f); - Assert.That(() => chunk.InjectGasToVoxel(0, chunk.ActiveGases.Length, 1f, 300f, 1f, 1f), - Throws.Exception.With.Message.EqualTo("Maximum unique gas channels reached for this chunk!")); - Assert.That(chunk.ActiveGasCount, Is.EqualTo(chunk.ActiveGases.Length)); + Assert.Multiple(() => + { + Assert.That(chunk.ActiveGasCount, Is.EqualTo(initialCapacity + 1)); + Assert.That(chunk.ActiveGases, Has.Length.GreaterThan(initialCapacity)); + Assert.That(chunk.ActiveGases.Take(chunk.ActiveGasCount).Select(static gas => gas.GasId), + Is.EqualTo(Enumerable.Range(0, initialCapacity + 1))); + }); } private AtmosChunk CreateChunk(int width, int height, int depth) diff --git a/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs b/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs index 1ab61ca..05d503a 100644 --- a/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs @@ -22,7 +22,7 @@ public void Constructor_InitializesStorageAndState() Assert.That(chunk.ActiveAirIndices, Has.Length.EqualTo(24).And.All.Zero); Assert.That(chunk.TotalPressure.ToArray(), Has.Length.EqualTo(24).And.All.Zero); Assert.That(chunk.Temperature.ToArray(), Has.Length.EqualTo(24).And.All.Zero); - Assert.That(chunk.ActiveGases, Has.Length.EqualTo(AtmosChunkConstants.MaximumGasChannelsPerChunk)); + Assert.That(chunk.ActiveGases, Has.Length.EqualTo(AtmosChunkConstants.InitialGasChannelCapacity)); Assert.That(chunk.ActiveRoomIds, Has.Length.EqualTo(5).And.All.Zero); Assert.That(chunk.ActiveAirCount, Is.Zero); Assert.That(chunk.ActiveGasCount, Is.Zero); From 6a5c1a61db4eef161e8ab1978a9a2b97ab6b2184 Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:55:31 -0500 Subject: [PATCH 03/14] Implemented `AtmosSolverConfigSnapshot` and refactored doubles to float. --- docs/atmospherics_technical_documentation.md | 13 + src/Numos.API/AtmosSimulation.GasMixtures.cs | 348 +++++++++++----- src/Numos.API/GasMixture.cs | 2 +- src/Numos.API/IGasMixture.cs | 4 + src/Numos.CoreSim/AtmosKernel.GasMixtures.cs | 372 +++++++++++++++--- src/Numos.CoreSim/AtmosKernel.cs | 317 ++++++++------- .../AtmosSolverConfigSnapshot.cs | 140 +++++++ tests/Numos.API.Tests/GasMixtureTests.cs | 40 ++ .../SimTestHelpers.cs | 41 +- .../ThermodynamicsIntegrationTests.cs | 38 +- 10 files changed, 1007 insertions(+), 308 deletions(-) create mode 100644 src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 7a6f845..1c7ce1b 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -283,6 +283,19 @@ low-level tooling parity. `AddGas` and transfers instead conserve sensible inter constant-volume molar heat capacity. Pressure is always derived from `P = nRT/V` rather than being independently mutable. +The `Temperature` setter stores its raw value for parity with direct voxel tooling. Non-finite and nonpositive stored +temperatures are interpreted through `DefaultTemperatureFallback` when pressure or sensible energy is calculated. +Creation and incoming-gas operations still require finite, nonnegative temperatures. + +At the start of each simulation tick, the solver captures one normalized configuration and gas-property snapshot. +This keeps the tick internally consistent while retaining the public live-configuration model, and avoids repeating +configuration validation in the per-neighbor and per-species loops. + +Persistent voxel state and per-voxel thermal work buffers use single-precision storage. Numerically sensitive +products, ratios, reductions, and overflow checks are promoted to double precision only for the duration of the +calculation and are narrowed before storage. This preserves the range and rounding benefits where they affect the +math without doubling the memory bandwidth and working-set cost of the solver's structure-of-arrays layout. + ```csharp var canister = simulation.CreateGasMixture(volume: 0.07f, temperature: 293.15f); canister.AddGas(oxygenId, moles: 2f, temperature: 293.15f); diff --git a/src/Numos.API/AtmosSimulation.GasMixtures.cs b/src/Numos.API/AtmosSimulation.GasMixtures.cs index a089632..7ec09e1 100644 --- a/src/Numos.API/AtmosSimulation.GasMixtures.cs +++ b/src/Numos.API/AtmosSimulation.GasMixtures.cs @@ -56,11 +56,90 @@ public IGasMixture GetVoxelGasMixture(AtmosChunkHandle chunk, int x, int y, int } } - internal float GetMixtureVolume(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).Volume; - internal float GetMixtureTemperature(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).Temperature; - internal float GetMixturePressure(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).Pressure; - internal float GetMixtureTotalMoles(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).TotalMoles; - internal int GetMixtureActiveGasCount(IInternalGasMixture mixture) => GetMixtureMetrics(mixture).ActiveGasCount; + internal float GetMixtureVolume(IInternalGasMixture mixture) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + return mixture switch + { + GasMixture owned => owned.State.Volume, + VoxelGasMixture voxel => _kernel.GetVoxelMixtureVolume( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex), + _ => throw CreateUnsupportedMixtureException(nameof(mixture)) + }; + } + } + + internal float GetMixtureTemperature(IInternalGasMixture mixture) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + return mixture switch + { + GasMixture owned => owned.State.Temperature, + VoxelGasMixture voxel => _kernel.GetVoxelMixtureTemperature( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex), + _ => throw CreateUnsupportedMixtureException(nameof(mixture)) + }; + } + } + + internal float GetMixturePressure(IInternalGasMixture mixture) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + return mixture switch + { + GasMixture owned => CalculateMixturePressure(owned.State), + VoxelGasMixture voxel => _kernel.GetVoxelMixturePressure( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex), + _ => throw CreateUnsupportedMixtureException(nameof(mixture)) + }; + } + } + + internal float GetMixtureTotalMoles(IInternalGasMixture mixture) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + return mixture switch + { + GasMixture owned => owned.State.TotalMoles, + VoxelGasMixture voxel => _kernel.GetVoxelMixtureTotalMoles( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex), + _ => throw CreateUnsupportedMixtureException(nameof(mixture)) + }; + } + } + + internal int GetMixtureActiveGasCount(IInternalGasMixture mixture) + { + lock (_mixtureGate) + { + ThrowIfDisposed(); + return mixture switch + { + GasMixture owned => owned.State.ActiveGasCount, + VoxelGasMixture voxel => _kernel.GetVoxelMixtureActiveGasCount( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex), + _ => throw CreateUnsupportedMixtureException(nameof(mixture)) + }; + } + } internal float GetMixtureMoles(IInternalGasMixture mixture, int gasId) { @@ -68,7 +147,6 @@ internal float GetMixtureMoles(IInternalGasMixture mixture, int gasId) lock (_mixtureGate) { ThrowIfDisposed(); - ValidateOwnedMixture(mixture, nameof(mixture)); return mixture switch { GasMixture owned => owned.State.Moles.GetValueOrDefault(gasId), @@ -88,25 +166,72 @@ internal void SetMixtureVolume(GasMixture mixture, float volume) lock (_mixtureGate) { ThrowIfDisposed(); - ValidateOwnedMixture(mixture, nameof(mixture)); - var state = mixture.CaptureState(); + GasMixtureState state = mixture.State; + float previousVolume = state.Volume; state.Volume = volume; - ValidateState(state); - mixture.ApplyState(state); + try + { + ValidateState(state); + } + catch + { + state.Volume = previousVolume; + throw; + } } } internal void SetMixtureTemperature(IInternalGasMixture mixture, float temperature) { - ValidateTemperature(temperature); - MutateMixture(mixture, state => state.Temperature = temperature); + lock (_mixtureGate) + { + ThrowIfDisposed(); + if (mixture is GasMixture owned) + { + owned.State.Temperature = temperature; + return; + } + + if (mixture is VoxelGasMixture voxel) + { + _kernel.SetVoxelMixtureTemperature( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex, + temperature); + return; + } + + throw CreateUnsupportedMixtureException(nameof(mixture)); + } } internal void SetMixtureMoles(IInternalGasMixture mixture, int gasId, float moles) { ValidateGasId(gasId); ValidateNonnegativeFinite(moles, nameof(moles)); - MutateMixture(mixture, state => SetStateMoles(state, gasId, moles)); + lock (_mixtureGate) + { + ThrowIfDisposed(); + if (mixture is GasMixture owned) + { + SetOwnedMixtureMoles(owned.State, gasId, moles); + return; + } + + if (mixture is VoxelGasMixture voxel) + { + _kernel.SetVoxelMixtureMoles( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex, + gasId, + moles); + return; + } + + throw CreateUnsupportedMixtureException(nameof(mixture)); + } } internal void AdjustMixtureMoles(IInternalGasMixture mixture, int gasId, float deltaMoles) @@ -115,13 +240,31 @@ internal void AdjustMixtureMoles(IInternalGasMixture mixture, int gasId, float d if (!float.IsFinite(deltaMoles)) throw new ArgumentOutOfRangeException(nameof(deltaMoles), deltaMoles, "Mole adjustment must be finite."); - MutateMixture(mixture, state => + lock (_mixtureGate) { - double adjusted = state.Moles.GetValueOrDefault(gasId) + (double)deltaMoles; - if (!double.IsFinite(adjusted) || adjusted > float.MaxValue) - throw new InvalidOperationException("The adjusted gas amount exceeds the supported range."); - SetStateMoles(state, gasId, (float)Math.Max(0d, adjusted)); - }); + ThrowIfDisposed(); + if (mixture is GasMixture owned) + { + double adjusted = owned.State.Moles.GetValueOrDefault(gasId) + (double)deltaMoles; + if (!double.IsFinite(adjusted) || adjusted > float.MaxValue) + throw new InvalidOperationException("The adjusted gas amount exceeds the supported range."); + SetOwnedMixtureMoles(owned.State, gasId, (float)Math.Max(0d, adjusted)); + return; + } + + if (mixture is VoxelGasMixture voxel) + { + _kernel.AdjustVoxelMixtureMoles( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex, + gasId, + deltaMoles); + return; + } + + throw CreateUnsupportedMixtureException(nameof(mixture)); + } } internal void AddGasToMixture(IInternalGasMixture mixture, int gasId, float moles, float temperature) @@ -130,17 +273,53 @@ internal void AddGasToMixture(IInternalGasMixture mixture, int gasId, float mole ValidatePositiveFinite(moles, nameof(moles)); ValidateTemperature(temperature); - MutateMixture(mixture, destination => + lock (_mixtureGate) { - var incoming = new GasMixtureState(destination.Volume, temperature); - incoming.Moles.Add(gasId, moles); - MergeStates(destination, incoming); - }); + ThrowIfDisposed(); + if (mixture is GasMixture owned) + { + AddGasToOwnedMixture(owned.State, gasId, moles, temperature); + return; + } + + if (mixture is VoxelGasMixture voxel) + { + _kernel.AddVoxelMixtureGas( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex, + gasId, + moles, + temperature); + return; + } + + throw CreateUnsupportedMixtureException(nameof(mixture)); + } } internal void ClearMixture(IInternalGasMixture mixture) { - MutateMixture(mixture, static state => state.Moles.Clear()); + lock (_mixtureGate) + { + ThrowIfDisposed(); + if (mixture is GasMixture owned) + { + owned.State.Moles.Clear(); + return; + } + + if (mixture is VoxelGasMixture voxel) + { + _kernel.ClearVoxelMixture( + voxel.ChunkPosition, + voxel.ChunkGeneration, + voxel.LocalVoxelIndex); + return; + } + + throw CreateUnsupportedMixtureException(nameof(mixture)); + } } internal GasMixture RemoveFromMixture(IInternalGasMixture mixture, float moles) @@ -148,7 +327,6 @@ internal GasMixture RemoveFromMixture(IInternalGasMixture mixture, float moles) ValidateNonnegativeFinite(moles, nameof(moles)); return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => { - ValidateOwnedMixture(mixture, nameof(mixture)); var source = CaptureMixtureState(mixture); float totalMoles = source.TotalMoles; float ratio = totalMoles > 0f ? MathF.Min(1f, moles / totalMoles) : 0f; @@ -161,7 +339,6 @@ internal GasMixture RemoveRatioFromMixture(IInternalGasMixture mixture, float ra ValidateFinite(ratio, nameof(ratio)); return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => { - ValidateOwnedMixture(mixture, nameof(mixture)); return RemoveRatioCore(mixture, CaptureMixtureState(mixture), Math.Clamp(ratio, 0f, 1f)); }); } @@ -171,7 +348,6 @@ internal GasMixture RemoveVolumeFromMixture(IInternalGasMixture mixture, float v ValidateNonnegativeFinite(volume, nameof(volume)); return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => { - ValidateOwnedMixture(mixture, nameof(mixture)); var source = CaptureMixtureState(mixture); float ratio = Math.Clamp(volume / source.Volume, 0f, 1f); return RemoveRatioCore(mixture, source, ratio); @@ -200,7 +376,6 @@ internal GasMixture CloneMixture(IInternalGasMixture mixture) { return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => { - ValidateOwnedMixture(mixture, nameof(mixture)); return new GasMixture(this, CaptureMixtureState(mixture)); }); } @@ -209,7 +384,6 @@ internal GasMixtureSnapshot GetMixtureSnapshot(IInternalGasMixture mixture) { return ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => { - ValidateOwnedMixture(mixture, nameof(mixture)); var state = CaptureMixtureState(mixture); var gases = new GasMixtureGas[state.ActiveGasCount]; var index = 0; @@ -225,55 +399,6 @@ internal GasMixtureSnapshot GetMixtureSnapshot(IInternalGasMixture mixture) }); } - private MixtureMetrics GetMixtureMetrics(IInternalGasMixture mixture) - { - lock (_mixtureGate) - { - ThrowIfDisposed(); - ValidateOwnedMixture(mixture, nameof(mixture)); - if (mixture is GasMixture owned) - { - var state = owned.State; - return new MixtureMetrics( - state.Volume, - state.Temperature, - CalculateMixturePressure(state), - state.TotalMoles, - state.ActiveGasCount); - } - - if (mixture is VoxelGasMixture voxel) - { - var metrics = _kernel.GetVoxelMixtureMetrics( - voxel.ChunkPosition, - voxel.ChunkGeneration, - voxel.LocalVoxelIndex); - return new MixtureMetrics( - metrics.Volume, - metrics.Temperature, - metrics.Pressure, - metrics.TotalMoles, - metrics.ActiveGasCount); - } - - throw CreateUnsupportedMixtureException(nameof(mixture)); - } - } - - private void MutateMixture(IInternalGasMixture mixture, Action mutation) - { - ExecuteMixtureTransaction(mixture is VoxelGasMixture, () => - { - ValidateOwnedMixture(mixture, nameof(mixture)); - ValidateMixtureCanMutate(mixture); - var state = CaptureMixtureState(mixture); - mutation(state); - ValidateState(state); - ApplyMixtureState(mixture, state); - return true; - }); - } - private GasMixture RemoveRatioCore(IInternalGasMixture mixture, GasMixtureState source, float ratio) { var removed = new GasMixtureState(source.Volume, source.Temperature); @@ -301,16 +426,12 @@ private float ExecuteTransfer( IInternalGasMixture destination, Func getRatio) { - ValidateOwnedMixture(source, nameof(source)); if (IsSameMixture(source, destination)) return 0f; bool usesVoxel = source is VoxelGasMixture || destination is VoxelGasMixture; return ExecuteMixtureTransaction(usesVoxel, () => { - ValidateOwnedMixture(source, nameof(source)); - ValidateOwnedMixture(destination, nameof(destination)); - var sourceState = CaptureMixtureState(source); float ratio = getRatio(sourceState); if (ratio <= 0f || sourceState.ActiveGasCount == 0) @@ -415,6 +536,58 @@ private static VoxelGasMixtureAddress GetVoxelAddress(VoxelGasMixture mixture) mixture.LocalVoxelIndex); } + private void SetOwnedMixtureMoles(GasMixtureState state, int gasId, float moles) + { + bool hadGas = state.Moles.TryGetValue(gasId, out float previousMoles); + SetStateMoles(state, gasId, moles); + try + { + ValidateState(state); + } + catch + { + if (hadGas) + state.Moles[gasId] = previousMoles; + else + state.Moles.Remove(gasId); + throw; + } + } + + private void AddGasToOwnedMixture(GasMixtureState state, int gasId, float moles, float temperature) + { + bool hadGas = state.Moles.TryGetValue(gasId, out float previousMoles); + float previousTemperature = state.Temperature; + double combinedMoles = previousMoles + (double)moles; + if (!double.IsFinite(combinedMoles) || combinedMoles > float.MaxValue) + throw new InvalidOperationException("A merged gas amount exceeds the supported range."); + + double currentHeatCapacity = CalculateMixtureHeatCapacity(state); + double incomingHeatCapacity = moles * (double)GetMolarHeatCapacityAtConstantVolume(gasId); + double combinedHeatCapacity = currentHeatCapacity + incomingHeatCapacity; + float mixedTemperature = combinedHeatCapacity > 0d + ? (float)((currentHeatCapacity * GetEffectiveMixtureTemperature(state.Temperature) + + incomingHeatCapacity * GetEffectiveMixtureTemperature(temperature)) / + combinedHeatCapacity) + : temperature; + + state.Moles[gasId] = (float)combinedMoles; + state.Temperature = mixedTemperature; + try + { + ValidateState(state); + } + catch + { + if (hadGas) + state.Moles[gasId] = previousMoles; + else + state.Moles.Remove(gasId); + state.Temperature = previousTemperature; + throw; + } + } + private void MergeStates(GasMixtureState destination, GasMixtureState incoming) { if (incoming.ActiveGasCount == 0) @@ -516,7 +689,6 @@ private static void SetStateMoles(GasMixtureState state, int gasId, float moles) private void ValidateState(GasMixtureState state) { ValidateVolume(state.Volume); - ValidateTemperature(state.Temperature); double total = 0d; foreach (var (gasId, moles) in state.Moles) { @@ -612,10 +784,4 @@ private static void ValidateFinite(float value, string parameterName) throw new ArgumentOutOfRangeException(parameterName, value, "Value must be finite."); } - private readonly record struct MixtureMetrics( - float Volume, - float Temperature, - float Pressure, - float TotalMoles, - int ActiveGasCount); -} \ No newline at end of file +} diff --git a/src/Numos.API/GasMixture.cs b/src/Numos.API/GasMixture.cs index 217a0db..51e6750 100644 --- a/src/Numos.API/GasMixture.cs +++ b/src/Numos.API/GasMixture.cs @@ -93,6 +93,6 @@ public float TransferRatioTo(IGasMixture destination, float ratio) => internal void ApplyState(GasMixtureState state) { - _state = state.Clone(); + _state = state; } } \ No newline at end of file diff --git a/src/Numos.API/IGasMixture.cs b/src/Numos.API/IGasMixture.cs index 71f987f..4f22289 100644 --- a/src/Numos.API/IGasMixture.cs +++ b/src/Numos.API/IGasMixture.cs @@ -21,6 +21,10 @@ public interface IGasMixture float Volume { get; } /// The stored temperature, in kelvins (K). + /// + /// The setter stores the raw value. Pressure and energy calculations use the owner's configured fallback + /// when the stored value is non-finite or nonpositive. + /// float Temperature { get; set; } /// The ideal-gas pressure, in pascals (Pa). diff --git a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs index a14eb37..16c40b8 100644 --- a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs +++ b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs @@ -1,15 +1,9 @@ +using System.Diagnostics; using Numos.CoreSim.Datatypes.Primitives; using Numos.Maths; namespace Numos.CoreSim; -internal readonly record struct VoxelGasMixtureMetrics( - float Volume, - float Temperature, - float Pressure, - float TotalMoles, - int ActiveGasCount); - internal readonly record struct VoxelGasMixtureState( float Volume, float Temperature, @@ -57,7 +51,19 @@ internal void ExecuteMixtureTransaction(Action transaction) } } - internal VoxelGasMixtureMetrics GetVoxelMixtureMetrics( + internal float GetVoxelMixtureVolume( + Int3 position, + long generation, + ushort localVoxelIndex) + { + lock (_stateGate) + { + GetMixtureChunk(position, generation, localVoxelIndex); + return GetVoxelVolume(); + } + } + + internal float GetVoxelMixtureTemperature( Int3 position, long generation, ushort localVoxelIndex) @@ -65,25 +71,51 @@ internal VoxelGasMixtureMetrics GetVoxelMixtureMetrics( lock (_stateGate) { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); - double totalMoles = 0d; - var activeGasCount = 0; + return chunk.Temperature[localVoxelIndex]; + } + } + + internal float GetVoxelMixturePressure( + Int3 position, + long generation, + ushort localVoxelIndex) + { + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + float totalMoles = GetVoxelTotalMoles(chunk, localVoxelIndex); + return CalculatePressure(totalMoles, chunk.Temperature[localVoxelIndex]); + } + } + + internal float GetVoxelMixtureTotalMoles( + Int3 position, + long generation, + ushort localVoxelIndex) + { + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + return GetVoxelTotalMoles(chunk, localVoxelIndex); + } + } + + internal int GetVoxelMixtureActiveGasCount( + Int3 position, + long generation, + ushort localVoxelIndex) + { + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + var count = 0; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { - float moles = MathF.Max(0f, chunk.ActiveGases[gas].Moles[localVoxelIndex]); - if (moles <= 0f) - continue; - totalMoles += moles; - activeGasCount++; + if (chunk.ActiveGases[gas].Moles[localVoxelIndex] > 0f) + count++; } - float storedTemperature = chunk.Temperature[localVoxelIndex]; - float pressure = CalculatePressure((float)totalMoles, storedTemperature); - return new VoxelGasMixtureMetrics( - GetVoxelVolume(), - storedTemperature, - pressure, - (float)totalMoles, - activeGasCount); + return count; } } @@ -99,7 +131,7 @@ internal float GetVoxelMixtureMoles( for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { if (chunk.ActiveGases[gas].GasId == gasId) - return MathF.Max(0f, chunk.ActiveGases[gas].Moles[localVoxelIndex]); + return chunk.ActiveGases[gas].Moles[localVoxelIndex]; } return 0f; @@ -118,7 +150,7 @@ internal VoxelGasMixtureState CaptureVoxelMixture( var gasCount = 0; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { - float moles = MathF.Max(0f, chunk.ActiveGases[gas].Moles[localVoxelIndex]); + float moles = chunk.ActiveGases[gas].Moles[localVoxelIndex]; if (moles <= 0f) continue; gases[gasCount++] = new KeyValuePair(chunk.ActiveGases[gas].GasId, moles); @@ -135,6 +167,144 @@ internal VoxelGasMixtureState CaptureVoxelMixture( } } + internal void SetVoxelMixtureTemperature( + Int3 position, + long generation, + ushort localVoxelIndex, + float temperature) + { + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + int roomId = GetGasRoomId(chunk, localVoxelIndex); + chunk.WakeRoom(roomId); + chunk.Temperature[localVoxelIndex] = temperature; + chunk.MarkChanged(); + } + } + + internal void SetVoxelMixtureMoles( + Int3 position, + long generation, + ushort localVoxelIndex, + int gasId, + float moles) + { + Debug.Assert(gasId >= 0); + Debug.Assert(float.IsFinite(moles) && moles >= 0f); + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + int roomId = GetGasRoomId(chunk, localVoxelIndex); + VoxelGasMixtureTotals totals = CalculateVoxelMixtureTotals( + chunk, + localVoxelIndex, + chunk.Temperature[localVoxelIndex], + gasId, + moles); + + chunk.WakeRoom(roomId); + SetVoxelGasMoles(chunk, localVoxelIndex, gasId, moles); + ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); + } + } + + internal void AdjustVoxelMixtureMoles( + Int3 position, + long generation, + ushort localVoxelIndex, + int gasId, + float deltaMoles) + { + Debug.Assert(gasId >= 0); + Debug.Assert(float.IsFinite(deltaMoles)); + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + float currentMoles = GetVoxelGasMoles(chunk, localVoxelIndex, gasId); + double adjusted = currentMoles + (double)deltaMoles; + if (!double.IsFinite(adjusted) || adjusted > float.MaxValue) + throw new InvalidOperationException("The adjusted gas amount exceeds the supported range."); + + float moles = (float)Math.Max(0d, adjusted); + int roomId = GetGasRoomId(chunk, localVoxelIndex); + VoxelGasMixtureTotals totals = CalculateVoxelMixtureTotals( + chunk, + localVoxelIndex, + chunk.Temperature[localVoxelIndex], + gasId, + moles); + + chunk.WakeRoom(roomId); + SetVoxelGasMoles(chunk, localVoxelIndex, gasId, moles); + ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); + } + } + + internal void AddVoxelMixtureGas( + Int3 position, + long generation, + ushort localVoxelIndex, + int gasId, + float moles, + float temperature) + { + Debug.Assert(gasId >= 0); + Debug.Assert(float.IsFinite(moles) && moles > 0f); + Debug.Assert(float.IsFinite(temperature) && temperature >= 0f); + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + float currentGasMoles = GetVoxelGasMoles(chunk, localVoxelIndex, gasId); + double combinedGasMoles = currentGasMoles + (double)moles; + if (!double.IsFinite(combinedGasMoles) || combinedGasMoles > float.MaxValue) + throw new InvalidOperationException("A merged gas amount exceeds the supported range."); + + VoxelGasMixtureTotals currentTotals = CalculateVoxelMixtureTotals( + chunk, + localVoxelIndex, + chunk.Temperature[localVoxelIndex]); + float currentHeatCapacity = currentTotals.HeatCapacity; + float incomingHeatCapacity = moles * GetMolarHeatCapacityAtConstantVolume(gasId); + double combinedHeatCapacity = currentHeatCapacity + (double)incomingHeatCapacity; + float mixedTemperature = combinedHeatCapacity > 0d + ? (float)((currentHeatCapacity * GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]) + + incomingHeatCapacity * GetEffectiveTemperature(temperature)) / + combinedHeatCapacity) + : temperature; + + int roomId = GetGasRoomId(chunk, localVoxelIndex); + VoxelGasMixtureTotals totals = CalculateVoxelMixtureTotals( + chunk, + localVoxelIndex, + mixedTemperature, + gasId, + (float)combinedGasMoles); + + chunk.WakeRoom(roomId); + SetVoxelGasMoles(chunk, localVoxelIndex, gasId, (float)combinedGasMoles); + chunk.Temperature[localVoxelIndex] = mixedTemperature; + ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); + } + } + + internal void ClearVoxelMixture( + Int3 position, + long generation, + ushort localVoxelIndex) + { + lock (_stateGate) + { + var chunk = GetMixtureChunk(position, generation, localVoxelIndex); + int roomId = GetGasRoomId(chunk, localVoxelIndex); + chunk.WakeRoom(roomId); + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + chunk.ActiveGases[gas].Moles[localVoxelIndex] = 0f; + + ApplyVoxelMixtureTotals(chunk, localVoxelIndex, default); + } + } + internal void ValidateVoxelMixtureMutations(VoxelGasMixtureAddress[] addresses) { ArgumentNullException.ThrowIfNull(addresses); @@ -180,38 +350,24 @@ internal void ReplaceVoxelMixture( float temperature, KeyValuePair[] gases) { - ArgumentNullException.ThrowIfNull(gases); - if (!float.IsFinite(temperature) || temperature < 0f) - throw new ArgumentOutOfRangeException(nameof(temperature)); - - var previousGasId = -1; - double totalMoles = 0d; - foreach (var (gasId, moles) in gases) - { - if (gasId < 0) - throw new ArgumentOutOfRangeException(nameof(gases), "Gas IDs must be nonnegative."); - if (gasId <= previousGasId) - throw new ArgumentException("Gas IDs must be unique and ordered.", nameof(gases)); - if (!float.IsFinite(moles) || moles <= 0f) - throw new ArgumentOutOfRangeException(nameof(gases), "Gas amounts must be positive and finite."); - previousGasId = gasId; - totalMoles += moles; - } - if (!double.IsFinite(totalMoles) || totalMoles > float.MaxValue) - throw new InvalidOperationException("The mixture's total moles exceed the supported range."); + Debug.Assert(gases != null); lock (_stateGate) { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); int roomId = chunk.VoxelRoomMap[localVoxelIndex]; - if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) - throw new InvalidOperationException("Solid and void voxels cannot contain a gas mixture."); + Debug.Assert(roomId != VoxelClassification.RoomSolid && roomId != VoxelClassification.RoomVoid); + double totalMoles = 0d; double totalHeatCapacity = 0d; foreach (var (gasId, moles) in gases) + { + totalMoles += moles; totalHeatCapacity += (double)moles * GetMolarHeatCapacityAtConstantVolume(gasId); - if (!double.IsFinite(totalHeatCapacity) || totalHeatCapacity > float.MaxValue) - throw new InvalidOperationException("The mixture's total heat capacity exceeds the supported range."); + } + + Debug.Assert(double.IsFinite(totalMoles) && totalMoles <= float.MaxValue); + Debug.Assert(double.IsFinite(totalHeatCapacity) && totalHeatCapacity <= float.MaxValue); chunk.WakeRoom(roomId); for (var gas = 0; gas < chunk.ActiveGasCount; gas++) @@ -230,6 +386,122 @@ internal void ReplaceVoxelMixture( } } + private float GetVoxelTotalMoles(AtmosChunk chunk, ushort localVoxelIndex) + { + double totalMoles = 0d; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + totalMoles += chunk.ActiveGases[gas].Moles[localVoxelIndex]; + return (float)totalMoles; + } + + private static float GetVoxelGasMoles(AtmosChunk chunk, ushort localVoxelIndex, int gasId) + { + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + if (chunk.ActiveGases[gas].GasId == gasId) + return chunk.ActiveGases[gas].Moles[localVoxelIndex]; + } + + return 0f; + } + + private static void SetVoxelGasMoles(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles) + { + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + if (chunk.ActiveGases[gas].GasId != gasId) + continue; + chunk.ActiveGases[gas].Moles[localVoxelIndex] = moles; + return; + } + + if (moles <= 0f) + return; + + int channel = chunk.GetOrCreateGasChannel(gasId); + chunk.ActiveGases[channel].Moles[localVoxelIndex] = moles; + } + + private static int GetGasRoomId(AtmosChunk chunk, ushort localVoxelIndex) + { + int roomId = chunk.VoxelRoomMap[localVoxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + throw new InvalidOperationException("Solid and void voxels cannot contain a gas mixture."); + + if (chunk.IsAwake) + { + for (var room = 0; room < chunk.ActiveRoomCount; room++) + { + if (chunk.ActiveRoomIds[room] == roomId) + return roomId; + } + + if (chunk.ActiveRoomCount >= chunk.MaxActiveRooms) + { + throw new InvalidOperationException( + "The gas-mixture operation would exceed the chunk's active-room capacity."); + } + } + + return roomId; + } + + private VoxelGasMixtureTotals CalculateVoxelMixtureTotals( + AtmosChunk chunk, + ushort localVoxelIndex, + float temperature, + int overrideGasId = -1, + float overrideMoles = 0f) + { + double totalMoles = 0d; + double totalHeatCapacity = 0d; + var foundOverride = false; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + int gasId = chunk.ActiveGases[gas].GasId; + float moles = gasId == overrideGasId + ? overrideMoles + : chunk.ActiveGases[gas].Moles[localVoxelIndex]; + foundOverride |= gasId == overrideGasId; + if (moles <= 0f) + continue; + + totalMoles += moles; + totalHeatCapacity += (double)moles * GetMolarHeatCapacityAtConstantVolume(gasId); + } + + if (!foundOverride && overrideGasId >= 0 && overrideMoles > 0f) + { + totalMoles += overrideMoles; + totalHeatCapacity += + (double)overrideMoles * GetMolarHeatCapacityAtConstantVolume(overrideGasId); + } + + if (!double.IsFinite(totalMoles) || totalMoles > float.MaxValue) + throw new InvalidOperationException("The mixture's total moles exceed the supported range."); + if (!double.IsFinite(totalHeatCapacity) || totalHeatCapacity > float.MaxValue) + throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); + + double pressure = totalMoles * AtmosPhysicalConstants.MolarGasConstant * + GetEffectiveTemperature(temperature) / GetVoxelVolume(); + if (!double.IsFinite(pressure) || pressure > float.MaxValue) + throw new InvalidOperationException("The mixture's pressure exceeds the supported range."); + + return new VoxelGasMixtureTotals( + (float)totalHeatCapacity, + (float)pressure); + } + + private static void ApplyVoxelMixtureTotals( + AtmosChunk chunk, + ushort localVoxelIndex, + VoxelGasMixtureTotals totals) + { + chunk.TotalHeatCapacity[localVoxelIndex] = totals.HeatCapacity; + chunk.TotalPressure[localVoxelIndex] = totals.Pressure; + chunk.MarkChanged(); + } + private AtmosChunk GetMixtureChunk(Int3 position, long generation, ushort localVoxelIndex) { var chunk = GetChunk(position); @@ -242,4 +514,8 @@ private AtmosChunk GetMixtureChunk(Int3 position, long generation, ushort localV ValidateVoxelIndex(chunk, localVoxelIndex); return chunk; } + + private readonly record struct VoxelGasMixtureTotals( + float HeatCapacity, + float Pressure); } \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosKernel.cs b/src/Numos.CoreSim/AtmosKernel.cs index 09df11b..f9adf31 100644 --- a/src/Numos.CoreSim/AtmosKernel.cs +++ b/src/Numos.CoreSim/AtmosKernel.cs @@ -24,11 +24,13 @@ internal sealed partial class AtmosKernel : IDisposable private readonly ThreadLocal _precipBufferPool; private readonly object _stateGate = new(); private readonly List _activeThermalBoundaryEdges = []; - private readonly Dictionary _thermalBoundaryEnergyDeltas = []; + // Boundary payloads match the float-backed voxel state; only arithmetic that benefits from extra range or + // precision is promoted to double while it is being evaluated. + private readonly Dictionary _thermalBoundaryEnergyDeltas = []; private readonly HashSet _thermalBoundaryEdges = []; private readonly ThreadLocal _thermalBoundaryBufferPool; private readonly ConcurrentQueue<(Int3 Key, ThermalBoundaryEvent Evt)> _thermalBoundaryEvents = new(); - private readonly Dictionary _thermalBoundaryIncidentConductances = []; + private readonly Dictionary _thermalBoundaryIncidentConductances = []; private readonly List _thermalBoundaryOrderedEdges = []; private readonly Dictionary _thermalBoundaryStates = []; @@ -44,6 +46,7 @@ internal sealed partial class AtmosKernel : IDisposable private float _accumulator; private long _chunkCollectionRevision; + private readonly AtmosSolverConfigSnapshot _tickConfig = new(); /// /// Current that this simulation runs under. @@ -91,6 +94,7 @@ public void Dispose() private void TickSimulation(AtmosChunk[] chunks) { + _tickConfig.Capture(_config); TickCount++; // A revision is advanced once per processed tick. Conditional snapshot consumers can @@ -249,12 +253,14 @@ private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, if (totalMoles > 0) { - float temp = GetEffectiveTemperature(sourceChunk.Temperature[srcIdx]); + float temp = _tickConfig.GetEffectiveTemperature(sourceChunk.Temperature[srcIdx]); float invTemp = 1f / temp; - float advectedMoles = PressureToMoles(bulkPressureTransfer, temp); + float advectedMoles = TickPressureToMoles(bulkPressureTransfer, temp); bool isVoid = neighborChunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomVoid; - float neighborTemp = isVoid ? 0f : GetEffectiveTemperature(neighborChunk.Temperature[neighborIdx]); + float neighborTemp = isVoid + ? 0f + : _tickConfig.GetEffectiveTemperature(neighborChunk.Temperature[neighborIdx]); float tempRatio = neighborTemp * invTemp; var movedGas = false; @@ -282,7 +288,7 @@ private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, } } - float diffusionCoeff = GetDiffusionCoefficient(gasId); + float diffusionCoeff = _tickConfig.GetDiffusionCoefficient(gasId); var molesDiffused = 0f; if (diffusionCoeff > 0) { @@ -299,7 +305,8 @@ private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, if (totalMolesToMove <= 0f) continue; - float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + float molarHeatCapacityAtConstantVolume = + _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); float heatCapacityTransferred = totalMolesToMove * molarHeatCapacityAtConstantVolume; sourceChunk.ActiveGases[g].Moles[srcIdx] -= totalMolesToMove; @@ -313,7 +320,7 @@ private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, { if (!neighborChunk.IsAwake) neighborChunk.WakeRoom(neighborChunk.VoxelRoomMap[neighborIdx]); - InjectGasWithEnergy(neighborChunk, neighborIdx, gasId, totalMolesToMove, temp, + InjectGasWithEnergyDuringTick(neighborChunk, neighborIdx, gasId, totalMolesToMove, temp, molarHeatCapacityAtConstantVolume); } } @@ -326,7 +333,7 @@ private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, if (sourceChunk.TotalHeatCapacity[srcIdx] > 0f) sourceChunk.Temperature[srcIdx] = temp; - sourceChunk.TotalPressure[srcIdx] = CalculatePressure(remainingMoles, temp); + sourceChunk.TotalPressure[srcIdx] = CalculateTickPressure(remainingMoles, temp); } } } @@ -367,7 +374,7 @@ private void Advect(AtmosChunk chunk, BoundaryFlowEvent[] boundaryBuffer, ref in float[] scheduledOutflows = ArrayPool.Shared.Rent(gasVoxelCount); Array.Clear(scheduledOutflows, 0, gasVoxelCount); - float vacuumThreshold = GetNonNegativeFinite(_config.VacuumThreshold); + float vacuumThreshold = _tickConfig.VacuumThreshold; for (var i = 0; i < chunk.ActiveAirCount; i++) { @@ -441,8 +448,8 @@ private void Advect(AtmosChunk chunk, BoundaryFlowEvent[] boundaryBuffer, ref in ArrayPool.Shared.Return(scheduledOutflows); } - float sleepEpsilon = GetNonNegativeFinite(_config.SleepEpsilon); - int sleepThreshold = Math.Max(0, _config.SleepThreshold); + float sleepEpsilon = _tickConfig.SleepEpsilon; + int sleepThreshold = _tickConfig.SleepThreshold; if (maxPressureDelta < sleepEpsilon) { @@ -511,20 +518,19 @@ private void CheckNeighborAdvect(AtmosChunk chunk, Int3 neighborPosition, ushort if (absDelta > maxPressureDelta) maxPressureDelta = absDelta; - if (!IsFinitePositive(totalMoles)) - return; - float bulkPressureTransfer = pressureDelta > 0f ? CalculateBulkPressureTransfer(pressureDelta, currentPressure) : 0f; // Species diffusion is independent of the total-pressure gradient and may counterflow against advection. // Pre-calculate factors to eliminate division in the species loop. - float temp = GetEffectiveTemperature(chunk.Temperature[idx]); + float temp = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); float invTemp = 1f / temp; - float advectedMoles = PressureToMoles(bulkPressureTransfer, temp); - float neighborTemp = isVoid ? 0f : GetEffectiveTemperature(chunk.Temperature[neighborIdx]); + float advectedMoles = TickPressureToMoles(bulkPressureTransfer, temp); + float neighborTemp = isVoid + ? 0f + : _tickConfig.GetEffectiveTemperature(chunk.Temperature[neighborIdx]); float tempRatio = neighborTemp * invTemp; for (var g = 0; g < chunk.ActiveGasCount; g++) @@ -539,7 +545,7 @@ private void CheckNeighborAdvect(AtmosChunk chunk, Int3 neighborPosition, ushort // 2. Vectorized Fickian Partial Pressure Diffusion float neighborMoles = isVoid ? 0f : chunk.ActiveGases[g].Moles[neighborIdx]; - float diffusionCoeff = GetDiffusionCoefficient(gasId); + float diffusionCoeff = _tickConfig.GetDiffusionCoefficient(gasId); var molesDiffused = 0f; if (diffusionCoeff > 0) @@ -561,7 +567,8 @@ private void CheckNeighborAdvect(AtmosChunk chunk, Int3 neighborPosition, ushort continue; scheduledOutflows[outflowOffset] += totalMolesToMove; - float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + float molarHeatCapacityAtConstantVolume = + _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); float energyTransferred = totalMolesToMove * molarHeatCapacityAtConstantVolume * temp; // Update the deltas for the current voxel and the neighbor voxel. @@ -598,9 +605,9 @@ private void CalculateTotalPressure(AtmosChunk chunk) molesInVoxel += chunk.ActiveGases[g].Moles[idx]; } - float temp = GetEffectiveTemperature(chunk.Temperature[idx]); + float temp = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); - chunk.TotalPressure[idx] = CalculatePressure(molesInVoxel, temp); + chunk.TotalPressure[idx] = CalculateTickPressure(molesInVoxel, temp); } } @@ -610,7 +617,8 @@ private void CalculateHeatCapacity(AtmosChunk chunk) for (var g = 0; g < chunk.ActiveGasCount; g++) { int gasId = chunk.ActiveGases[g].GasId; - float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + float molarHeatCapacityAtConstantVolume = + _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); for (var i = 0; i < chunk.ActiveAirCount; i++) { ushort idx = chunk.ActiveAirIndices[i]; @@ -639,18 +647,6 @@ private float GetMolarHeatCapacityAtConstantVolume(int gasId) return fallbackMolarHeatCapacityAtConstantVolume; } - private float GetDiffusionCoefficient(int gasId) - { - var gasRegistry = _config.GasRegistry; - if ((uint)gasId < (uint)gasRegistry.Count) - { - float coefficient = gasRegistry[gasId].DiffusionCoefficient; - return ClampUnitInterval(coefficient); - } - - return ClampUnitInterval(_config.DefaultDiffusionCoefficient); - } - private float GetVoxelVolume() { float volume = _config.VoxelVolume; @@ -669,13 +665,21 @@ private float CalculatePressure(float moles, float temperature) return (float)pressure; } - private float PressureToMoles(float pressure, float temperature) + private float CalculateTickPressure(float moles, float temperature) + { + double pressure = Math.Max(0d, moles) * _tickConfig.GetEffectiveTemperature(temperature) * + _tickConfig.PressurePerMoleKelvin; + return (float)pressure; + } + + private float TickPressureToMoles(float pressure, float temperature) { if (!IsFinitePositive(pressure)) return 0f; - double denominator = GetPressurePerMoleKelvin() * GetEffectiveTemperature(temperature); - return double.IsFinite(denominator) && denominator > 0d ? (float)(pressure / denominator) : 0f; + double denominator = _tickConfig.PressurePerMoleKelvin * + _tickConfig.GetEffectiveTemperature(temperature); + return (float)(pressure / denominator); } private float CalculateHeatCapacityAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) @@ -699,7 +703,7 @@ private float CalculatePressureAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) for (var g = 0; g < chunk.ActiveGasCount; g++) totalMoles += MathF.Max(0f, chunk.ActiveGases[g].Moles[localVoxelIndex]); - return CalculatePressure(totalMoles, chunk.Temperature[localVoxelIndex]); + return CalculateTickPressure(totalMoles, chunk.Temperature[localVoxelIndex]); } private float GetEffectiveTemperature(float storedTemperature) @@ -730,6 +734,44 @@ private void InjectGasWithEnergy(AtmosChunk chunk, ushort localVoxelIndex, int g GetPressurePerMoleKelvin()); } + private void InjectGasWithEnergyDuringTick(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, + float temperature, float molarHeatCapacityAtConstantVolume) + { + if (!chunk.IsAwake) + return; + + int room = chunk.VoxelRoomMap[localVoxelIndex]; + if (room == VoxelClassification.RoomSolid || room == VoxelClassification.RoomVoid) + return; + + chunk.TotalHeatCapacity[localVoxelIndex] = CalculateTickHeatCapacityAtVoxel(chunk, localVoxelIndex); + if (chunk.TotalHeatCapacity[localVoxelIndex] > 0f && + !IsFinitePositive(chunk.Temperature[localVoxelIndex])) + { + chunk.Temperature[localVoxelIndex] = + _tickConfig.GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]); + } + + chunk.InjectGasToVoxel(localVoxelIndex, gasId, moles, temperature, molarHeatCapacityAtConstantVolume, + _tickConfig.PressurePerMoleKelvin); + } + + private float CalculateTickHeatCapacityAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) + { + var totalHeatCapacity = 0f; + for (var g = 0; g < chunk.ActiveGasCount; g++) + { + float moles = chunk.ActiveGases[g].Moles[localVoxelIndex]; + if (moles <= 0f) + continue; + + totalHeatCapacity += moles * + _tickConfig.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[g].GasId); + } + + return totalHeatCapacity; + } + /// /// Applies buffered energy and mole deltas, then refreshes active-voxel temperature, heat-capacity, /// and pressure state. @@ -745,7 +787,7 @@ private void ApplyDeltas(AtmosChunk chunk, float[] deltas) for (var i = 0; i < chunk.ActiveAirCount; i++) { ushort idx = chunk.ActiveAirIndices[i]; - float energyTemperature = GetEffectiveTemperature(chunk.Temperature[idx]); + float energyTemperature = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); float oldEnergy = energyTemperature * chunk.TotalHeatCapacity[idx]; bool stateChanged = deltas[idx] != 0f; chunk.TotalHeatCapacity[idx] = 0; @@ -760,7 +802,8 @@ private void ApplyDeltas(AtmosChunk chunk, float[] deltas) chunk.ActiveGases[g].Moles[idx] = 0f; int gasId = chunk.ActiveGases[g].GasId; - float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + float molarHeatCapacityAtConstantVolume = + _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); chunk.TotalHeatCapacity[idx] += molarHeatCapacityAtConstantVolume * chunk.ActiveGases[g].Moles[idx]; totalMoles += chunk.ActiveGases[g].Moles[idx]; } @@ -771,7 +814,7 @@ private void ApplyDeltas(AtmosChunk chunk, float[] deltas) chunk.Temperature[idx] = MathF.Max(0f, newTemperature); } - chunk.TotalPressure[idx] = CalculatePressure(totalMoles, chunk.Temperature[idx]); + chunk.TotalPressure[idx] = CalculateTickPressure(totalMoles, chunk.Temperature[idx]); } ArrayPool.Shared.Return(deltas); // TODO PERF but what if..... this was threadlocal...... @@ -815,13 +858,15 @@ private void ProcessThermodynamics(AtmosChunk chunk, PrecipitationEvent[] precip private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] thermalBoundaryBuffer, ref int thermalBoundaryCount) { - float thermalConductance = _config.ThermalConductance; - float vacuumThreshold = GetNonNegativeFinite(_config.VacuumThreshold); - if (!IsFinitePositive(thermalConductance)) + float thermalConductance = _tickConfig.ThermalConductance; + float vacuumThreshold = _tickConfig.VacuumThreshold; + if (thermalConductance <= 0f) return; - double[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); - double[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); + // Keep per-voxel workspace at the same precision as the SoA state. Products, ratios, and the final + // energy-to-temperature conversion are promoted below, without doubling the solver's working set. + float[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); + float[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); Array.Clear(incidentConductances, 0, chunk.VoxelCount); Array.Clear(energyDeltas, 0, chunk.VoxelCount); @@ -881,26 +926,23 @@ private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] th for (var i = 0; i < chunk.ActiveAirCount; i++) { ushort idx = chunk.ActiveAirIndices[i]; - if (energyDeltas[idx] == 0d || - !TryGetThermalState(chunk, idx, vacuumThreshold, out double oldTemperature, - out double heatCapacity)) + if (energyDeltas[idx] == 0f || + !TryGetThermalState(chunk, idx, vacuumThreshold, out float oldTemperature, + out float heatCapacity)) continue; - double newEnergy = oldTemperature * heatCapacity + energyDeltas[idx]; + double newEnergy = (double)oldTemperature * heatCapacity + energyDeltas[idx]; double newTemperature = Math.Max(0d, newEnergy / heatCapacity); - if (!double.IsFinite(newTemperature)) - continue; - chunk.Temperature[idx] = (float)newTemperature; chunk.TotalPressure[idx] = CalculatePressureAtVoxel(chunk, idx); } - ArrayPool.Shared.Return(incidentConductances); - ArrayPool.Shared.Return(energyDeltas); + ArrayPool.Shared.Return(incidentConductances); + ArrayPool.Shared.Return(energyDeltas); } private void AccumulateThermalConductance(AtmosChunk chunk, Int3 neighborPosition, ushort idx, - float thermalConductance, float vacuumThreshold, double[] incidentConductances) + float thermalConductance, float vacuumThreshold, float[] incidentConductances) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; @@ -909,13 +951,13 @@ private void AccumulateThermalConductance(AtmosChunk chunk, Int3 neighborPositio if (chunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomSolid) return; - if (!TryGetThermalState(chunk, idx, vacuumThreshold, out _, out double currentHeatCapacity) || - !TryGetThermalState(chunk, neighborIdx, vacuumThreshold, out _, out double neighborHeatCapacity)) + if (!TryGetThermalState(chunk, idx, vacuumThreshold, out _, out float currentHeatCapacity) || + !TryGetThermalState(chunk, neighborIdx, vacuumThreshold, out _, out float neighborHeatCapacity)) return; - double conductance = CalculateThermalConductance(currentHeatCapacity, neighborHeatCapacity, + float conductance = CalculateThermalConductance(currentHeatCapacity, neighborHeatCapacity, thermalConductance); - if (conductance <= 0d) + if (conductance <= 0f) return; incidentConductances[idx] += conductance; @@ -923,7 +965,7 @@ private void AccumulateThermalConductance(AtmosChunk chunk, Int3 neighborPositio } private void ApplyThermalFlux(AtmosChunk chunk, Int3 neighborPosition, ushort idx, - float thermalConductance, float vacuumThreshold, double[] incidentConductances, double[] energyDeltas) + float thermalConductance, float vacuumThreshold, float[] incidentConductances, float[] energyDeltas) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; @@ -932,25 +974,24 @@ private void ApplyThermalFlux(AtmosChunk chunk, Int3 neighborPosition, ushort id if (chunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomSolid) return; - if (!TryGetThermalState(chunk, idx, vacuumThreshold, out double currentTemperature, - out double currentHeatCapacity) || - !TryGetThermalState(chunk, neighborIdx, vacuumThreshold, out double neighborTemperature, - out double neighborHeatCapacity)) + if (!TryGetThermalState(chunk, idx, vacuumThreshold, out float currentTemperature, + out float currentHeatCapacity) || + !TryGetThermalState(chunk, neighborIdx, vacuumThreshold, out float neighborTemperature, + out float neighborHeatCapacity)) return; - double conductance = CalculateThermalConductance(currentHeatCapacity, neighborHeatCapacity, + float conductance = CalculateThermalConductance(currentHeatCapacity, neighborHeatCapacity, thermalConductance); - double currentIncidentConductance = incidentConductances[idx]; - double neighborIncidentConductance = incidentConductances[neighborIdx]; - if (conductance <= 0d || !double.IsFinite(currentIncidentConductance) || - currentIncidentConductance <= 0d || !double.IsFinite(neighborIncidentConductance) || - neighborIncidentConductance <= 0d) + float currentIncidentConductance = incidentConductances[idx]; + float neighborIncidentConductance = incidentConductances[neighborIdx]; + if (conductance <= 0f || currentIncidentConductance <= 0f || neighborIncidentConductance <= 0f) return; - double scale = Math.Min(1d, Math.Min(currentHeatCapacity / currentIncidentConductance, - neighborHeatCapacity / neighborIncidentConductance)); - double heatTransfer = scale * conductance * (currentTemperature - neighborTemperature); - if (!double.IsFinite(heatTransfer) || heatTransfer == 0d) + double scale = Math.Min(1d, Math.Min( + (double)currentHeatCapacity / currentIncidentConductance, + (double)neighborHeatCapacity / neighborIncidentConductance)); + float heatTransfer = (float)(scale * conductance * (currentTemperature - neighborTemperature)); + if (heatTransfer == 0f) return; energyDeltas[idx] -= heatTransfer; @@ -958,16 +999,16 @@ private void ApplyThermalFlux(AtmosChunk chunk, Int3 neighborPosition, ushort id } private bool TryGetThermalState(AtmosChunk chunk, ushort idx, float vacuumThreshold, - out double temperature, out double heatCapacity) + out float temperature, out float heatCapacity) { float storedHeatCapacity = chunk.TotalHeatCapacity[idx]; float pressure = chunk.TotalPressure[idx]; - float effectiveTemperature = GetEffectiveTemperature(chunk.Temperature[idx]); + float effectiveTemperature = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); if (!IsFinitePositive(storedHeatCapacity) || !float.IsFinite(pressure) || - pressure < vacuumThreshold || !float.IsFinite(effectiveTemperature) || effectiveTemperature < 0f) + pressure < vacuumThreshold) { - temperature = 0d; - heatCapacity = 0d; + temperature = 0f; + heatCapacity = 0f; return false; } @@ -976,18 +1017,16 @@ private bool TryGetThermalState(AtmosChunk chunk, ushort idx, float vacuumThresh return true; } - private static double CalculateThermalConductance(double sourceHeatCapacity, double targetHeatCapacity, + private static float CalculateThermalConductance(float sourceHeatCapacity, float targetHeatCapacity, float thermalConductance) { - if (!double.IsFinite(sourceHeatCapacity) || sourceHeatCapacity <= 0d || - !double.IsFinite(targetHeatCapacity) || targetHeatCapacity <= 0d || - !IsFinitePositive(thermalConductance)) - return 0d; - - double equilibriumConductance = sourceHeatCapacity * targetHeatCapacity / - (sourceHeatCapacity + targetHeatCapacity); - double conductance = Math.Min(thermalConductance, equilibriumConductance); - return double.IsFinite(conductance) && conductance > 0d ? conductance : 0d; + Debug.Assert(float.IsFinite(sourceHeatCapacity) && sourceHeatCapacity > 0f); + Debug.Assert(float.IsFinite(targetHeatCapacity) && targetHeatCapacity > 0f); + Debug.Assert(float.IsFinite(thermalConductance) && thermalConductance > 0f); + + double equilibriumConductance = (double)sourceHeatCapacity * targetHeatCapacity / + ((double)sourceHeatCapacity + targetHeatCapacity); + return (float)Math.Min(thermalConductance, equilibriumConductance); } private static bool IsFinitePositive(float value) @@ -995,41 +1034,25 @@ private static bool IsFinitePositive(float value) return float.IsFinite(value) && value > 0f; } - private static float ClampUnitInterval(float value) - { - return float.IsFinite(value) ? Math.Clamp(value, 0f, 1f) : 0f; - } - - private static float GetNonNegativeFinite(float value) - { - return float.IsFinite(value) ? MathF.Max(0f, value) : 0f; - } - private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBuffer, ref int precipCount) { - var gasRegistry = _config.GasRegistry; - Debug.Assert(gasRegistry != null, nameof(gasRegistry) + " != null"); - - float condensationRateFactor = ClampUnitInterval(_config.CondensationRateFactor); + float condensationRateFactor = _tickConfig.CondensationRateFactor; if (condensationRateFactor <= 0f) return; - float referencePressure = _config.SaturationReferencePressure; - if (!IsFinitePositive(referencePressure)) - referencePressure = AtmosConfigDefaults.SaturationReferencePressure; + float referencePressure = _tickConfig.SaturationReferencePressure; for (var g = 0; g < chunk.ActiveGasCount; g++) { int gasId = chunk.ActiveGases[g].GasId; - if ((uint)gasId >= (uint)gasRegistry.Count) + if (!_tickConfig.TryGetGasProperties(gasId, out var props)) continue; - var props = gasRegistry[gasId]; - if (props.CondensationEnabled) { float boilingPoint = props.BoilingPoint; float molarEnthalpyOfVaporization = props.MolarEnthalpyOfVaporization; - float molarHeatCapacityAtConstantVolume = GetMolarHeatCapacityAtConstantVolume(gasId); + float molarHeatCapacityAtConstantVolume = + _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); if (!IsFinitePositive(boilingPoint) || !IsFinitePositive(molarEnthalpyOfVaporization)) continue; @@ -1039,10 +1062,10 @@ private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBu for (var i = 0; i < chunk.ActiveAirCount; i++) { ushort idx = chunk.ActiveAirIndices[i]; - float currentTemp = GetEffectiveTemperature(chunk.Temperature[idx]); + float currentTemp = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); float gasMoles = chunk.ActiveGases[g].Moles[idx]; - if (gasMoles > AtmosSolverConstants.MinimumMolesForCondensation && currentTemp > 0f) + if (gasMoles > AtmosSolverConstants.MinimumMolesForCondensation) { // Clausius-Clapeyron calculation of saturation vapor pressure: // P_sat = P_ref * exp(-L * (1/T - 1/T_boiling)) @@ -1050,13 +1073,13 @@ private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBu (1f / currentTemp - invBoilingPoint); float satVaporPressure = referencePressure * MathF.Exp(exponent); - float currentPartialPressure = CalculatePressure(gasMoles, currentTemp); + float currentPartialPressure = CalculateTickPressure(gasMoles, currentTemp); if (currentPartialPressure > satVaporPressure) { float excessPressure = currentPartialPressure - satVaporPressure; - float molesToCondense = PressureToMoles(excessPressure, currentTemp) * + float molesToCondense = TickPressureToMoles(excessPressure, currentTemp) * condensationRateFactor; if (molesToCondense > gasMoles) @@ -1109,16 +1132,11 @@ private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBu /// The requested pressure transfer in pascals per tick. private float CalculateBulkPressureTransfer(float pressureDelta, float currentPressure) { - if (!IsFinitePositive(pressureDelta) || !IsFinitePositive(currentPressure)) - return 0f; - - float maximumFraction = ClampUnitInterval(_config.MaxPressureTransferFractionPerNeighbor); + float maximumFraction = _tickConfig.MaxPressureTransferFractionPerNeighbor; if (maximumFraction <= 0f) return 0f; - float lowPressureThreshold = float.IsFinite(_config.LowPressureDeltaThreshold) - ? MathF.Max(0f, _config.LowPressureDeltaThreshold) - : 0f; + float lowPressureThreshold = _tickConfig.LowPressureDeltaThreshold; float pressureTransfer; // Use the configured per-neighbor fraction directly below the low-delta threshold. @@ -1127,13 +1145,10 @@ private float CalculateBulkPressureTransfer(float pressureDelta, float currentPr if (pressureDelta < lowPressureThreshold) pressureTransfer = pressureDelta * maximumFraction; else - pressureTransfer = pressureDelta * ClampUnitInterval(_config.BulkFlowCoefficient) * - ClampUnitInterval(_config.BulkFlowDamping); + pressureTransfer = pressureDelta * _tickConfig.BulkFlowCoefficient * + _tickConfig.BulkFlowDamping; - float minimumTransfer = float.IsFinite(_config.MinimumPressureTransfer) - ? MathF.Max(0f, _config.MinimumPressureTransfer) - : 0f; - if (!IsFinitePositive(pressureTransfer) || pressureTransfer < minimumTransfer) + if (pressureTransfer <= 0f || pressureTransfer < _tickConfig.MinimumPressureTransfer) return 0f; // Cap the requested pressure transfer to a fraction of source pressure for this neighbor. @@ -1164,8 +1179,8 @@ private void ProcessThermalBoundaryFlows( _activeThermalBoundaryEdges.Clear(); _thermalBoundaryEnergyDeltas.Clear(); - float thermalConductance = _config.ThermalConductance; - if (!IsFinitePositive(thermalConductance)) + float thermalConductance = _tickConfig.ThermalConductance; + if (thermalConductance <= 0f) { while (boundaryEvents.TryDequeue(out _)) { @@ -1185,7 +1200,7 @@ private void ProcessThermalBoundaryFlows( _thermalBoundaryOrderedEdges.AddRange(_thermalBoundaryEdges); _thermalBoundaryOrderedEdges.Sort(CompareThermalEdges); - float vacuumThreshold = GetNonNegativeFinite(_config.VacuumThreshold); + float vacuumThreshold = _tickConfig.VacuumThreshold; foreach (var edge in _thermalBoundaryOrderedEdges) { @@ -1195,9 +1210,9 @@ private void ProcessThermalBoundaryFlows( out var secondState)) continue; - double conductance = CalculateThermalConductance(firstState.HeatCapacity, + float conductance = CalculateThermalConductance(firstState.HeatCapacity, secondState.HeatCapacity, thermalConductance); - if (conductance <= 0d) + if (conductance <= 0f) continue; AddToDictionary(_thermalBoundaryIncidentConductances, edge.First, conductance); @@ -1209,12 +1224,14 @@ private void ProcessThermalBoundaryFlows( { ThermalBoundaryState firstState = _thermalBoundaryStates[edge.First]; ThermalBoundaryState secondState = _thermalBoundaryStates[edge.Second]; - double firstIncident = _thermalBoundaryIncidentConductances[edge.First]; - double secondIncident = _thermalBoundaryIncidentConductances[edge.Second]; - double scale = Math.Min(1d, Math.Min(firstState.HeatCapacity / firstIncident, - secondState.HeatCapacity / secondIncident)); - double heatTransfer = scale * conductance * (firstState.Temperature - secondState.Temperature); - if (!double.IsFinite(heatTransfer) || heatTransfer == 0d) + float firstIncident = _thermalBoundaryIncidentConductances[edge.First]; + float secondIncident = _thermalBoundaryIncidentConductances[edge.Second]; + double scale = Math.Min(1d, Math.Min( + (double)firstState.HeatCapacity / firstIncident, + (double)secondState.HeatCapacity / secondIncident)); + float heatTransfer = (float)(scale * conductance * + (firstState.Temperature - secondState.Temperature)); + if (heatTransfer == 0f) continue; AddToDictionary(_thermalBoundaryEnergyDeltas, edge.First, -heatTransfer); @@ -1224,10 +1241,9 @@ private void ProcessThermalBoundaryFlows( foreach (var (address, energyDelta) in _thermalBoundaryEnergyDeltas) { ThermalBoundaryState state = _thermalBoundaryStates[address]; - double newTemperature = (state.Temperature * state.HeatCapacity + energyDelta) / + double newTemperature = ((double)state.Temperature * state.HeatCapacity + energyDelta) / state.HeatCapacity; - if (!double.IsFinite(newTemperature) || newTemperature < 0d || - !_chunkMap.TryGetValue(address.ChunkPosition, out var chunk)) + if (newTemperature < 0d || !_chunkMap.TryGetValue(address.ChunkPosition, out var chunk)) continue; chunk.Temperature[address.LocalVoxelIndex] = (float)newTemperature; @@ -1289,12 +1305,11 @@ private bool TryGetBoundaryThermalState(ThermalVoxelAddress address, float vacuu ushort idx = address.LocalVoxelIndex; float pressure = CalculatePressureAtVoxel(chunk, idx); - float heatCapacity = CalculateHeatCapacityAtVoxel(chunk, idx); + float heatCapacity = CalculateTickHeatCapacityAtVoxel(chunk, idx); chunk.TotalPressure[idx] = pressure; chunk.TotalHeatCapacity[idx] = heatCapacity; - float temperature = GetEffectiveTemperature(chunk.Temperature[idx]); - if (!IsFinitePositive(heatCapacity) || !float.IsFinite(pressure) || pressure < vacuumThreshold || - !IsFinitePositive(temperature)) + float temperature = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); + if (!IsFinitePositive(heatCapacity) || !float.IsFinite(pressure) || pressure < vacuumThreshold) return false; state = new ThermalBoundaryState(temperature, heatCapacity); @@ -1335,14 +1350,14 @@ private static int CompareThermalEdges(ThermalBoundaryEdge left, ThermalBoundary return comparison != 0 ? comparison : CompareThermalVoxels(left.Second, right.Second); } - private static void AddToDictionary(Dictionary values, - ThermalVoxelAddress address, double value) + private static void AddToDictionary(Dictionary values, + ThermalVoxelAddress address, float value) { values[address] = values.GetValueOrDefault(address) + value; } private readonly record struct ThermalVoxelAddress(Int3 ChunkPosition, ushort LocalVoxelIndex); private readonly record struct ThermalBoundaryEdge(ThermalVoxelAddress First, ThermalVoxelAddress Second); - private readonly record struct ThermalBoundaryConductance(ThermalBoundaryEdge Edge, double Conductance); - private readonly record struct ThermalBoundaryState(double Temperature, double HeatCapacity); + private readonly record struct ThermalBoundaryConductance(ThermalBoundaryEdge Edge, float Conductance); + private readonly record struct ThermalBoundaryState(float Temperature, float HeatCapacity); } \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs b/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs new file mode 100644 index 0000000..c442ae4 --- /dev/null +++ b/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs @@ -0,0 +1,140 @@ +namespace Numos.CoreSim; + +/// +/// Normalized solver inputs captured from the live configuration at the start of a tick. +/// +/// +/// Public configuration remains mutable. This reusable snapshot remains stable for the duration of a tick, +/// keeping the tick internally consistent and moving validation out of per-voxel and per-neighbor hot paths. +/// +internal sealed class AtmosSolverConfigSnapshot +{ + private float _defaultMolarHeatCapacityAtConstantVolume; + private float _defaultDiffusionCoefficient; + private float[] _diffusionCoefficients = []; + private GasProperties[] _gasRegistry = []; + private int _gasRegistryCount; + private float[] _molarHeatCapacitiesAtConstantVolume = []; + + internal void Capture(AtmosConfig config) + { + List gasRegistry = config.GasRegistry; + int previousGasRegistryCount = _gasRegistryCount; + if (_gasRegistry.Length < gasRegistry.Count) + { + Array.Resize(ref _gasRegistry, gasRegistry.Count); + Array.Resize(ref _molarHeatCapacitiesAtConstantVolume, gasRegistry.Count); + Array.Resize(ref _diffusionCoefficients, gasRegistry.Count); + } + + _gasRegistryCount = gasRegistry.Count; + DefaultTemperatureFallback = IsFinitePositive(config.DefaultTemperatureFallback) + ? config.DefaultTemperatureFallback + : AtmosConfigDefaults.DefaultTemperatureFallback; + _defaultMolarHeatCapacityAtConstantVolume = + IsFinitePositive(config.DefaultMolarHeatCapacityAtConstantVolume) + ? config.DefaultMolarHeatCapacityAtConstantVolume + : AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; + VoxelVolume = IsFinitePositive(config.VoxelVolume) + ? config.VoxelVolume + : AtmosConfigDefaults.VoxelVolume; + PressurePerMoleKelvin = (double)AtmosPhysicalConstants.MolarGasConstant / VoxelVolume; + SaturationReferencePressure = IsFinitePositive(config.SaturationReferencePressure) + ? config.SaturationReferencePressure + : AtmosConfigDefaults.SaturationReferencePressure; + _defaultDiffusionCoefficient = ClampUnitInterval(config.DefaultDiffusionCoefficient); + for (var gasId = 0; gasId < _gasRegistryCount; gasId++) + { + GasProperties properties = gasRegistry[gasId]; + _gasRegistry[gasId] = properties; + _molarHeatCapacitiesAtConstantVolume[gasId] = + IsFinitePositive(properties.MolarHeatCapacityAtConstantVolume) + ? properties.MolarHeatCapacityAtConstantVolume + : _defaultMolarHeatCapacityAtConstantVolume; + _diffusionCoefficients[gasId] = ClampUnitInterval(properties.DiffusionCoefficient); + } + + if (_gasRegistryCount < previousGasRegistryCount) + { + int removedCount = previousGasRegistryCount - _gasRegistryCount; + Array.Clear(_gasRegistry, _gasRegistryCount, removedCount); + Array.Clear(_molarHeatCapacitiesAtConstantVolume, _gasRegistryCount, removedCount); + Array.Clear(_diffusionCoefficients, _gasRegistryCount, removedCount); + } + + BulkFlowCoefficient = ClampUnitInterval(config.BulkFlowCoefficient); + BulkFlowDamping = ClampUnitInterval(config.BulkFlowDamping); + LowPressureDeltaThreshold = GetNonnegativeFinite(config.LowPressureDeltaThreshold); + MinimumPressureTransfer = GetNonnegativeFinite(config.MinimumPressureTransfer); + VacuumThreshold = GetNonnegativeFinite(config.VacuumThreshold); + SleepThreshold = Math.Max(0, config.SleepThreshold); + SleepEpsilon = GetNonnegativeFinite(config.SleepEpsilon); + ThermalConductance = IsFinitePositive(config.ThermalConductance) + ? config.ThermalConductance + : 0f; + CondensationRateFactor = ClampUnitInterval(config.CondensationRateFactor); + MaxPressureTransferFractionPerNeighbor = + ClampUnitInterval(config.MaxPressureTransferFractionPerNeighbor); + } + + internal float DefaultTemperatureFallback { get; private set; } + internal float VoxelVolume { get; private set; } + internal double PressurePerMoleKelvin { get; private set; } + internal float SaturationReferencePressure { get; private set; } + internal float BulkFlowCoefficient { get; private set; } + internal float BulkFlowDamping { get; private set; } + internal float LowPressureDeltaThreshold { get; private set; } + internal float MinimumPressureTransfer { get; private set; } + internal float VacuumThreshold { get; private set; } + internal int SleepThreshold { get; private set; } + internal float SleepEpsilon { get; private set; } + internal float ThermalConductance { get; private set; } + internal float CondensationRateFactor { get; private set; } + internal float MaxPressureTransferFractionPerNeighbor { get; private set; } + + internal float GetEffectiveTemperature(float storedTemperature) + { + return IsFinitePositive(storedTemperature) ? storedTemperature : DefaultTemperatureFallback; + } + + internal float GetMolarHeatCapacityAtConstantVolume(int gasId) + { + return (uint)gasId < (uint)_gasRegistryCount + ? _molarHeatCapacitiesAtConstantVolume[gasId] + : _defaultMolarHeatCapacityAtConstantVolume; + } + + internal float GetDiffusionCoefficient(int gasId) + { + return (uint)gasId < (uint)_gasRegistryCount + ? _diffusionCoefficients[gasId] + : _defaultDiffusionCoefficient; + } + + internal bool TryGetGasProperties(int gasId, out GasProperties properties) + { + if ((uint)gasId < (uint)_gasRegistryCount) + { + properties = _gasRegistry[gasId]; + return true; + } + + properties = default; + return false; + } + + private static bool IsFinitePositive(float value) + { + return float.IsFinite(value) && value > 0f; + } + + private static float ClampUnitInterval(float value) + { + return float.IsFinite(value) ? Math.Clamp(value, 0f, 1f) : 0f; + } + + private static float GetNonnegativeFinite(float value) + { + return float.IsFinite(value) ? MathF.Max(0f, value) : 0f; + } +} diff --git a/tests/Numos.API.Tests/GasMixtureTests.cs b/tests/Numos.API.Tests/GasMixtureTests.cs index 2938012..eb709ab 100644 --- a/tests/Numos.API.Tests/GasMixtureTests.cs +++ b/tests/Numos.API.Tests/GasMixtureTests.cs @@ -241,6 +241,46 @@ public void VoxelMixture_ChannelTableGrowsPastInitialCapacity() }); } + [Test] + public void StoredTemperature_AllowsRawValuesAndUsesConfiguredFallback() + { + var config = CreateSimulationConfig(); + config.DefaultTemperatureFallback = 123f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + var owned = simulation.CreateGasMixture(1f, 300f); + var voxel = simulation.GetVoxelGasMixture(chunk, 0); + + owned.SetMoles(0, 1f); + voxel.SetMoles(0, 1f); + owned.Temperature = float.NaN; + voxel.Temperature = float.NaN; + + float expectedPressure = AtmosPhysicalConstants.MolarGasConstant * config.DefaultTemperatureFallback; + Assert.Multiple(() => + { + Assert.That(owned.Temperature, Is.NaN); + Assert.That(voxel.Temperature, Is.NaN); + Assert.That(owned.Pressure, Is.EqualTo(expectedPressure).Within(0.001f)); + Assert.That(voxel.Pressure, Is.EqualTo(expectedPressure).Within(0.001f)); + }); + } + + [Test] + public void VoxelMixture_ScalarMutationPreservesRoomCapacityGuard() + { + using var simulation = new AtmosSimulation(CreateSimulationConfig(), 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default, maxActiveRooms: 1); + simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 1, new VoxelClassification(2)); + var first = simulation.GetVoxelGasMixture(chunk, 0); + var second = simulation.GetVoxelGasMixture(chunk, 1); + first.SetMoles(0, 1f); + + Assert.That(() => second.SetMoles(0, 1f), Throws.InvalidOperationException); + Assert.That(second.TotalMoles, Is.Zero); + } + [Test] public void VoxelMixture_IsBoundToOriginalChunkGeneration() { diff --git a/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs b/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs index 0dfd1c0..f880c20 100644 --- a/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs +++ b/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs @@ -53,9 +53,9 @@ internal static void SetAllTemperatures(AtmosSimulation simulation, AtmosChunkHa int width, int height, int depth, float temperature = DefaultTemperature) { for (var z = 0; z < depth; z++) - for (var y = 0; y < height; y++) - for (var x = 0; x < width; x++) - simulation.SetVoxelTemperature(chunk, x, y, z, temperature); + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + simulation.SetVoxelTemperature(chunk, x, y, z, temperature); } internal static int Index(int x, int y, int z, int width, int height) @@ -87,34 +87,43 @@ internal static float TotalMoles(params AtmosChunkSnapshot[] snapshots) internal static float TotalThermalEnergy(AtmosConfig config, params AtmosChunkSnapshot[] snapshots) { - var totalEnergy = 0f; - float fallbackMolarHeatCapacityAtConstantVolume = float.IsFinite(config.DefaultMolarHeatCapacityAtConstantVolume) && - config.DefaultMolarHeatCapacityAtConstantVolume > 0f - ? config.DefaultMolarHeatCapacityAtConstantVolume - : AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume; + return (float)TotalThermalEnergyPrecise(config, snapshots); + } + + internal static double TotalThermalEnergyPrecise(AtmosConfig config, + params AtmosChunkSnapshot[] snapshots) + { + double totalEnergy = 0d; + float fallbackMolarHeatCapacityAtConstantVolume = + float.IsFinite(config.DefaultMolarHeatCapacityAtConstantVolume) && + config.DefaultMolarHeatCapacityAtConstantVolume > 0f + ? config.DefaultMolarHeatCapacityAtConstantVolume + : AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume; + foreach (var snapshot in snapshots) { for (var index = 0; index < snapshot.Temperature.Length; index++) { - var heatCapacity = 0f; + double heatCapacity = 0d; foreach (var gas in snapshot.Gases) { float configuredMolarHeatCapacityAtConstantVolume = gas.GasId >= 0 && - gas.GasId < config.GasRegistry.Count + gas.GasId < config.GasRegistry.Count ? config.GasRegistry[gas.GasId].MolarHeatCapacityAtConstantVolume : fallbackMolarHeatCapacityAtConstantVolume; - float molarHeatCapacityAtConstantVolume = float.IsFinite(configuredMolarHeatCapacityAtConstantVolume) && - configuredMolarHeatCapacityAtConstantVolume > 0f - ? configuredMolarHeatCapacityAtConstantVolume - : fallbackMolarHeatCapacityAtConstantVolume; - heatCapacity += gas.Moles[index] * molarHeatCapacityAtConstantVolume; + float molarHeatCapacityAtConstantVolume = + float.IsFinite(configuredMolarHeatCapacityAtConstantVolume) && + configuredMolarHeatCapacityAtConstantVolume > 0f + ? configuredMolarHeatCapacityAtConstantVolume + : fallbackMolarHeatCapacityAtConstantVolume; + heatCapacity += (double)gas.Moles[index] * molarHeatCapacityAtConstantVolume; } float storedTemperature = snapshot.Temperature[index]; float effectiveTemperature = float.IsFinite(storedTemperature) && storedTemperature > 0f ? storedTemperature : config.DefaultTemperatureFallback; - totalEnergy += effectiveTemperature * heatCapacity; + totalEnergy += heatCapacity * effectiveTemperature; } } diff --git a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs index 59ed32e..1a6144a 100644 --- a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs @@ -331,6 +331,42 @@ public void IntraChunkThermalDiffusion_AggregateOutflowCannotExceedSourceEnergy( }); } + [Test] + public void IntraChunkThermalDiffusion_FloatWorkspaceBoundsLongRunningEnergyDrift() + { + const int size = 4; + var config = SimTestHelpers.CreateDeterministicConfig(); + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; + config.ThermalConductance = 0.7f; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.MolarHeatCapacityAtConstantVolume = 2.75f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + + using var simulation = new AtmosSimulation(config, size, size, size); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + for (var z = 0; z < size; z++) + for (var y = 0; y < size; y++) + for (var x = 0; x < size; x++) + { + int sequence = x + y * size + z * size * size; + float moles = 0.5f + sequence % 7 * 0.37f; + float temperature = 150f + sequence % 11 * 23.7f; + simulation.AddGasToVoxel(chunk, x, y, z, SimTestHelpers.FirstGasId, moles, temperature); + } + + double initialEnergy = SimTestHelpers.TotalThermalEnergyPrecise(config, + simulation.GetChunkSnapshot(chunk)); + for (var tick = 0; tick < 200; tick++) + simulation.Tick(); + + double finalEnergy = SimTestHelpers.TotalThermalEnergyPrecise(config, + simulation.GetChunkSnapshot(chunk)); + double relativeDrift = Math.Abs(finalEnergy - initialEnergy) / initialEnergy; + + Assert.That(relativeDrift, Is.LessThan(2e-6d)); + } + [Test] public void DepthOneChunks_DoNotTreatZAsAThermalFlowPlaneWhenAnotherEdgeEmitsAnEvent() { @@ -596,4 +632,4 @@ private static AtmosChunkHandle CreateIsolatedVoxel(AtmosSimulation simulation, simulation.SetVoxelTemperature(chunk, x, y, z, temperature); return chunk; } -} +} \ No newline at end of file From d4915f67ae5ef46af3c4bdf9a206bbb8f555973d Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:18:42 -0500 Subject: [PATCH 04/14] Refactored gas mixture arithmetic to use single precision consistently, simplifying calculations and avoiding overflow risks. Updated algorithms, tests, and documentation to reflect the new approach while preserving precision and range checks. --- docs/atmospherics_technical_documentation.md | 10 ++- src/Numos.API/AtmosSimulation.GasMixtures.cs | 81 ++++++++++--------- src/Numos.API/GasMixtureState.cs | 6 +- src/Numos.CoreSim/AtmosChunk.cs | 12 +-- src/Numos.CoreSim/AtmosKernel.GasMixtures.cs | 71 ++++++++-------- src/Numos.CoreSim/AtmosKernel.cs | 69 ++++++++-------- .../AtmosSolverConfigSnapshot.cs | 4 +- src/Numos.CoreSim/RoomNode.cs | 10 +-- .../ThermodynamicsIntegrationTests.cs | 27 ++++++- 9 files changed, 161 insertions(+), 129 deletions(-) diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 1c7ce1b..37a1cec 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -291,10 +291,12 @@ At the start of each simulation tick, the solver captures one normalized configu This keeps the tick internally consistent while retaining the public live-configuration model, and avoids repeating configuration validation in the per-neighbor and per-species loops. -Persistent voxel state and per-voxel thermal work buffers use single-precision storage. Numerically sensitive -products, ratios, reductions, and overflow checks are promoted to double precision only for the duration of the -calculation and are narrowed before storage. This preserves the range and rounding benefits where they affect the -math without doubling the memory bandwidth and working-set cost of the solver's structure-of-arrays layout. +Persistent voxel state, per-voxel thermal work buffers, and production atmos calculations use single precision +end-to-end. Overflow-prone formulas use algebraically equivalent float forms: thermal equilibrium conductance is +evaluated without forming `C1 * C2`, temperature updates use `T + ΔE/C`, and heat-capacity-weighted mixing uses +bounded interpolation. Finite-range checks reject results that cannot be represented by the float-backed state. +Double precision is reserved for test/reference reductions, avoiding production float-to-double conversions while +preserving an independent, higher-precision conservation check. ```csharp var canister = simulation.CreateGasMixture(volume: 0.07f, temperature: 293.15f); diff --git a/src/Numos.API/AtmosSimulation.GasMixtures.cs b/src/Numos.API/AtmosSimulation.GasMixtures.cs index 7ec09e1..f53dc8b 100644 --- a/src/Numos.API/AtmosSimulation.GasMixtures.cs +++ b/src/Numos.API/AtmosSimulation.GasMixtures.cs @@ -245,10 +245,10 @@ internal void AdjustMixtureMoles(IInternalGasMixture mixture, int gasId, float d ThrowIfDisposed(); if (mixture is GasMixture owned) { - double adjusted = owned.State.Moles.GetValueOrDefault(gasId) + (double)deltaMoles; - if (!double.IsFinite(adjusted) || adjusted > float.MaxValue) + float adjusted = owned.State.Moles.GetValueOrDefault(gasId) + deltaMoles; + if (!float.IsFinite(adjusted)) throw new InvalidOperationException("The adjusted gas amount exceeds the supported range."); - SetOwnedMixtureMoles(owned.State, gasId, (float)Math.Max(0d, adjusted)); + SetOwnedMixtureMoles(owned.State, gasId, MathF.Max(0f, adjusted)); return; } @@ -558,20 +558,24 @@ private void AddGasToOwnedMixture(GasMixtureState state, int gasId, float moles, { bool hadGas = state.Moles.TryGetValue(gasId, out float previousMoles); float previousTemperature = state.Temperature; - double combinedMoles = previousMoles + (double)moles; - if (!double.IsFinite(combinedMoles) || combinedMoles > float.MaxValue) + float combinedMoles = previousMoles + moles; + if (!float.IsFinite(combinedMoles)) throw new InvalidOperationException("A merged gas amount exceeds the supported range."); - double currentHeatCapacity = CalculateMixtureHeatCapacity(state); - double incomingHeatCapacity = moles * (double)GetMolarHeatCapacityAtConstantVolume(gasId); - double combinedHeatCapacity = currentHeatCapacity + incomingHeatCapacity; - float mixedTemperature = combinedHeatCapacity > 0d - ? (float)((currentHeatCapacity * GetEffectiveMixtureTemperature(state.Temperature) + - incomingHeatCapacity * GetEffectiveMixtureTemperature(temperature)) / - combinedHeatCapacity) + float currentHeatCapacity = CalculateMixtureHeatCapacity(state); + float incomingHeatCapacity = moles * GetMolarHeatCapacityAtConstantVolume(gasId); + float combinedHeatCapacity = currentHeatCapacity + incomingHeatCapacity; + if (!float.IsFinite(combinedHeatCapacity)) + throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); + + float currentTemperature = GetEffectiveMixtureTemperature(state.Temperature); + float incomingTemperature = GetEffectiveMixtureTemperature(temperature); + float mixedTemperature = combinedHeatCapacity > 0f + ? currentTemperature + + (incomingTemperature - currentTemperature) * incomingHeatCapacity / combinedHeatCapacity : temperature; - state.Moles[gasId] = (float)combinedMoles; + state.Moles[gasId] = combinedMoles; state.Temperature = mixedTemperature; try { @@ -593,21 +597,25 @@ private void MergeStates(GasMixtureState destination, GasMixtureState incoming) if (incoming.ActiveGasCount == 0) return; - double destinationHeatCapacity = CalculateMixtureHeatCapacity(destination); - double incomingHeatCapacity = CalculateMixtureHeatCapacity(incoming); - double combinedHeatCapacity = destinationHeatCapacity + incomingHeatCapacity; - float mixedTemperature = combinedHeatCapacity > 0d - ? (float)((destinationHeatCapacity * GetEffectiveMixtureTemperature(destination.Temperature) + - incomingHeatCapacity * GetEffectiveMixtureTemperature(incoming.Temperature)) / - combinedHeatCapacity) + float destinationHeatCapacity = CalculateMixtureHeatCapacity(destination); + float incomingHeatCapacity = CalculateMixtureHeatCapacity(incoming); + float combinedHeatCapacity = destinationHeatCapacity + incomingHeatCapacity; + if (!float.IsFinite(combinedHeatCapacity)) + throw new InvalidOperationException("The merged mixture heat capacity exceeds the supported range."); + + float destinationTemperature = GetEffectiveMixtureTemperature(destination.Temperature); + float incomingTemperature = GetEffectiveMixtureTemperature(incoming.Temperature); + float mixedTemperature = combinedHeatCapacity > 0f + ? destinationTemperature + + (incomingTemperature - destinationTemperature) * incomingHeatCapacity / combinedHeatCapacity : incoming.Temperature; foreach (var (gasId, incomingMoles) in incoming.Moles) { - double combinedMoles = destination.Moles.GetValueOrDefault(gasId) + (double)incomingMoles; - if (!double.IsFinite(combinedMoles) || combinedMoles > float.MaxValue) + float combinedMoles = destination.Moles.GetValueOrDefault(gasId) + incomingMoles; + if (!float.IsFinite(combinedMoles)) throw new InvalidOperationException("A merged gas amount exceeds the supported range."); - destination.Moles[gasId] = (float)combinedMoles; + destination.Moles[gasId] = combinedMoles; } if (!float.IsFinite(mixedTemperature) || mixedTemperature < 0f) @@ -615,12 +623,12 @@ private void MergeStates(GasMixtureState destination, GasMixtureState incoming) destination.Temperature = mixedTemperature; } - private double CalculateMixtureHeatCapacity(GasMixtureState state) + private float CalculateMixtureHeatCapacity(GasMixtureState state) { - double total = 0d; + var total = 0f; foreach (var (gasId, moles) in state.Moles) - total += (double)moles * GetMolarHeatCapacityAtConstantVolume(gasId); - if (!double.IsFinite(total)) + total += moles * GetMolarHeatCapacityAtConstantVolume(gasId); + if (!float.IsFinite(total)) throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); return total; } @@ -657,9 +665,8 @@ private float CalculateMixturePressure(GasMixtureState state) float totalMoles = state.TotalMoles; if (totalMoles <= 0f) return 0f; - double pressure = (double)totalMoles * AtmosPhysicalConstants.MolarGasConstant * - GetEffectiveMixtureTemperature(state.Temperature) / state.Volume; - return (float)pressure; + return totalMoles / state.Volume * AtmosPhysicalConstants.MolarGasConstant * + GetEffectiveMixtureTemperature(state.Temperature); } private static GasMixtureState RemoveRatioFromState(GasMixtureState source, float ratio) @@ -689,7 +696,7 @@ private static void SetStateMoles(GasMixtureState state, int gasId, float moles) private void ValidateState(GasMixtureState state) { ValidateVolume(state.Volume); - double total = 0d; + var total = 0f; foreach (var (gasId, moles) in state.Moles) { ValidateGasId(gasId); @@ -697,16 +704,14 @@ private void ValidateState(GasMixtureState state) total += moles; } - if (!double.IsFinite(total) || total > float.MaxValue) + if (!float.IsFinite(total)) throw new InvalidOperationException("The mixture's total moles exceed the supported range."); - double heatCapacity = CalculateMixtureHeatCapacity(state); - if (heatCapacity > float.MaxValue) - throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); + CalculateMixtureHeatCapacity(state); - double pressure = total * AtmosPhysicalConstants.MolarGasConstant * - GetEffectiveMixtureTemperature(state.Temperature) / state.Volume; - if (!double.IsFinite(pressure) || pressure > float.MaxValue) + float pressure = total / state.Volume * AtmosPhysicalConstants.MolarGasConstant * + GetEffectiveMixtureTemperature(state.Temperature); + if (!float.IsFinite(pressure)) throw new InvalidOperationException("The mixture's pressure exceeds the supported range."); } diff --git a/src/Numos.API/GasMixtureState.cs b/src/Numos.API/GasMixtureState.cs index a8ec9e1..9585b3b 100644 --- a/src/Numos.API/GasMixtureState.cs +++ b/src/Numos.API/GasMixtureState.cs @@ -18,10 +18,10 @@ internal float TotalMoles { get { - double total = 0d; + var total = 0f; foreach (float moles in Moles.Values) total += moles; - return (float)total; + return total; } } @@ -41,4 +41,4 @@ internal KeyValuePair[] ToGasArray() gases[index++] = gas; return gases; } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AtmosChunk.cs b/src/Numos.CoreSim/AtmosChunk.cs index db47514..b273f7e 100644 --- a/src/Numos.CoreSim/AtmosChunk.cs +++ b/src/Numos.CoreSim/AtmosChunk.cs @@ -373,11 +373,11 @@ public virtual void Sleep() /// The already-resolved ideal-gas coefficient R/V, in Pa/(mol·K). /// public void InjectGasToVoxel(ushort localVoxelIndex, int gasId, float molesToAdd, float temperature, - float effectiveMolarHeatCapacityAtConstantVolume, double pressurePerMoleKelvin) + float effectiveMolarHeatCapacityAtConstantVolume, float pressurePerMoleKelvin) { Debug.Assert(float.IsFinite(effectiveMolarHeatCapacityAtConstantVolume) && effectiveMolarHeatCapacityAtConstantVolume > 0f); - Debug.Assert(double.IsFinite(pressurePerMoleKelvin) && pressurePerMoleKelvin > 0d); + Debug.Assert(float.IsFinite(pressurePerMoleKelvin) && pressurePerMoleKelvin > 0f); if (!IsAwake) return; @@ -408,14 +408,14 @@ public void InjectGasToVoxel(ushort localVoxelIndex, int gasId, float molesToAdd float newTemp = currentHeatCapacity > 0f && newHeatCapacity > 0f ? currentTemp == temperature ? currentTemp - : (float)(((double)currentHeatCapacity * currentTemp + - (double)incomingHeatCapacity * temperature) / newHeatCapacity) + // Interpolation avoids the overflow-prone sum C1*T1 + C2*T2. + : currentTemp + (temperature - currentTemp) * incomingHeatCapacity / newHeatCapacity : temperature; TotalHeatCapacity[localVoxelIndex] = newHeatCapacity; Temperature[localVoxelIndex] = newTemp; - TotalPressure[localVoxelIndex] = (float)(currentTotalMoles * newTemp * pressurePerMoleKelvin); + TotalPressure[localVoxelIndex] = currentTotalMoles * newTemp * pressurePerMoleKelvin; MarkChanged(); } @@ -568,4 +568,4 @@ private static int GetValidatedVoxelCount(int width, int height, int depth) return (int)voxelCount; } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs index 16c40b8..5e0a7d6 100644 --- a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs +++ b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs @@ -222,11 +222,11 @@ internal void AdjustVoxelMixtureMoles( { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); float currentMoles = GetVoxelGasMoles(chunk, localVoxelIndex, gasId); - double adjusted = currentMoles + (double)deltaMoles; - if (!double.IsFinite(adjusted) || adjusted > float.MaxValue) + float adjusted = currentMoles + deltaMoles; + if (!float.IsFinite(adjusted)) throw new InvalidOperationException("The adjusted gas amount exceeds the supported range."); - float moles = (float)Math.Max(0d, adjusted); + float moles = MathF.Max(0f, adjusted); int roomId = GetGasRoomId(chunk, localVoxelIndex); VoxelGasMixtureTotals totals = CalculateVoxelMixtureTotals( chunk, @@ -256,8 +256,8 @@ internal void AddVoxelMixtureGas( { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); float currentGasMoles = GetVoxelGasMoles(chunk, localVoxelIndex, gasId); - double combinedGasMoles = currentGasMoles + (double)moles; - if (!double.IsFinite(combinedGasMoles) || combinedGasMoles > float.MaxValue) + float combinedGasMoles = currentGasMoles + moles; + if (!float.IsFinite(combinedGasMoles)) throw new InvalidOperationException("A merged gas amount exceeds the supported range."); VoxelGasMixtureTotals currentTotals = CalculateVoxelMixtureTotals( @@ -266,11 +266,15 @@ internal void AddVoxelMixtureGas( chunk.Temperature[localVoxelIndex]); float currentHeatCapacity = currentTotals.HeatCapacity; float incomingHeatCapacity = moles * GetMolarHeatCapacityAtConstantVolume(gasId); - double combinedHeatCapacity = currentHeatCapacity + (double)incomingHeatCapacity; - float mixedTemperature = combinedHeatCapacity > 0d - ? (float)((currentHeatCapacity * GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]) + - incomingHeatCapacity * GetEffectiveTemperature(temperature)) / - combinedHeatCapacity) + float combinedHeatCapacity = currentHeatCapacity + incomingHeatCapacity; + if (!float.IsFinite(combinedHeatCapacity)) + throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); + + float currentTemperature = GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]); + float incomingTemperature = GetEffectiveTemperature(temperature); + float mixedTemperature = combinedHeatCapacity > 0f + ? currentTemperature + + (incomingTemperature - currentTemperature) * incomingHeatCapacity / combinedHeatCapacity : temperature; int roomId = GetGasRoomId(chunk, localVoxelIndex); @@ -279,10 +283,10 @@ internal void AddVoxelMixtureGas( localVoxelIndex, mixedTemperature, gasId, - (float)combinedGasMoles); + combinedGasMoles); chunk.WakeRoom(roomId); - SetVoxelGasMoles(chunk, localVoxelIndex, gasId, (float)combinedGasMoles); + SetVoxelGasMoles(chunk, localVoxelIndex, gasId, combinedGasMoles); chunk.Temperature[localVoxelIndex] = mixedTemperature; ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); } @@ -358,16 +362,16 @@ internal void ReplaceVoxelMixture( int roomId = chunk.VoxelRoomMap[localVoxelIndex]; Debug.Assert(roomId != VoxelClassification.RoomSolid && roomId != VoxelClassification.RoomVoid); - double totalMoles = 0d; - double totalHeatCapacity = 0d; + var totalMoles = 0f; + var totalHeatCapacity = 0f; foreach (var (gasId, moles) in gases) { totalMoles += moles; - totalHeatCapacity += (double)moles * GetMolarHeatCapacityAtConstantVolume(gasId); + totalHeatCapacity += moles * GetMolarHeatCapacityAtConstantVolume(gasId); } - Debug.Assert(double.IsFinite(totalMoles) && totalMoles <= float.MaxValue); - Debug.Assert(double.IsFinite(totalHeatCapacity) && totalHeatCapacity <= float.MaxValue); + Debug.Assert(float.IsFinite(totalMoles)); + Debug.Assert(float.IsFinite(totalHeatCapacity)); chunk.WakeRoom(roomId); for (var gas = 0; gas < chunk.ActiveGasCount; gas++) @@ -380,18 +384,18 @@ internal void ReplaceVoxelMixture( } chunk.Temperature[localVoxelIndex] = temperature; - chunk.TotalHeatCapacity[localVoxelIndex] = (float)totalHeatCapacity; - chunk.TotalPressure[localVoxelIndex] = CalculatePressure((float)totalMoles, temperature); + chunk.TotalHeatCapacity[localVoxelIndex] = totalHeatCapacity; + chunk.TotalPressure[localVoxelIndex] = CalculatePressure(totalMoles, temperature); chunk.MarkChanged(); } } private float GetVoxelTotalMoles(AtmosChunk chunk, ushort localVoxelIndex) { - double totalMoles = 0d; + var totalMoles = 0f; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) totalMoles += chunk.ActiveGases[gas].Moles[localVoxelIndex]; - return (float)totalMoles; + return totalMoles; } private static float GetVoxelGasMoles(AtmosChunk chunk, ushort localVoxelIndex, int gasId) @@ -453,8 +457,8 @@ private VoxelGasMixtureTotals CalculateVoxelMixtureTotals( int overrideGasId = -1, float overrideMoles = 0f) { - double totalMoles = 0d; - double totalHeatCapacity = 0d; + var totalMoles = 0f; + var totalHeatCapacity = 0f; var foundOverride = false; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { @@ -467,29 +471,28 @@ private VoxelGasMixtureTotals CalculateVoxelMixtureTotals( continue; totalMoles += moles; - totalHeatCapacity += (double)moles * GetMolarHeatCapacityAtConstantVolume(gasId); + totalHeatCapacity += moles * GetMolarHeatCapacityAtConstantVolume(gasId); } if (!foundOverride && overrideGasId >= 0 && overrideMoles > 0f) { totalMoles += overrideMoles; - totalHeatCapacity += - (double)overrideMoles * GetMolarHeatCapacityAtConstantVolume(overrideGasId); + totalHeatCapacity += overrideMoles * GetMolarHeatCapacityAtConstantVolume(overrideGasId); } - if (!double.IsFinite(totalMoles) || totalMoles > float.MaxValue) + if (!float.IsFinite(totalMoles)) throw new InvalidOperationException("The mixture's total moles exceed the supported range."); - if (!double.IsFinite(totalHeatCapacity) || totalHeatCapacity > float.MaxValue) + if (!float.IsFinite(totalHeatCapacity)) throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); - double pressure = totalMoles * AtmosPhysicalConstants.MolarGasConstant * - GetEffectiveTemperature(temperature) / GetVoxelVolume(); - if (!double.IsFinite(pressure) || pressure > float.MaxValue) + float pressure = totalMoles / GetVoxelVolume() * AtmosPhysicalConstants.MolarGasConstant * + GetEffectiveTemperature(temperature); + if (!float.IsFinite(pressure)) throw new InvalidOperationException("The mixture's pressure exceeds the supported range."); return new VoxelGasMixtureTotals( - (float)totalHeatCapacity, - (float)pressure); + totalHeatCapacity, + pressure); } private static void ApplyVoxelMixtureTotals( @@ -518,4 +521,4 @@ private AtmosChunk GetMixtureChunk(Int3 position, long generation, ushort localV private readonly record struct VoxelGasMixtureTotals( float HeatCapacity, float Pressure); -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AtmosKernel.cs b/src/Numos.CoreSim/AtmosKernel.cs index f9adf31..71b8722 100644 --- a/src/Numos.CoreSim/AtmosKernel.cs +++ b/src/Numos.CoreSim/AtmosKernel.cs @@ -24,8 +24,7 @@ internal sealed partial class AtmosKernel : IDisposable private readonly ThreadLocal _precipBufferPool; private readonly object _stateGate = new(); private readonly List _activeThermalBoundaryEdges = []; - // Boundary payloads match the float-backed voxel state; only arithmetic that benefits from extra range or - // precision is promoted to double while it is being evaluated. + // Boundary payloads match the float-backed voxel state so the thermal path does not switch precision. private readonly Dictionary _thermalBoundaryEnergyDeltas = []; private readonly HashSet _thermalBoundaryEdges = []; private readonly ThreadLocal _thermalBoundaryBufferPool; @@ -653,23 +652,21 @@ private float GetVoxelVolume() return IsFinitePositive(volume) ? volume : AtmosConfigDefaults.VoxelVolume; } - private double GetPressurePerMoleKelvin() + private float GetPressurePerMoleKelvin() { - return (double)AtmosPhysicalConstants.MolarGasConstant / GetVoxelVolume(); + return AtmosPhysicalConstants.MolarGasConstant / GetVoxelVolume(); } private float CalculatePressure(float moles, float temperature) { - double pressure = Math.Max(0d, moles) * GetEffectiveTemperature(temperature) * - GetPressurePerMoleKelvin(); - return (float)pressure; + return MathF.Max(0f, moles) * GetEffectiveTemperature(temperature) * + GetPressurePerMoleKelvin(); } private float CalculateTickPressure(float moles, float temperature) { - double pressure = Math.Max(0d, moles) * _tickConfig.GetEffectiveTemperature(temperature) * - _tickConfig.PressurePerMoleKelvin; - return (float)pressure; + return MathF.Max(0f, moles) * _tickConfig.GetEffectiveTemperature(temperature) * + _tickConfig.PressurePerMoleKelvin; } private float TickPressureToMoles(float pressure, float temperature) @@ -677,9 +674,9 @@ private float TickPressureToMoles(float pressure, float temperature) if (!IsFinitePositive(pressure)) return 0f; - double denominator = _tickConfig.PressurePerMoleKelvin * - _tickConfig.GetEffectiveTemperature(temperature); - return (float)(pressure / denominator); + float denominator = _tickConfig.PressurePerMoleKelvin * + _tickConfig.GetEffectiveTemperature(temperature); + return pressure / denominator; } private float CalculateHeatCapacityAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) @@ -863,8 +860,7 @@ private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] th if (thermalConductance <= 0f) return; - // Keep per-voxel workspace at the same precision as the SoA state. Products, ratios, and the final - // energy-to-temperature conversion are promoted below, without doubling the solver's working set. + // Keep per-voxel workspace and arithmetic at the same precision as the SoA state. float[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); float[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); Array.Clear(incidentConductances, 0, chunk.VoxelCount); @@ -931,9 +927,9 @@ private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] th out float heatCapacity)) continue; - double newEnergy = (double)oldTemperature * heatCapacity + energyDeltas[idx]; - double newTemperature = Math.Max(0d, newEnergy / heatCapacity); - chunk.Temperature[idx] = (float)newTemperature; + // T + ΔE/C is equivalent to (C*T + ΔE)/C without an overflow-prone C*T product. + float newTemperature = MathF.Max(0f, oldTemperature + energyDeltas[idx] / heatCapacity); + chunk.Temperature[idx] = newTemperature; chunk.TotalPressure[idx] = CalculatePressureAtVoxel(chunk, idx); } @@ -987,10 +983,10 @@ private void ApplyThermalFlux(AtmosChunk chunk, Int3 neighborPosition, ushort id if (conductance <= 0f || currentIncidentConductance <= 0f || neighborIncidentConductance <= 0f) return; - double scale = Math.Min(1d, Math.Min( - (double)currentHeatCapacity / currentIncidentConductance, - (double)neighborHeatCapacity / neighborIncidentConductance)); - float heatTransfer = (float)(scale * conductance * (currentTemperature - neighborTemperature)); + float scale = MathF.Min(1f, MathF.Min( + currentHeatCapacity / currentIncidentConductance, + neighborHeatCapacity / neighborIncidentConductance)); + float heatTransfer = scale * conductance * (currentTemperature - neighborTemperature); if (heatTransfer == 0f) return; @@ -1024,9 +1020,12 @@ private static float CalculateThermalConductance(float sourceHeatCapacity, float Debug.Assert(float.IsFinite(targetHeatCapacity) && targetHeatCapacity > 0f); Debug.Assert(float.IsFinite(thermalConductance) && thermalConductance > 0f); - double equilibriumConductance = (double)sourceHeatCapacity * targetHeatCapacity / - ((double)sourceHeatCapacity + targetHeatCapacity); - return (float)Math.Min(thermalConductance, equilibriumConductance); + // Algebraically equivalent to C1*C2/(C1+C2), but neither intermediate can exceed the smaller capacity. + float smallerHeatCapacity = MathF.Min(sourceHeatCapacity, targetHeatCapacity); + float largerHeatCapacity = MathF.Max(sourceHeatCapacity, targetHeatCapacity); + float equilibriumConductance = smallerHeatCapacity / + (1f + smallerHeatCapacity / largerHeatCapacity); + return MathF.Min(thermalConductance, equilibriumConductance); } private static bool IsFinitePositive(float value) @@ -1226,11 +1225,11 @@ private void ProcessThermalBoundaryFlows( ThermalBoundaryState secondState = _thermalBoundaryStates[edge.Second]; float firstIncident = _thermalBoundaryIncidentConductances[edge.First]; float secondIncident = _thermalBoundaryIncidentConductances[edge.Second]; - double scale = Math.Min(1d, Math.Min( - (double)firstState.HeatCapacity / firstIncident, - (double)secondState.HeatCapacity / secondIncident)); - float heatTransfer = (float)(scale * conductance * - (firstState.Temperature - secondState.Temperature)); + float scale = MathF.Min(1f, MathF.Min( + firstState.HeatCapacity / firstIncident, + secondState.HeatCapacity / secondIncident)); + float heatTransfer = scale * conductance * + (firstState.Temperature - secondState.Temperature); if (heatTransfer == 0f) continue; @@ -1241,12 +1240,12 @@ private void ProcessThermalBoundaryFlows( foreach (var (address, energyDelta) in _thermalBoundaryEnergyDeltas) { ThermalBoundaryState state = _thermalBoundaryStates[address]; - double newTemperature = ((double)state.Temperature * state.HeatCapacity + energyDelta) / - state.HeatCapacity; - if (newTemperature < 0d || !_chunkMap.TryGetValue(address.ChunkPosition, out var chunk)) + // Avoid forming the potentially much larger intermediate C*T. + float newTemperature = state.Temperature + energyDelta / state.HeatCapacity; + if (newTemperature < 0f || !_chunkMap.TryGetValue(address.ChunkPosition, out var chunk)) continue; - chunk.Temperature[address.LocalVoxelIndex] = (float)newTemperature; + chunk.Temperature[address.LocalVoxelIndex] = newTemperature; chunk.TotalPressure[address.LocalVoxelIndex] = CalculatePressureAtVoxel(chunk, address.LocalVoxelIndex); chunk.MarkChanged(); @@ -1360,4 +1359,4 @@ private static void AddToDictionary(Dictionary value private readonly record struct ThermalBoundaryEdge(ThermalVoxelAddress First, ThermalVoxelAddress Second); private readonly record struct ThermalBoundaryConductance(ThermalBoundaryEdge Edge, float Conductance); private readonly record struct ThermalBoundaryState(float Temperature, float HeatCapacity); -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs b/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs index c442ae4..d237ac2 100644 --- a/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs +++ b/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs @@ -38,7 +38,7 @@ internal void Capture(AtmosConfig config) VoxelVolume = IsFinitePositive(config.VoxelVolume) ? config.VoxelVolume : AtmosConfigDefaults.VoxelVolume; - PressurePerMoleKelvin = (double)AtmosPhysicalConstants.MolarGasConstant / VoxelVolume; + PressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / VoxelVolume; SaturationReferencePressure = IsFinitePositive(config.SaturationReferencePressure) ? config.SaturationReferencePressure : AtmosConfigDefaults.SaturationReferencePressure; @@ -79,7 +79,7 @@ internal void Capture(AtmosConfig config) internal float DefaultTemperatureFallback { get; private set; } internal float VoxelVolume { get; private set; } - internal double PressurePerMoleKelvin { get; private set; } + internal float PressurePerMoleKelvin { get; private set; } internal float SaturationReferencePressure { get; private set; } internal float BulkFlowCoefficient { get; private set; } internal float BulkFlowDamping { get; private set; } diff --git a/src/Numos.CoreSim/RoomNode.cs b/src/Numos.CoreSim/RoomNode.cs index 5c97e54..4f14173 100644 --- a/src/Numos.CoreSim/RoomNode.cs +++ b/src/Numos.CoreSim/RoomNode.cs @@ -43,8 +43,8 @@ public void AddGas(int gasId, float addedMoles, float incomingTemp, { AverageTemperature = TotalHeatCapacity > 0f && AverageTemperature == incomingTemp ? AverageTemperature - : (float)(((double)TotalHeatCapacity * AverageTemperature + - (double)incomingHeatCapacity * incomingTemp) / newHeatCapacity); + : AverageTemperature + + (incomingTemp - AverageTemperature) * incomingHeatCapacity / newHeatCapacity; GasMoles[gasId] += addedMoles; TotalHeatCapacity = newHeatCapacity; @@ -80,7 +80,7 @@ private readonly float CalculatePressure(float totalMoles) float voxelVolume = float.IsFinite(VoxelVolume) && VoxelVolume > 0f ? VoxelVolume : AtmosConfigDefaults.VoxelVolume; - return (float)((double)totalMoles * AtmosPhysicalConstants.MolarGasConstant * AverageTemperature / - (VoxelCount * voxelVolume)); + return totalMoles / VoxelCount / voxelVolume * + AtmosPhysicalConstants.MolarGasConstant * AverageTemperature; } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs index 1a6144a..6a6c4fc 100644 --- a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs @@ -332,7 +332,7 @@ public void IntraChunkThermalDiffusion_AggregateOutflowCannotExceedSourceEnergy( } [Test] - public void IntraChunkThermalDiffusion_FloatWorkspaceBoundsLongRunningEnergyDrift() + public void IntraChunkThermalDiffusion_FloatArithmeticBoundsLongRunningEnergyDrift() { const int size = 4; var config = SimTestHelpers.CreateDeterministicConfig(); @@ -367,6 +367,29 @@ public void IntraChunkThermalDiffusion_FloatWorkspaceBoundsLongRunningEnergyDrif Assert.That(relativeDrift, Is.LessThan(2e-6d)); } + [Test] + public void IntraChunkThermalDiffusion_LargeHeatCapacitiesAvoidIntermediateOverflow() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; + config.ThermalConductance = float.MaxValue; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.MolarHeatCapacityAtConstantVolume = 1e30f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 400f); + simulation.AddGasToVoxel(chunk, 1, 0, 0, SimTestHelpers.FirstGasId, 1f, 200f); + + simulation.Tick(); + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.That(snapshot.Temperature, Is.All.EqualTo(300f).Within(SimTestHelpers.Tolerance)); + } + [Test] public void DepthOneChunks_DoNotTreatZAsAThermalFlowPlaneWhenAnotherEdgeEmitsAnEvent() { @@ -632,4 +655,4 @@ private static AtmosChunkHandle CreateIsolatedVoxel(AtmosSimulation simulation, simulation.SetVoxelTemperature(chunk, x, y, z, temperature); return chunk; } -} \ No newline at end of file +} From 1be0bd6e268407375fa59f08b2219dc0300f3e1a Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:46:50 -0500 Subject: [PATCH 05/14] Introduce extensible Atmosphere Solver Pipeline with support for custom and built-in solvers. --- README.md | 2 + docs/atmospherics_technical_documentation.md | 71 ++++++-- src/Numos.API.Dangerous/AtmosDangerousApi.cs | 13 +- .../AtmosDangerousSolver.cs | 166 ++++++++++++++++++ .../AtmosDangerousSolverPipeline.cs | 45 +++++ .../AtmosSimulationDangerousExtensions.cs | 3 +- src/Numos.API/AtmosSimulation.cs | 7 + src/Numos.API/AtmosSolver.cs | 50 ++++++ src/Numos.API/AtmosSolverContext.cs | 79 +++++++++ src/Numos.API/AtmosSolverPipeline.cs | 86 +++++++++ src/Numos.CoreSim/AtmosKernel.API.cs | 73 +++++++- src/Numos.CoreSim/AtmosKernel.Dangerous.cs | 23 ++- src/Numos.CoreSim/AtmosKernel.cs | 145 +++++++-------- src/Numos.CoreSim/Collections/FlatArray.cs | 8 + .../Solvers/AtmosSolverExecutionContext.cs | 17 ++ .../Solvers/AtmosSolverPipeline.cs | 104 +++++++++++ .../Solvers/AtmosSolverStageNames.cs | 9 + src/Numos.CoreSim/Solvers/BuiltInSolvers.cs | 33 ++++ .../Solvers/GasInjectionSolver.cs | 101 +++++++++++ src/Numos.CoreSim/Solvers/IAtmosSolver.cs | 9 + .../AtmosDangerousApiTests.cs | 58 +++++- .../AtmosSolverPipelineTests.cs | 103 +++++++++++ 22 files changed, 1102 insertions(+), 103 deletions(-) create mode 100644 src/Numos.API.Dangerous/AtmosDangerousSolver.cs create mode 100644 src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs create mode 100644 src/Numos.API/AtmosSolver.cs create mode 100644 src/Numos.API/AtmosSolverContext.cs create mode 100644 src/Numos.API/AtmosSolverPipeline.cs create mode 100644 src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs create mode 100644 src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs create mode 100644 src/Numos.CoreSim/Solvers/AtmosSolverStageNames.cs create mode 100644 src/Numos.CoreSim/Solvers/BuiltInSolvers.cs create mode 100644 src/Numos.CoreSim/Solvers/GasInjectionSolver.cs create mode 100644 src/Numos.CoreSim/Solvers/IAtmosSolver.cs create mode 100644 tests/Numos.API.Tests/AtmosSolverPipelineTests.cs diff --git a/README.md b/README.md index 9c8a1ac..31ca481 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ The project will follow a regular semantic versioning structure when I feel comf - Engine-agnostic, with a supported `Numos.API` facade over an internal simulation kernel - Multithreaded intra-chunk advection and thermodynamics - Singlethreaded cross-chunk boundary flow +- Ordered solver pipeline with replaceable/disableable built-in stages and custom delegates +- Separate supported solver context and opt-in `Numos.API.Dangerous` live-span context - Ideal-gas pressure in pascals (`P = nRT/V`) with configurable, uniform voxel volume - Sensible internal-energy transport using per-species molar heat capacity at constant volume - Simulation-owned `IGasMixture` containers and sandboxed live voxel mixtures for canisters, pumps, and tools diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 37a1cec..f105e53 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -22,9 +22,10 @@ - 3.7 [Container and Voxel Gas Mixtures](#37-container-and-voxel-gas-mixtures) 4. [Simulation Loop](#4-simulation-loop) - 4.1 [Fixed Timestep Accumulator](#41-fixed-timestep-accumulator) - - 4.2 [Phase 1 — Pressure Advection](#42-phase-1--pressure-advection) - - 4.3 [Phase 2 — Cross-Chunk Boundary Flow](#43-phase-2--cross-chunk-boundary-flow) - - 4.4 [Phase 3 — Thermodynamics](#44-phase-3--thermodynamics) + - 4.2 [Solver Pipeline](#42-solver-pipeline) + - 4.3 [Stage 1 — Pressure Advection](#43-stage-1--pressure-advection) + - 4.4 [Stage 2 — Cross-Chunk Boundary Flow](#44-stage-2--cross-chunk-boundary-flow) + - 4.5 [Stages 3 and 4 — Thermodynamics and Thermal Boundaries](#45-stages-3-and-4--thermodynamics-and-thermal-boundaries) 5. [Stability & Convergence Mechanisms](#5-stability--convergence-mechanisms) - 5.1 [Per-Neighbor Bulk-Flow Cap](#51-per-neighbor-bulk-flow-cap) - 5.2 [Damping & Low-Delta Regime](#52-damping--low-delta-regime) @@ -68,7 +69,8 @@ When a disturbance exceeds a configurable threshold (the "Threshold of Violence" ```mermaid graph TD - API["AtmosSimulation (Public API)"] --> A["AtmosKernel (Tick Driver)"] + API["AtmosSimulation (Public API)"] --> PIPE["Solver Pipeline"] + PIPE --> A["AtmosKernel (Tick State)"] DANGER["Numos.API.Dangerous (Opt-in Raw Views)"] -.-> A A --> B["AtmosChunk[] (Active Grid)"] @@ -90,10 +92,12 @@ Numos deliberately exposes two package-level integration surfaces: | Package | Intended use | Compatibility | State access | |---------|--------------|--------------------------------------|---------------------------------------------------| | `Numos.API` | Normal engine and game integration | Supported public contract | Handles, validated operations, detached snapshots | -| `Numos.API.Dangerous` | Measured performance-critical code | No compatibility guarantee (for now) | No impl for now. | +| `Numos.API.Dangerous` | Measured performance-critical solver code | No compatibility guarantee (for now) | Callback-scoped live spans and unchecked state views | The dangerous package must be referenced separately and imported through `Numos.API.Dangerous`. Access begins with -`simulation.Dangerous()`. +`simulation.Dangerous()`. Standard custom solvers use detached snapshots and validated mutations. Dangerous custom +solvers are stack-scoped callbacks over live chunk arrays and gas-channel spans; they are responsible for maintaining +cache, topology, and revision invariants after raw writes. The kernel hooks used by this package live in `AtmosKernel.Dangerous.cs`, keeping them distinct from the internal operations that back the supported facade. `AtmosKernel`, `AtmosChunk`, and gas-channel representations remain @@ -330,9 +334,54 @@ Each frame: 2. The accumulator is clamped to `FixedDt * MaxStepsPerFrame` to prevent a "spiral of death" when frame rate drops. 3. While the accumulator ≥ `FixedDt`, a simulation tick is consumed. -Each tick proceeds through three phases: +### 4.2 Solver Pipeline -### 4.2 Phase 1 — Pressure Advection +Each tick captures one chunk/configuration snapshot, increments the tick counter, and executes the ordered +`simulation.Solvers` pipeline. Its default stages are: + +1. `advection` +2. `boundary-flow` +3. `thermodynamics` +4. `thermal-boundary` + +Stages can be enabled, disabled, removed, or restored with `ResetToDefaults`. Standard delegates can be appended or +inserted before/after any named stage: + +```csharp +simulation.Solvers.RegisterAfter(AtmosBuiltInSolvers.Advection, "game-reactions", context => +{ + foreach (AtmosChunkHandle chunk in context.Chunks) + { + AtmosChunkSnapshot snapshot = context.GetChunkSnapshot(chunk); + // Inspect the detached snapshot and apply results through validated context methods. + } +}); + +simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Thermodynamics, false); +``` + +Pipeline edits made by a callback take effect on the next tick. Transient boundary-event queues are cleared at the +start of every tick, so disabling a consumer stage cannot replay stale events when it is later re-enabled. + +Solvers that have a measured need to avoid snapshot copies can opt into live storage through the separate dangerous +package: + +```csharp +simulation.Dangerous().Solvers.RegisterAfter(AtmosBuiltInSolvers.Advection, "fast-reaction", context => +{ + AtmosDangerousChunk chunk = context.GetChunk(0); + Span oxygen = chunk.GetGasChannel(0).Moles; + // Raw writes are unchecked. Repair affected caches/topology and call MarkChanged as required. +}); +``` + +Gas injection is an atomic solver operation shared by the supported API, boundary flow, and dangerous solver context. +It recalculates the target voxel's existing total SHC from its gas composition before temperature mixing. Dangerous +pipeline injection uses the normalized gas properties and pressure coefficient captured for that tick. + +The four default stages are described below. + +### 4.3 Stage 1 — Pressure Advection This is the core fluid dynamics step. It runs in parallel across chunks. @@ -365,7 +414,7 @@ This is the core fluid dynamics step. It runs in parallel across chunks. 5. **Emit boundary events**: If a voxel is on the edge of the chunk (coordinate is 0 or `Size - 1`) and has positive pressure at or above the normalized `VacuumThreshold`, a `BoundaryFlowEvent` is emitted for cross-chunk processing. -### 4.3 Phase 2 — Cross-Chunk Boundary Flow +### 4.4 Stage 2 — Cross-Chunk Boundary Flow Boundary events are collected into a `ConcurrentQueue` during the parallel advection phase, then processed **sequentially** afterward. @@ -386,11 +435,11 @@ For each boundary event: For a void target, neighbor moles and temperature are treated as zero. An unregistered gas uses `DefaultDiffusionCoefficient`. 8. Transfer the capped moles directly (no delta buffer — this is sequential). -Each species carries `molesMoved * c_effective * sourceEffectiveTemperature` of sensible energy during the direct transfer. The source and target heat-capacity caches, temperatures, and pressures are updated immediately by energy balance. Before injection, the target voxel's existing heat capacity is recalculated from its current moles and the live gas registry, including for a target chunk that was sleeping before the transfer. +Each species carries `molesMoved * c_effective * sourceEffectiveTemperature` of sensible energy during the direct transfer. The source and target heat-capacity caches, temperatures, and pressures are updated immediately by energy balance. Before injection, the target voxel's existing heat capacity is recalculated from its current moles and the normalized gas registry captured for the tick, including for a target chunk that was sleeping before the transfer. If the adjacent chunk is not registered or the mapped target is solid, no transfer occurs. A non-void target room is woken before it receives gas. A void target is an energy sink: transferred moles and their carried energy are removed from the source without being added to a target voxel. -### 4.4 Phase 3 — Thermodynamics +### 4.5 Stages 3 and 4 — Thermodynamics and Thermal Boundaries Thermodynamics runs at half frequency (every 2nd tick) to save computation. Execution order is: diff --git a/src/Numos.API.Dangerous/AtmosDangerousApi.cs b/src/Numos.API.Dangerous/AtmosDangerousApi.cs index 9961472..e1d8eb2 100644 --- a/src/Numos.API.Dangerous/AtmosDangerousApi.cs +++ b/src/Numos.API.Dangerous/AtmosDangerousApi.cs @@ -1,5 +1,3 @@ -using Numos.CoreSim; - namespace Numos.API.Dangerous; /// @@ -20,10 +18,15 @@ namespace Numos.API.Dangerous; /// public readonly struct AtmosDangerousApi { - private readonly AtmosKernel _kernel; + private readonly AtmosSimulation _simulation; - internal AtmosDangerousApi(AtmosKernel kernel) + internal AtmosDangerousApi(AtmosSimulation simulation) { - _kernel = kernel; + _simulation = simulation; } + + /// + /// Gets the dangerous registration surface for the simulation's shared solver pipeline. + /// + public AtmosDangerousSolverPipeline Solvers => new(_simulation); } \ No newline at end of file diff --git a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs new file mode 100644 index 0000000..f137412 --- /dev/null +++ b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs @@ -0,0 +1,166 @@ +using Numos.CoreSim; +using Numos.CoreSim.Solvers; +using Numos.Maths; + +namespace Numos.API.Dangerous; + +/// +/// A low-level solver that operates on live simulation storage. +/// +/// +/// The context and its views are valid only for the callback. Numos performs no validation or cache repair +/// after arbitrary span writes; the solver is responsible for maintaining all invariants it touches. +/// +public delegate void AtmosDangerousSolver(AtmosDangerousSolverContext context); + +/// +/// Low-level state supplied to a dangerous solver for one fixed tick. +/// +public readonly ref struct AtmosDangerousSolverContext +{ + private readonly AtmosSolverExecutionContext _context; + + internal AtmosDangerousSolverContext(AtmosSolverExecutionContext context) + { + _context = context; + } + + /// The one-based tick number currently being solved. + public int TickCount => _context.TickCount; + + /// The mutable live configuration retained by the simulation. + public AtmosConfig Config => _context.Kernel.DangerousConfiguration; + + /// The number of chunks in the tick snapshot. + public int ChunkCount => _context.Chunks.Length; + + /// Returns a live view of a chunk in the tick snapshot. + public AtmosDangerousChunk GetChunk(int index) + { + return new AtmosDangerousChunk(_context.Chunks[index]); + } + + /// + /// Injects gas using the normalized SHC and pressure coefficient captured for this tick. + /// + public void InjectGasToVoxel(int chunkIndex, ushort localVoxelIndex, int gasId, float moles, + float temperature) + { + _context.Kernel.DangerousInjectGasDuringTick( + _context.Chunks[chunkIndex], localVoxelIndex, gasId, moles, temperature); + } +} + +/// +/// Live, unchecked views over one solver chunk. +/// +public readonly ref struct AtmosDangerousChunk +{ + private readonly AtmosChunk _chunk; + + internal AtmosDangerousChunk(AtmosChunk chunk) + { + _chunk = chunk; + } + + /// The chunk-grid position. + public Int3 Position => _chunk.GridPosition; + + /// The chunk dimensions. + public Int3 Dimensions => _chunk.Dimensions; + + /// The number of addressable voxels. + public int VoxelCount => _chunk.VoxelCount; + + /// Whether built-in solver stages currently process the chunk. + public bool IsAwake => _chunk.IsAwake; + + /// Gets or sets the unchecked sleep counter. + public int SleepTimer { get => _chunk.SleepTimer; set => _chunk.SleepTimer = value; } + + /// The number of active gas channels. + public int ActiveGasCount => _chunk.ActiveGasCount; + + /// The number of valid active-air indices. + public int ActiveAirCount => _chunk.ActiveAirCount; + + /// The number of active room IDs. + public int ActiveRoomCount => _chunk.ActiveRoomCount; + + /// Live per-voxel temperature storage. + public Span Temperature => _chunk.Temperature.AsSpan(); + + /// Live per-voxel pressure-cache storage. + public Span TotalPressure => _chunk.TotalPressure.AsSpan(); + + /// Live per-voxel heat-capacity-cache storage. + public Span TotalHeatCapacity => _chunk.TotalHeatCapacity.AsSpan(); + + /// Live per-voxel room-classification storage. + public Span VoxelRoomMap => _chunk.VoxelRoomMap.AsSpan(); + + /// Live active-air indices, limited to the current valid count. + public Span ActiveAirIndices => _chunk.ActiveAirIndices.AsSpan(0, _chunk.ActiveAirCount); + + /// Live active-room IDs, limited to the current valid count. + public Span ActiveRoomIds => _chunk.ActiveRoomIds.AsSpan(0, _chunk.ActiveRoomCount); + + /// Returns a live gas-channel view by active-channel index. + public AtmosDangerousGasChannel GetGasChannel(int index) + { + if ((uint)index >= (uint)_chunk.ActiveGasCount) + throw new ArgumentOutOfRangeException(nameof(index)); + return new AtmosDangerousGasChannel(_chunk.ActiveGases[index], _chunk.VoxelCount); + } + + /// Maps unchecked local coordinates to a flat voxel index. + public ushort GetVoxelIndex(int x, int y, int z) + { + return _chunk.GetIndex(x, y, z); + } + + /// Wakes and activates a room using the kernel operation. + public void WakeRoom(int roomId) + { + _chunk.WakeRoom(roomId); + } + + /// Puts the chunk to sleep using the kernel operation. + public void Sleep() + { + _chunk.Sleep(); + } + + /// Rebuilds the active-air index after raw topology edits. + public void RebuildActiveAirIndices() + { + _chunk.RebuildActiveAirIndices(); + } + + /// Advances the presentation revision after raw observable writes. + public void MarkChanged() + { + _chunk.MarkChanged(); + } +} + +/// +/// Live, unchecked view over one structure-of-arrays gas channel. +/// +public readonly ref struct AtmosDangerousGasChannel +{ + private readonly GasChannel _channel; + private readonly int _voxelCount; + + internal AtmosDangerousGasChannel(GasChannel channel, int voxelCount) + { + _channel = channel; + _voxelCount = voxelCount; + } + + /// The gas registry ID represented by this channel. + public int GasId => _channel.GasId; + + /// Live per-voxel mole storage for this gas. + public Span Moles => _channel.Moles.AsSpan(0, _voxelCount); +} \ No newline at end of file diff --git a/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs b/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs new file mode 100644 index 0000000..a9106a5 --- /dev/null +++ b/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs @@ -0,0 +1,45 @@ +using JetBrains.Annotations; +using Numos.CoreSim; +using Numos.CoreSim.Solvers; + +namespace Numos.API.Dangerous; + +/// +/// Registers custom stages that receive unchecked live simulation views. +/// +public readonly struct AtmosDangerousSolverPipeline +{ + private readonly AtmosSimulation _simulation; + + internal AtmosDangerousSolverPipeline(AtmosSimulation simulation) + { + _simulation = simulation; + } + + /// Appends a dangerous custom solver to the shared pipeline. + [PublicAPI] + public void Register(string name, AtmosDangerousSolver solver) + { + ArgumentNullException.ThrowIfNull(solver); + _simulation.Kernel.RegisterSolver(name, SolverStepKind.Dangerous, + context => solver(new AtmosDangerousSolverContext(context))); + } + + /// Registers a dangerous solver immediately before an existing stage. + [PublicAPI] + public void RegisterBefore(string existingName, string name, AtmosDangerousSolver solver) + { + ArgumentNullException.ThrowIfNull(solver); + _simulation.Kernel.RegisterSolverBefore(existingName, name, SolverStepKind.Dangerous, + context => solver(new AtmosDangerousSolverContext(context))); + } + + /// Registers a dangerous solver immediately after an existing stage. + [PublicAPI] + public void RegisterAfter(string existingName, string name, AtmosDangerousSolver solver) + { + ArgumentNullException.ThrowIfNull(solver); + _simulation.Kernel.RegisterSolverAfter(existingName, name, SolverStepKind.Dangerous, + context => solver(new AtmosDangerousSolverContext(context))); + } +} \ No newline at end of file diff --git a/src/Numos.API.Dangerous/AtmosSimulationDangerousExtensions.cs b/src/Numos.API.Dangerous/AtmosSimulationDangerousExtensions.cs index 2deb980..c544300 100644 --- a/src/Numos.API.Dangerous/AtmosSimulationDangerousExtensions.cs +++ b/src/Numos.API.Dangerous/AtmosSimulationDangerousExtensions.cs @@ -15,6 +15,7 @@ public static class AtmosSimulationDangerousExtensions public static AtmosDangerousApi Dangerous(this AtmosSimulation simulation) { ArgumentNullException.ThrowIfNull(simulation); - return new AtmosDangerousApi(simulation.Kernel); + _ = simulation.Kernel; + return new AtmosDangerousApi(simulation); } } \ No newline at end of file diff --git a/src/Numos.API/AtmosSimulation.cs b/src/Numos.API/AtmosSimulation.cs index ec532a6..e1018f0 100644 --- a/src/Numos.API/AtmosSimulation.cs +++ b/src/Numos.API/AtmosSimulation.cs @@ -96,6 +96,7 @@ public AtmosSimulation( _chunkDepth = chunkDepth; _kernel = new AtmosKernel(chunkWidth, chunkHeight, chunkDepth); _kernel.SetAtmosConfig(config); + Solvers = new AtmosSolverPipeline(this); } /// @@ -120,6 +121,12 @@ internal AtmosKernel Kernel [PublicAPI] public AtmosConfig Config { get; private set; } + /// + /// Gets the ordered solver pipeline used by subsequent ticks. + /// + [PublicAPI] + public AtmosSolverPipeline Solvers { get; } + /// /// Gets the number of chunks currently owned by the simulation. /// diff --git a/src/Numos.API/AtmosSolver.cs b/src/Numos.API/AtmosSolver.cs new file mode 100644 index 0000000..6bbc48a --- /dev/null +++ b/src/Numos.API/AtmosSolver.cs @@ -0,0 +1,50 @@ +namespace Numos.API; + +/// +/// A user-defined stage in an tick. +/// +/// +/// Standard solvers inspect detached snapshots and mutate the simulation through validated operations on +/// . Use Numos.API.Dangerous only when profiling demonstrates that a +/// solver needs direct access to live storage. +/// +/// The supported simulation surface for the current tick. +public delegate void AtmosSolver(AtmosSolverContext context); + +/// +/// Identifies the origin and compatibility boundary of a registered solver stage. +/// +public enum AtmosSolverKind +{ + /// A built-in Numos stage. + BuiltIn, + + /// A custom solver using the supported snapshot and mutation APIs. + Standard, + + /// A custom solver registered through Numos.API.Dangerous. + Dangerous +} + +/// +/// Detached metadata for one registered solver stage. +/// +public readonly record struct AtmosSolverStep(string Name, bool IsEnabled, AtmosSolverKind Kind); + +/// +/// Stable names of the default Numos solver stages. +/// +public static class AtmosBuiltInSolvers +{ + /// Parallel intra-chunk pressure advection and species diffusion. + public const string Advection = "advection"; + + /// Sequential cross-chunk pressure flow. + public const string BoundaryFlow = "boundary-flow"; + + /// Intra-chunk thermal diffusion and phase changes. + public const string Thermodynamics = "thermodynamics"; + + /// Cross-chunk thermal diffusion. + public const string ThermalBoundary = "thermal-boundary"; +} \ No newline at end of file diff --git a/src/Numos.API/AtmosSolverContext.cs b/src/Numos.API/AtmosSolverContext.cs new file mode 100644 index 0000000..32c2e04 --- /dev/null +++ b/src/Numos.API/AtmosSolverContext.cs @@ -0,0 +1,79 @@ +using Numos.CoreSim; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.CoreSim.Datatypes.Snapshots; +using Numos.CoreSim.Solvers; + +namespace Numos.API; + +/// +/// Supported state access available to a custom solver during one fixed tick. +/// +/// +/// Reads are detached snapshots and writes use the same validation as . The +/// chunk list is captured at the beginning of the tick; registration changes apply to the next tick. +/// +public sealed class AtmosSolverContext +{ + private readonly AtmosSimulation _simulation; + private readonly AtmosChunkHandle[] _chunks; + + internal AtmosSolverContext(AtmosSimulation simulation, AtmosSolverExecutionContext context) + { + _simulation = simulation; + TickCount = context.TickCount; + _chunks = context.Chunks.Select(static chunk => new AtmosChunkHandle(chunk.GridPosition)).ToArray(); + } + + /// The one-based tick number currently being solved. + public int TickCount { get; } + + /// The simulation's live configuration. + public AtmosConfig Config => _simulation.Config; + + /// Chunks captured for the current tick. + public IReadOnlyList Chunks => _chunks; + + /// Captures a detached snapshot of a current-tick chunk. + public AtmosChunkSnapshot GetChunkSnapshot(AtmosChunkHandle chunk) + { + return _simulation.GetChunkSnapshot(chunk); + } + + /// Captures a detached snapshot of one current-tick voxel. + public AtmosVoxelSnapshot GetVoxelSnapshot(AtmosChunkHandle chunk, ushort localVoxelIndex) + { + return _simulation.GetVoxelSnapshot(chunk, localVoxelIndex); + } + + /// Changes one voxel classification through the validated API. + public void SetVoxelClassification(AtmosChunkHandle chunk, ushort localVoxelIndex, + VoxelClassification classification) + { + _simulation.SetVoxelClassification(chunk, localVoxelIndex, classification); + } + + /// Changes one voxel temperature through the validated API. + public void SetVoxelTemperature(AtmosChunkHandle chunk, ushort localVoxelIndex, float temperature) + { + _simulation.SetVoxelTemperature(chunk, localVoxelIndex, temperature); + } + + /// Adds gas to a voxel through the validated, SHC-aware injection path. + public void AddGasToVoxel(AtmosChunkHandle chunk, ushort localVoxelIndex, int gasId, float moles, + float temperature) + { + _simulation.AddGasToVoxel(chunk, localVoxelIndex, gasId, moles, temperature); + } + + /// Wakes a room through the validated API. + public void WakeRoom(AtmosChunkHandle chunk, int roomId) + { + _simulation.WakeRoom(chunk, roomId); + } + + /// Puts a chunk to sleep through the validated API. + public void SleepChunk(AtmosChunkHandle chunk) + { + _simulation.SleepChunk(chunk); + } +} \ No newline at end of file diff --git a/src/Numos.API/AtmosSolverPipeline.cs b/src/Numos.API/AtmosSolverPipeline.cs new file mode 100644 index 0000000..a28498b --- /dev/null +++ b/src/Numos.API/AtmosSolverPipeline.cs @@ -0,0 +1,86 @@ +using JetBrains.Annotations; +using Numos.CoreSim.Solvers; + +namespace Numos.API; + +/// +/// Configures the ordered solver stages executed by an . +/// +public sealed class AtmosSolverPipeline +{ + private readonly AtmosSimulation _simulation; + + internal AtmosSolverPipeline(AtmosSimulation simulation) + { + _simulation = simulation; + } + + /// Returns detached metadata in execution order. + [PublicAPI] + public IReadOnlyList Steps + { + get + { + return _simulation.Kernel.GetSolverSteps() + .Select(static step => new AtmosSolverStep( + step.Name, + step.Enabled, + step.Kind switch + { + SolverStepKind.BuiltIn => AtmosSolverKind.BuiltIn, + SolverStepKind.Standard => AtmosSolverKind.Standard, + SolverStepKind.Dangerous => AtmosSolverKind.Dangerous, + _ => throw new ArgumentOutOfRangeException() + })) + .ToArray(); + } + } + + /// Appends a supported custom solver to the pipeline. + [PublicAPI] + public void Register(string name, AtmosSolver solver) + { + ArgumentNullException.ThrowIfNull(solver); + _simulation.Kernel.RegisterSolver(name, SolverStepKind.Standard, + context => solver(new AtmosSolverContext(_simulation, context))); + } + + /// Registers a supported custom solver immediately before an existing stage. + [PublicAPI] + public void RegisterBefore(string existingName, string name, AtmosSolver solver) + { + ArgumentNullException.ThrowIfNull(solver); + _simulation.Kernel.RegisterSolverBefore(existingName, name, SolverStepKind.Standard, + context => solver(new AtmosSolverContext(_simulation, context))); + } + + /// Registers a supported custom solver immediately after an existing stage. + [PublicAPI] + public void RegisterAfter(string existingName, string name, AtmosSolver solver) + { + ArgumentNullException.ThrowIfNull(solver); + _simulation.Kernel.RegisterSolverAfter(existingName, name, SolverStepKind.Standard, + context => solver(new AtmosSolverContext(_simulation, context))); + } + + /// Removes a stage by name. + [PublicAPI] + public bool Unregister(string name) + { + return _simulation.Kernel.UnregisterSolver(name); + } + + /// Enables or disables a stage without changing its position. + [PublicAPI] + public bool SetEnabled(string name, bool enabled) + { + return _simulation.Kernel.SetSolverEnabled(name, enabled); + } + + /// Restores the built-in pipeline and removes every custom solver. + [PublicAPI] + public void ResetToDefaults() + { + _simulation.Kernel.ResetSolverPipeline(); + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosKernel.API.cs b/src/Numos.CoreSim/AtmosKernel.API.cs index 19badb0..576cf8f 100644 --- a/src/Numos.CoreSim/AtmosKernel.API.cs +++ b/src/Numos.CoreSim/AtmosKernel.API.cs @@ -1,5 +1,6 @@ using Numos.CoreSim.Datatypes.Primitives; using Numos.CoreSim.Datatypes.Snapshots; +using Numos.CoreSim.Solvers; using Numos.Maths; namespace Numos.CoreSim; @@ -21,6 +22,74 @@ internal int ChunkCount } } + /// + /// Returns a detached description of the currently configured solver pipeline. + /// + internal SolverStepInfo[] GetSolverSteps() + { + lock (_stateGate) + { + return _solverPipeline.GetSteps(); + } + } + + internal void RegisterSolver(string name, SolverStepKind kind, + Action solver) + { + lock (_stateGate) + { + _solverPipeline.Register(name, kind, solver, _solverPipeline.Count); + } + } + + internal void RegisterSolverBefore(string existingName, string name, SolverStepKind kind, + Action solver) + { + lock (_stateGate) + { + int index = _solverPipeline.IndexOf(existingName); + if (index < 0) + throw new KeyNotFoundException($"No solver named '{existingName}' is registered."); + _solverPipeline.Register(name, kind, solver, index); + } + } + + internal void RegisterSolverAfter(string existingName, string name, SolverStepKind kind, + Action solver) + { + lock (_stateGate) + { + int index = _solverPipeline.IndexOf(existingName); + if (index < 0) + throw new KeyNotFoundException($"No solver named '{existingName}' is registered."); + _solverPipeline.Register(name, kind, solver, index + 1); + } + } + + internal bool UnregisterSolver(string name) + { + lock (_stateGate) + { + return _solverPipeline.Unregister(name); + } + } + + internal bool SetSolverEnabled(string name, bool enabled) + { + lock (_stateGate) + { + return _solverPipeline.SetEnabled(name, enabled); + } + } + + internal void ResetSolverPipeline() + { + lock (_stateGate) + { + _solverPipeline.Reset(); + } + } + /// /// Returns a detached list of the currently registered chunk-grid positions. /// @@ -464,7 +533,7 @@ internal void AddGasToVoxel(Int3 position, ushort localVoxelIndex, int gasId, fl ValidateGasInjection(gasId, moles, temperature); chunk.WakeRoom(chunk.VoxelRoomMap[localVoxelIndex]); - InjectGasWithEnergy(chunk, localVoxelIndex, gasId, moles, temperature, GetMolarHeatCapacityAtConstantVolume(gasId)); + GasInjectionSolver.Inject(chunk, localVoxelIndex, gasId, moles, temperature, _config); } } @@ -586,4 +655,4 @@ private static void ValidateGasInjection(int gasId, float moles, float temperatu "Temperature must be nonnegative and finite."); } } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AtmosKernel.Dangerous.cs b/src/Numos.CoreSim/AtmosKernel.Dangerous.cs index 299039c..5e38984 100644 --- a/src/Numos.CoreSim/AtmosKernel.Dangerous.cs +++ b/src/Numos.CoreSim/AtmosKernel.Dangerous.cs @@ -1,3 +1,24 @@ +using Numos.CoreSim.Datatypes.Primitives; +using Numos.CoreSim.Solvers; + namespace Numos.CoreSim; -internal sealed partial class AtmosKernel; \ No newline at end of file +internal sealed partial class AtmosKernel +{ + internal AtmosConfig DangerousConfiguration => _config; + + /// + /// Injects gas from a solver stage using the normalized heat-capacity and pressure values captured for + /// the current tick. + /// + internal void DangerousInjectGasDuringTick(AtmosChunk chunk, ushort localVoxelIndex, int gasId, + float moles, float temperature) + { + int roomId = chunk.VoxelRoomMap[localVoxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return; + + chunk.WakeRoom(roomId); + GasInjectionSolver.InjectDuringTick(chunk, localVoxelIndex, gasId, moles, temperature, _tickConfig); + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosKernel.cs b/src/Numos.CoreSim/AtmosKernel.cs index 71b8722..a600097 100644 --- a/src/Numos.CoreSim/AtmosKernel.cs +++ b/src/Numos.CoreSim/AtmosKernel.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Numos.CoreSim.Datatypes.Events; using Numos.CoreSim.Datatypes.Primitives; +using Numos.CoreSim.Solvers; using Numos.Maths; namespace Numos.CoreSim; @@ -46,6 +47,7 @@ internal sealed partial class AtmosKernel : IDisposable private float _accumulator; private long _chunkCollectionRevision; private readonly AtmosSolverConfigSnapshot _tickConfig = new(); + private readonly AtmosSolverPipeline _solverPipeline; /// /// Current that this simulation runs under. @@ -72,6 +74,7 @@ internal AtmosKernel( _precipBufferPool = new ThreadLocal(() => new PrecipitationEvent[maxPrecipitationEvents]); _thermalBoundaryBufferPool = new ThreadLocal(() => new ThermalBoundaryEvent[_maxBoundaryEvents]); + _solverPipeline = new AtmosSolverPipeline(CreateDefaultSolverSteps); } /// @@ -96,6 +99,15 @@ private void TickSimulation(AtmosChunk[] chunks) _tickConfig.Capture(_config); TickCount++; + // Producer and consumer stages may be independently disabled. Never carry their transient events into a + // later tick when the consumer is re-enabled. + while (_boundaryEvents.TryDequeue(out _)) + { + } + while (_thermalBoundaryEvents.TryDequeue(out _)) + { + } + // A revision is advanced once per processed tick. Conditional snapshot consumers can // consequently retain sleeping chunks without copying their arrays again. foreach (var chunk in chunks) @@ -104,8 +116,26 @@ private void TickSimulation(AtmosChunk[] chunks) chunk.MarkChanged(); } - // 1. Parallel Advection & Fickian Diffusion + _solverPipeline.Execute(new AtmosSolverExecutionContext(this, chunks)); + } + + private static SolverStep[] CreateDefaultSolverSteps() + { + IAtmosSolver advection = new AdvectionSolver(); + IAtmosSolver boundaryFlow = new BoundaryFlowSolver(); + IAtmosSolver thermodynamics = new ThermodynamicsSolver(); + IAtmosSolver thermalBoundary = new ThermalBoundarySolver(); + return + [ + new SolverStep(AtmosSolverStageNames.Advection, SolverStepKind.BuiltIn, advection.Solve), + new SolverStep(AtmosSolverStageNames.BoundaryFlow, SolverStepKind.BuiltIn, boundaryFlow.Solve), + new SolverStep(AtmosSolverStageNames.Thermodynamics, SolverStepKind.BuiltIn, thermodynamics.Solve), + new SolverStep(AtmosSolverStageNames.ThermalBoundary, SolverStepKind.BuiltIn, thermalBoundary.Solve) + ]; + } + internal void SolveAdvection(AtmosChunk[] chunks) + { Parallel.ForEach(chunks, chunk => { if (!chunk.IsAwake) @@ -122,8 +152,10 @@ private void TickSimulation(AtmosChunk[] chunks) _boundaryEvents.Enqueue((chunk.GridPosition, localBoundaryBuffer[i])); } }); + } - // 2. Sequential Boundary Processing + internal void SolveBoundaryFlow() + { long boundaryFlowStart = Stopwatch.GetTimestamp(); _orderedBoundaryEvents.Clear(); while (_boundaryEvents.TryDequeue(out var boundaryEvent)) @@ -135,35 +167,40 @@ private void TickSimulation(AtmosChunk[] chunks) } LastBoundaryTicks += Stopwatch.GetTimestamp() - boundaryFlowStart; + } + + internal void SolveThermodynamics(AtmosChunk[] chunks) + { + if (TickCount % AtmosSolverConstants.ThermodynamicsTickInterval != 0) + return; - // 3. Parallel Thermodynamics & Clausius-Clapeyron condensation. - if (TickCount % AtmosSolverConstants.ThermodynamicsTickInterval == 0) + Parallel.ForEach(chunks, chunk => { - Parallel.ForEach(chunks, chunk => - { - if (!chunk.IsAwake) - return; + if (!chunk.IsAwake) + return; - var localPrecipBuffer = _precipBufferPool.Value; - var precipCount = 0; + var localPrecipBuffer = _precipBufferPool.Value; + var precipCount = 0; - var localThermalBuffer = _thermalBoundaryBufferPool.Value; - var thermalBoundaryCount = 0; + var localThermalBuffer = _thermalBoundaryBufferPool.Value; + var thermalBoundaryCount = 0; - Debug.Assert(localPrecipBuffer != null, nameof(localPrecipBuffer) + " != null"); - Debug.Assert(localThermalBuffer != null, nameof(localThermalBuffer) + " != null"); - ProcessThermodynamics(chunk, localPrecipBuffer, ref precipCount, localThermalBuffer, - ref thermalBoundaryCount); + Debug.Assert(localPrecipBuffer != null, nameof(localPrecipBuffer) + " != null"); + Debug.Assert(localThermalBuffer != null, nameof(localThermalBuffer) + " != null"); + ProcessThermodynamics(chunk, localPrecipBuffer, ref precipCount, localThermalBuffer, + ref thermalBoundaryCount); - for (var i = 0; i < thermalBoundaryCount; i++) - { - _thermalBoundaryEvents.Enqueue((chunk.GridPosition, localThermalBuffer[i])); - } - }); + for (var i = 0; i < thermalBoundaryCount; i++) + _thermalBoundaryEvents.Enqueue((chunk.GridPosition, localThermalBuffer[i])); + }); + } - // 4. Boundary thermodynamics uses the same simultaneous conservative solve as intra-chunk edges. - ProcessThermalBoundaryFlows(_thermalBoundaryEvents); - } + internal void SolveThermalBoundary() + { + if (TickCount % AtmosSolverConstants.ThermodynamicsTickInterval != 0) + return; + + ProcessThermalBoundaryFlows(_thermalBoundaryEvents); } /// @@ -319,8 +356,8 @@ private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, { if (!neighborChunk.IsAwake) neighborChunk.WakeRoom(neighborChunk.VoxelRoomMap[neighborIdx]); - InjectGasWithEnergyDuringTick(neighborChunk, neighborIdx, gasId, totalMolesToMove, temp, - molarHeatCapacityAtConstantVolume); + GasInjectionSolver.InjectDuringTick(neighborChunk, neighborIdx, gasId, totalMolesToMove, + temp, _tickConfig); } } @@ -679,21 +716,6 @@ private float TickPressureToMoles(float pressure, float temperature) return pressure / denominator; } - private float CalculateHeatCapacityAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) - { - var totalHeatCapacity = 0f; - for (var g = 0; g < chunk.ActiveGasCount; g++) - { - float moles = chunk.ActiveGases[g].Moles[localVoxelIndex]; - if (moles <= 0f) - continue; - - totalHeatCapacity += moles * GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[g].GasId); - } - - return totalHeatCapacity; - } - private float CalculatePressureAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) { var totalMoles = 0f; @@ -712,47 +734,6 @@ private float GetEffectiveTemperature(float storedTemperature) return IsFinitePositive(fallback) ? fallback : AtmosConfigDefaults.DefaultTemperatureFallback; } - private void InjectGasWithEnergy(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, - float temperature, float molarHeatCapacityAtConstantVolume) - { - if (!chunk.IsAwake) - return; - - int room = chunk.VoxelRoomMap[localVoxelIndex]; - if (room == VoxelClassification.RoomSolid || room == VoxelClassification.RoomVoid) - return; - - chunk.TotalHeatCapacity[localVoxelIndex] = CalculateHeatCapacityAtVoxel(chunk, localVoxelIndex); - if (chunk.TotalHeatCapacity[localVoxelIndex] > 0f && - (!float.IsFinite(chunk.Temperature[localVoxelIndex]) || chunk.Temperature[localVoxelIndex] <= 0f)) - chunk.Temperature[localVoxelIndex] = GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]); - - chunk.InjectGasToVoxel(localVoxelIndex, gasId, moles, temperature, molarHeatCapacityAtConstantVolume, - GetPressurePerMoleKelvin()); - } - - private void InjectGasWithEnergyDuringTick(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, - float temperature, float molarHeatCapacityAtConstantVolume) - { - if (!chunk.IsAwake) - return; - - int room = chunk.VoxelRoomMap[localVoxelIndex]; - if (room == VoxelClassification.RoomSolid || room == VoxelClassification.RoomVoid) - return; - - chunk.TotalHeatCapacity[localVoxelIndex] = CalculateTickHeatCapacityAtVoxel(chunk, localVoxelIndex); - if (chunk.TotalHeatCapacity[localVoxelIndex] > 0f && - !IsFinitePositive(chunk.Temperature[localVoxelIndex])) - { - chunk.Temperature[localVoxelIndex] = - _tickConfig.GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]); - } - - chunk.InjectGasToVoxel(localVoxelIndex, gasId, moles, temperature, molarHeatCapacityAtConstantVolume, - _tickConfig.PressurePerMoleKelvin); - } - private float CalculateTickHeatCapacityAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) { var totalHeatCapacity = 0f; diff --git a/src/Numos.CoreSim/Collections/FlatArray.cs b/src/Numos.CoreSim/Collections/FlatArray.cs index 02036fd..3f449ef 100644 --- a/src/Numos.CoreSim/Collections/FlatArray.cs +++ b/src/Numos.CoreSim/Collections/FlatArray.cs @@ -165,6 +165,14 @@ public void CopyFrom(ReadOnlySpan source) source.CopyTo(_data); } + /// + /// Returns a live span over the backing storage for the opt-in dangerous API. + /// + internal Span AsSpan() + { + return _data.AsSpan(); + } + /// /// Returns a wrapper over the same storage using new dimensions. /// diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs b/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs new file mode 100644 index 0000000..0d74649 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs @@ -0,0 +1,17 @@ +namespace Numos.CoreSim.Solvers; + +/// +/// Stable inputs shared by every solver stage in one tick. +/// +internal sealed class AtmosSolverExecutionContext +{ + internal AtmosSolverExecutionContext(AtmosKernel kernel, AtmosChunk[] chunks) + { + Kernel = kernel; + Chunks = chunks; + } + + internal AtmosKernel Kernel { get; } + internal AtmosChunk[] Chunks { get; } + internal int TickCount => Kernel.TickCount; +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs b/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs new file mode 100644 index 0000000..d7869e7 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs @@ -0,0 +1,104 @@ +namespace Numos.CoreSim.Solvers; + +/// +/// Ordered, mutable collection of solver delegates executed for every fixed tick. +/// +internal sealed class AtmosSolverPipeline +{ + private readonly Func _createDefaults; + private readonly List _steps = []; + + internal AtmosSolverPipeline(Func createDefaults) + { + _createDefaults = createDefaults; + Reset(); + } + + internal int Count => _steps.Count; + + internal SolverStepInfo[] GetSteps() + { + return _steps.Select(static step => + new SolverStepInfo(step.Name, step.Enabled, step.Kind)) + .ToArray(); + } + + internal void Register(string name, SolverStepKind kind, + Action solver, int index) + { + ValidateName(name); + ArgumentNullException.ThrowIfNull(solver); + if (_steps.Any(step => string.Equals(step.Name, name, StringComparison.Ordinal))) + throw new InvalidOperationException($"A solver named '{name}' is already registered."); + if ((uint)index > (uint)_steps.Count) + throw new ArgumentOutOfRangeException(nameof(index)); + + _steps.Insert(index, new SolverStep(name, kind, solver)); + } + + internal int IndexOf(string name) + { + ValidateName(name); + return _steps.FindIndex(step => string.Equals(step.Name, name, StringComparison.Ordinal)); + } + + internal bool Unregister(string name) + { + int index = IndexOf(name); + if (index < 0) + return false; + + _steps.RemoveAt(index); + return true; + } + + internal bool SetEnabled(string name, bool enabled) + { + int index = IndexOf(name); + if (index < 0) + return false; + + _steps[index].Enabled = enabled; + return true; + } + + internal void Reset() + { + _steps.Clear(); + _steps.AddRange(_createDefaults()); + } + + internal void Execute(AtmosSolverExecutionContext context) + { + // A stage may edit the pipeline. Snapshotting makes those edits take effect on the next tick and keeps + // the current tick deterministic. + SolverStep[] steps = _steps.Where(static step => step.Enabled).ToArray(); + foreach (SolverStep step in steps) + step.Solver(context); + } + + private static void ValidateName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + } +} + +internal sealed class SolverStep( + string name, + SolverStepKind kind, + Action solver) +{ + internal string Name { get; } = name; + internal SolverStepKind Kind { get; } = kind; + internal Action Solver { get; } = solver; + internal bool Enabled { get; set; } = true; +} + +internal enum SolverStepKind +{ + BuiltIn, + Standard, + Dangerous +} + +internal readonly record struct SolverStepInfo(string Name, bool Enabled, SolverStepKind Kind); diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverStageNames.cs b/src/Numos.CoreSim/Solvers/AtmosSolverStageNames.cs new file mode 100644 index 0000000..a3e4555 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/AtmosSolverStageNames.cs @@ -0,0 +1,9 @@ +namespace Numos.CoreSim.Solvers; + +internal static class AtmosSolverStageNames +{ + internal const string Advection = "advection"; + internal const string BoundaryFlow = "boundary-flow"; + internal const string Thermodynamics = "thermodynamics"; + internal const string ThermalBoundary = "thermal-boundary"; +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/BuiltInSolvers.cs b/src/Numos.CoreSim/Solvers/BuiltInSolvers.cs new file mode 100644 index 0000000..5a4a7f3 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/BuiltInSolvers.cs @@ -0,0 +1,33 @@ +namespace Numos.CoreSim.Solvers; + +internal sealed class AdvectionSolver : IAtmosSolver +{ + public void Solve(AtmosSolverExecutionContext context) + { + context.Kernel.SolveAdvection(context.Chunks); + } +} + +internal sealed class BoundaryFlowSolver : IAtmosSolver +{ + public void Solve(AtmosSolverExecutionContext context) + { + context.Kernel.SolveBoundaryFlow(); + } +} + +internal sealed class ThermodynamicsSolver : IAtmosSolver +{ + public void Solve(AtmosSolverExecutionContext context) + { + context.Kernel.SolveThermodynamics(context.Chunks); + } +} + +internal sealed class ThermalBoundarySolver : IAtmosSolver +{ + public void Solve(AtmosSolverExecutionContext context) + { + context.Kernel.SolveThermalBoundary(); + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs b/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs new file mode 100644 index 0000000..fb8a756 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs @@ -0,0 +1,101 @@ +using Numos.CoreSim.Datatypes.Primitives; + +namespace Numos.CoreSim.Solvers; + +/// +/// Applies one gas injection while keeping mixture SHC, temperature, and pressure coherent. +/// +internal static class GasInjectionSolver +{ + internal static void Inject(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, + float temperature, AtmosConfig config) + { + if (!CanInject(chunk, localVoxelIndex)) + return; + + float fallbackHeatCapacity = IsFinitePositive(config.DefaultMolarHeatCapacityAtConstantVolume) + ? config.DefaultMolarHeatCapacityAtConstantVolume + : AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; + float currentHeatCapacity = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + float existingMoles = chunk.ActiveGases[gas].Moles[localVoxelIndex]; + if (existingMoles <= 0f) + continue; + currentHeatCapacity += existingMoles * GetMolarHeatCapacity( + chunk.ActiveGases[gas].GasId, config, fallbackHeatCapacity); + } + + float effectiveTemperature = IsFinitePositive(chunk.Temperature[localVoxelIndex]) + ? chunk.Temperature[localVoxelIndex] + : IsFinitePositive(config.DefaultTemperatureFallback) + ? config.DefaultTemperatureFallback + : AtmosConfigDefaults.DefaultTemperatureFallback; + float volume = IsFinitePositive(config.VoxelVolume) + ? config.VoxelVolume + : AtmosConfigDefaults.VoxelVolume; + + InjectCore(chunk, localVoxelIndex, gasId, moles, temperature, + GetMolarHeatCapacity(gasId, config, fallbackHeatCapacity), currentHeatCapacity, + effectiveTemperature, AtmosPhysicalConstants.MolarGasConstant / volume); + } + + internal static void InjectDuringTick(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, + float temperature, AtmosSolverConfigSnapshot config) + { + if (!CanInject(chunk, localVoxelIndex)) + return; + + float currentHeatCapacity = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + float existingMoles = chunk.ActiveGases[gas].Moles[localVoxelIndex]; + if (existingMoles <= 0f) + continue; + currentHeatCapacity += existingMoles * + config.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[gas].GasId); + } + + InjectCore(chunk, localVoxelIndex, gasId, moles, temperature, + config.GetMolarHeatCapacityAtConstantVolume(gasId), currentHeatCapacity, + config.GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]), config.PressurePerMoleKelvin); + } + + private static bool CanInject(AtmosChunk chunk, ushort localVoxelIndex) + { + if (!chunk.IsAwake) + return false; + + int room = chunk.VoxelRoomMap[localVoxelIndex]; + return room != VoxelClassification.RoomSolid && room != VoxelClassification.RoomVoid; + } + + private static void InjectCore(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, + float temperature, float molarHeatCapacity, float currentHeatCapacity, + float effectiveCurrentTemperature, float pressurePerMoleKelvin) + { + chunk.TotalHeatCapacity[localVoxelIndex] = currentHeatCapacity; + if (currentHeatCapacity > 0f && !IsFinitePositive(chunk.Temperature[localVoxelIndex])) + chunk.Temperature[localVoxelIndex] = effectiveCurrentTemperature; + + chunk.InjectGasToVoxel(localVoxelIndex, gasId, moles, temperature, molarHeatCapacity, + pressurePerMoleKelvin); + } + + private static float GetMolarHeatCapacity(int gasId, AtmosConfig config, float fallback) + { + if ((uint)gasId < (uint)config.GasRegistry.Count) + { + float configured = config.GasRegistry[gasId].MolarHeatCapacityAtConstantVolume; + if (IsFinitePositive(configured)) + return configured; + } + + return fallback; + } + + private static bool IsFinitePositive(float value) + { + return float.IsFinite(value) && value > 0f; + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/IAtmosSolver.cs b/src/Numos.CoreSim/Solvers/IAtmosSolver.cs new file mode 100644 index 0000000..4b0c986 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/IAtmosSolver.cs @@ -0,0 +1,9 @@ +namespace Numos.CoreSim.Solvers; + +/// +/// One atomic stage in an atmospheric simulation tick. +/// +internal interface IAtmosSolver +{ + void Solve(AtmosSolverExecutionContext context); +} \ No newline at end of file diff --git a/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs b/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs index 5d27132..b23d895 100644 --- a/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs +++ b/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs @@ -31,4 +31,60 @@ public void Dangerous_WithDisposedSimulation_Throws() Assert.That(simulation.Dangerous, Throws.TypeOf()); } -} \ No newline at end of file + + [Test] + public void RetainedDangerousApi_RejectsRegistrationAfterSimulationIsDisposed() + { + var simulation = new AtmosSimulation(); + var dangerous = simulation.Dangerous(); + simulation.Dispose(); + + Assert.That(() => dangerous.Solvers.Register("late", _ => { }), + Throws.TypeOf()); + } + + [Test] + public void DangerousSolver_ReceivesLiveChunkAndGasSpans() + { + using var simulation = new AtmosSimulation(1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new Numos.CoreSim.Datatypes.Primitives.VoxelClassification(7)); + simulation.AddGasToVoxel(chunk, 0, 2, 1f, 300f); + simulation.Dangerous().Solvers.RegisterAfter(AtmosBuiltInSolvers.ThermalBoundary, "raw-write", context => + { + var rawChunk = context.GetChunk(0); + rawChunk.GetGasChannel(0).Moles[0] = 4f; + rawChunk.MarkChanged(); + }); + + simulation.Tick(); + + Assert.That(simulation.GetChunkSnapshot(chunk).Gases.Single().Moles[0], Is.EqualTo(4f)); + Assert.That(simulation.Solvers.Steps.Single(step => step.Name == "raw-write").Kind, + Is.EqualTo(AtmosSolverKind.Dangerous)); + } + + [Test] + public void DangerousInjection_UsesCurrentVoxelShcFromTickSnapshot() + { + var config = new Numos.CoreSim.AtmosConfig + { + GasRegistry = + [ + new Numos.CoreSim.GasProperties { MolarHeatCapacityAtConstantVolume = 10f }, + new Numos.CoreSim.GasProperties { MolarHeatCapacityAtConstantVolume = 30f } + ] + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new Numos.CoreSim.Datatypes.Primitives.VoxelClassification(7)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.Dangerous().Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "inject", context => + context.InjectGasToVoxel(0, 0, 1, 1f, 600f)); + + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.That(snapshot.Temperature[0], Is.EqualTo(525f).Within(0.0001f)); + } +} diff --git a/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs b/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs new file mode 100644 index 0000000..9e4bd66 --- /dev/null +++ b/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs @@ -0,0 +1,103 @@ +using Numos.CoreSim; +using Numos.CoreSim.Datatypes.Primitives; + +namespace Numos.API.Tests; + +[TestFixture] +public sealed class AtmosSolverPipelineTests +{ + [Test] + public void NewSimulation_HasAtomicBuiltInPipelineInExecutionOrder() + { + using var simulation = new AtmosSimulation(); + + Assert.That(simulation.Solvers.Steps, Is.EqualTo(new[] + { + new AtmosSolverStep(AtmosBuiltInSolvers.Advection, true, AtmosSolverKind.BuiltIn), + new AtmosSolverStep(AtmosBuiltInSolvers.BoundaryFlow, true, AtmosSolverKind.BuiltIn), + new AtmosSolverStep(AtmosBuiltInSolvers.Thermodynamics, true, AtmosSolverKind.BuiltIn), + new AtmosSolverStep(AtmosBuiltInSolvers.ThermalBoundary, true, AtmosSolverKind.BuiltIn) + })); + } + + [Test] + public void RegisterBeforeAndAfter_ExecutesCustomSolversInConfiguredOrder() + { + using var simulation = new AtmosSimulation(); + var calls = new List(); + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "first", + context => calls.Add($"first:{context.TickCount}")); + simulation.Solvers.RegisterAfter("first", "second", _ => calls.Add("second")); + + simulation.Tick(); + + Assert.That(calls, Is.EqualTo(new[] { "first:1", "second" })); + } + + [Test] + public void DisabledBuiltInStage_IsSkippedAndCanBeReenabled() + { + var config = new AtmosConfig + { + VacuumThreshold = 0f, + MinimumPressureTransfer = 0f, + SleepThreshold = int.MaxValue + }; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 2f, 300f); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + + simulation.Tick(); + var disabled = simulation.GetChunkSnapshot(chunk); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, true); + simulation.Tick(); + var enabled = simulation.GetChunkSnapshot(chunk); + + Assert.Multiple(() => + { + Assert.That(disabled.Gases[0].Moles[1], Is.Zero); + Assert.That(enabled.Gases[0].Moles[1], Is.GreaterThan(0f)); + }); + } + + [Test] + public void StandardSolver_UsesDetachedReadsAndValidatedMutations() + { + using var simulation = new AtmosSimulation(1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(7)); + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "inject", context => + { + Assert.That(context.Chunks, Is.EqualTo(new[] { chunk })); + Assert.That(context.GetChunkSnapshot(chunk).Gases, Is.Empty); + context.AddGasToVoxel(chunk, 0, 3, 2f, 350f); + }); + + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.That(snapshot.Gases.Single().Moles[0], Is.EqualTo(2f)); + Assert.That(snapshot.Temperature[0], Is.EqualTo(350f)); + } + + [Test] + public void ResetToDefaults_RemovesCustomizations() + { + using var simulation = new AtmosSimulation(); + simulation.Solvers.Register("custom", _ => { }); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + + simulation.Solvers.ResetToDefaults(); + + Assert.That(simulation.Solvers.Steps.Select(static step => (step.Name, step.IsEnabled)), + Is.EqualTo(new[] + { + (AtmosBuiltInSolvers.Advection, true), + (AtmosBuiltInSolvers.BoundaryFlow, true), + (AtmosBuiltInSolvers.Thermodynamics, true), + (AtmosBuiltInSolvers.ThermalBoundary, true) + })); + } +} \ No newline at end of file From e4052d5ba26a97ad05c892ddb662afbb8dc11960 Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:24:34 -0500 Subject: [PATCH 06/14] Minimize AtmosKernel.cs, preferring to break up complex logic into atomic files. --- docs/atmospherics_technical_documentation.md | 86 +- .../AtmosDangerousSolver.cs | 13 +- src/Numos.CoreSim/AtmosKernel.Dangerous.cs | 24 - src/Numos.CoreSim/AtmosKernel.GasMixtures.cs | 29 +- src/Numos.CoreSim/AtmosKernel.cs | 1309 +---------------- .../Datatypes/Events/PrecipitationEvent.cs | 16 - src/Numos.CoreSim/Solvers/AdvectionSolver.cs | 292 ++++ .../Solvers/AtmosSolverExecutionContext.cs | 29 +- src/Numos.CoreSim/Solvers/AtmosSolverMath.cs | 134 ++ .../Solvers/AtmosSolverPipeline.cs | 13 +- .../Solvers/BoundaryFlowSolver.cs | 161 ++ src/Numos.CoreSim/Solvers/BuiltInSolvers.cs | 33 - .../Solvers/DefaultAtmosSolvers.cs | 37 + .../Solvers/GasInjectionSolver.cs | 39 +- .../Solvers/PhaseChangeSolver.cs | 80 + .../Solvers/ThermalBoundarySolver.cs | 195 +++ .../Solvers/ThermalDiffusionSolver.cs | 188 +++ .../Solvers/ThermodynamicsSolver.cs | 47 + 18 files changed, 1270 insertions(+), 1455 deletions(-) delete mode 100644 src/Numos.CoreSim/AtmosKernel.Dangerous.cs delete mode 100644 src/Numos.CoreSim/Datatypes/Events/PrecipitationEvent.cs create mode 100644 src/Numos.CoreSim/Solvers/AdvectionSolver.cs create mode 100644 src/Numos.CoreSim/Solvers/AtmosSolverMath.cs create mode 100644 src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs delete mode 100644 src/Numos.CoreSim/Solvers/BuiltInSolvers.cs create mode 100644 src/Numos.CoreSim/Solvers/DefaultAtmosSolvers.cs create mode 100644 src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs create mode 100644 src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs create mode 100644 src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs create mode 100644 src/Numos.CoreSim/Solvers/ThermodynamicsSolver.cs diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index f105e53..42b117f 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -69,16 +69,20 @@ When a disturbance exceeds a configurable threshold (the "Threshold of Violence" ```mermaid graph TD - API["AtmosSimulation (Public API)"] --> PIPE["Solver Pipeline"] - PIPE --> A["AtmosKernel (Tick State)"] - DANGER["Numos.API.Dangerous - (Opt-in Raw Views)"] -.-> A - A --> B["AtmosChunk[] (Active Grid)"] + API["AtmosSimulation (Public API)"] --> KERNEL["AtmosKernel (Lifecycle and Tick Driver)"] + API --> PIPE["Ordered Solver Pipeline"] + DANGER["Numos.API.Dangerous (Opt-in Raw Views)"] --> PIPE + KERNEL --> PIPE + PIPE --> CTX["Per-tick Solver Context"] + PIPE --> A["AdvectionSolver"] + PIPE --> BOUNDARY["BoundaryFlowSolver"] + PIPE --> THERMO["ThermodynamicsSolver"] + PIPE --> THERMAL["ThermalBoundarySolver"] + CTX --> B["AtmosChunk[] (Tick Snapshot)"] + CTX --> E["Tick-scoped Boundary Queues"] B --> C["GasChannel[] (SoA Gas Data)"] B --> D["VoxelRoomMap (Topology)"] - A --> E["BoundaryFlowEvent Queue"] - A --> F["PrecipitationEvent Buffer"] - G["AtmosConfig (Tuning)"] --> A + G["AtmosConfig (Live Tuning)"] --> CTX H["GasProperties Registry"] --> G I["RoomNode (Macro Layer)"] -.-> B J["GasAccumulator"] -.-> I @@ -99,9 +103,10 @@ The dangerous package must be referenced separately and imported through `Numos. solvers are stack-scoped callbacks over live chunk arrays and gas-channel spans; they are responsible for maintaining cache, topology, and revision invariants after raw writes. -The kernel hooks used by this package live in `AtmosKernel.Dangerous.cs`, keeping them distinct from the internal -operations that back the supported facade. `AtmosKernel`, `AtmosChunk`, and gas-channel representations remain -internal CLR types and are never returned directly from either package. +The dangerous package translates internal state into callback-scoped `ref struct` views. It does not add raw-access +members to `AtmosKernel`; lifecycle and tick orchestration therefore remain separate from the opt-in integration +surface. `AtmosKernel`, `AtmosChunk`, and gas-channel representations remain internal CLR types and are never +returned directly from either package. --- @@ -336,8 +341,10 @@ Each frame: ### 4.2 Solver Pipeline -Each tick captures one chunk/configuration snapshot, increments the tick counter, and executes the ordered -`simulation.Solvers` pipeline. Its default stages are: +`AtmosKernel` owns chunk lifecycle, tick state, and pipeline execution. Physics is implemented by focused components +under `Numos.CoreSim.Solvers`; the kernel does not contain advection, boundary-flow, thermodynamics, or phase-change +algorithms. Each tick captures one chunk/configuration snapshot, increments the tick counter, constructs a fresh +execution context, and executes the ordered `simulation.Solvers` pipeline. Its default stages are: 1. `advection` 2. `boundary-flow` @@ -360,8 +367,25 @@ simulation.Solvers.RegisterAfter(AtmosBuiltInSolvers.Advection, "game-reactions" simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Thermodynamics, false); ``` -Pipeline edits made by a callback take effect on the next tick. Transient boundary-event queues are cleared at the -start of every tick, so disabling a consumer stage cannot replay stale events when it is later re-enabled. +Pipeline edits made by a callback take effect on the next tick. Gas and thermal boundary events are stored in the +per-tick execution context, so disabling a consumer stage cannot replay stale events when it is later re-enabled. + +Solver-specific settings should remain with the solver instead of expanding `AtmosConfig` with unrelated game +configuration. A delegate naturally captures a dedicated configuration object: + +```csharp +var reactionConfig = new ReactionSolverConfig { Rate = 0.25f }; +var reactionSolver = new ReactionSolver(reactionConfig); + +simulation.Solvers.RegisterAfter( + AtmosBuiltInSolvers.Advection, + "game-reactions", + reactionSolver.Solve); +``` + +`AtmosSolverContext.Config` still exposes the simulation-wide physical configuration for stages that need it. The +same ownership pattern applies to dangerous solvers; only state access, not configuration ownership, determines +which package a custom stage belongs in. Solvers that have a measured need to avoid snapshot copies can opt into live storage through the separate dangerous package: @@ -387,7 +411,7 @@ This is the core fluid dynamics step. It runs in parallel across chunks. **For each awake chunk:** -1. **Recalculate pressure and heat capacity**: For every active voxel, `TotalPressure[i] = TotalMoles[i] * R * effectiveTemperature[i] / VoxelVolume`. `effectiveTemperature` is the stored temperature when it is finite and positive, otherwise the normalized `DefaultTemperatureFallback`. The kernel also caches `TotalHeatCapacity[i] = sum(moles[g] * c_effective[g])` for energy calculations. +1. **Recalculate pressure and heat capacity**: For every active voxel, `TotalPressure[i] = TotalMoles[i] * R * effectiveTemperature[i] / VoxelVolume`. `effectiveTemperature` is the stored temperature when it is finite and positive, otherwise the normalized `DefaultTemperatureFallback`. The advection stage also caches `TotalHeatCapacity[i] = sum(moles[g] * c_effective[g])` for energy calculations. 2. **Compute flow deltas**: For every active voxel, examine each Von Neumann neighbor (±X, ±Y, ±Z — 4 neighbors for 2D chunks, 6 for 3D): - Skip solid neighbors. @@ -626,20 +650,11 @@ The temperature division is performed only when `C_after > 0`. The voxel's cache Phase-change energy generally warms the remaining gas, which raises saturation pressure and slows further condensation. Accounting for both the ideal-gas `pV` term and the condensed gas's departing sensible energy avoids assigning enthalpy directly to a constant-volume internal-energy state. -### Output: PrecipitationEvent - -Condensed gas is packaged into a `PrecipitationEvent`: +### Liquid-system integration -``` -struct PrecipitationEvent { - ushort LocalVoxelIndex; - int LiquidId; - float CondensedMoles; - float Temperature; -} -``` - -These events are written to a thread-local buffer and are intended to be consumed by a separate liquid simulation system. That liquid system is not part of this codebase. Each worker's buffer holds `VoxelCount` events for the configured chunk dimensions. Phase changes can emit one event per gas per voxel, so multiple condensable species can exceed this capacity; the simulation then throws `InvalidOperationException` rather than dropping the event. +Condensed moles are removed from the gas channel and their energy effect is applied immediately. Numos does not +currently expose a liquid state or precipitation-event output. A game that models liquids must provide that state and +coordinate it with a custom solver. --- @@ -695,13 +710,9 @@ All networking methods are stubs with comments indicating where real implementat 2. **Unidirectional flow in advection.** The advection loop only processes flow from high pressure to low (`pressureDelta > 0`). Due to the delta buffer, each voxel-pair transfer is computed from the higher-pressure side and applied after the neighbor scan. -### Capacity - -3. **Precipitation-event buffer is sized per voxel, not per gas-voxel pair.** Each worker has room for `VoxelCount` precipitation events, but phase changes can emit an event for every condensable gas in every voxel. If more than `VoxelCount` events are generated during one thermodynamics pass, the simulation throws `InvalidOperationException`. - ### Performance -4. **Per-tick chunk snapshot via `.ToArray()`.** Each tick, the simulation calls `_chunkMap.Values.ToArray()` to snapshot the chunk collection. This allocates a new array every tick. For large chunk counts at 20 Hz, this generates significant GC pressure. +3. **Per-tick chunk snapshot via `.ToArray()`.** Each tick, the simulation calls `_chunkMap.Values.ToArray()` to snapshot the chunk collection. This allocates a new array every tick. For large chunk counts at 20 Hz, this generates significant GC pressure. --- @@ -709,7 +720,8 @@ All networking methods are stubs with comments indicating where real implementat To implement this system in another engine or language, start from the core module described in this document: - `AtmosSimulation` — the supported public facade. -- `AtmosKernel` — the internal tick driver and physics implementation. +- `AtmosKernel` — the internal lifecycle and tick driver. +- `Numos.CoreSim.Solvers` — atomic physics stages and shared solver math. - `AtmosChunk` — the parameterized voxel grid. - `AtmosConfig` — all tunable parameters. - `GasChannel`, `GasProperties`, `RoomNode` — all data structures. @@ -726,7 +738,7 @@ To implement this system in another engine or language, start from the core modu | Macro-micro transition | ❌ Not provided | You must implement the logic that seeds voxel grids from `RoomNode` state on wake, and collapses back on sleep. | | GasAccumulator orchestration | ❌ Not provided | You must implement the per-source accumulator loop and dispatch `Diffuse`/`Inject` actions. | | Gas source API | ✅ API provided | Use `AddGasToVoxel` for game-side sources such as pipes, vents, and fires. | -| Liquid system | ❌ Not provided | `PrecipitationEvent` is produced but never consumed. Build a liquid simulation if needed. | +| Liquid system | ❌ Not provided | Condensation updates atmospheric state only. Build liquid state and integration if needed. | | Visualization | ❌ Not provided | Pressure, temperature, and gas composition are available per-voxel. You must build rendering (overlays, particle effects, fog). | | Networking | ❌ Snapshot only | `AtmosChunkSnapshot` is exposed, but serialization, transport, and client reconciliation are not implemented. | @@ -735,7 +747,7 @@ To implement this system in another engine or language, start from the core modu The simulation assumes parallel execution: - **Intra-chunk advection and thermodynamics** are dispatched in parallel across chunks (e.g. via a `Parallel.ForEach`-style construct). - **Gas and thermal boundary processing** is sequential and must remain so to avoid race conditions when two chunks write to each other's voxels. -- **Thread-local buffers** (`ThreadLocal`) are used for gas-boundary, thermal-boundary, and precipitation events to avoid contention. +- **Thread-local buffers** (`ThreadLocal`) are owned by the producer stages for gas- and thermal-boundary events to avoid contention. If your target platform does not support threading (e.g., single-threaded WASM), the simulation will still function correctly when run sequentially — the parallel regions have no ordering dependencies within them. diff --git a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs index f137412..0c38066 100644 --- a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs +++ b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs @@ -1,4 +1,5 @@ using Numos.CoreSim; +using Numos.CoreSim.Datatypes.Primitives; using Numos.CoreSim.Solvers; using Numos.Maths; @@ -29,7 +30,7 @@ internal AtmosDangerousSolverContext(AtmosSolverExecutionContext context) public int TickCount => _context.TickCount; /// The mutable live configuration retained by the simulation. - public AtmosConfig Config => _context.Kernel.DangerousConfiguration; + public AtmosConfig Config => _context.Configuration; /// The number of chunks in the tick snapshot. public int ChunkCount => _context.Chunks.Length; @@ -46,8 +47,14 @@ public AtmosDangerousChunk GetChunk(int index) public void InjectGasToVoxel(int chunkIndex, ushort localVoxelIndex, int gasId, float moles, float temperature) { - _context.Kernel.DangerousInjectGasDuringTick( - _context.Chunks[chunkIndex], localVoxelIndex, gasId, moles, temperature); + AtmosChunk chunk = _context.Chunks[chunkIndex]; + int roomId = chunk.VoxelRoomMap[localVoxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return; + + chunk.WakeRoom(roomId); + GasInjectionSolver.InjectDuringTick( + chunk, localVoxelIndex, gasId, moles, temperature, _context.Config); } } diff --git a/src/Numos.CoreSim/AtmosKernel.Dangerous.cs b/src/Numos.CoreSim/AtmosKernel.Dangerous.cs deleted file mode 100644 index 5e38984..0000000 --- a/src/Numos.CoreSim/AtmosKernel.Dangerous.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Numos.CoreSim.Datatypes.Primitives; -using Numos.CoreSim.Solvers; - -namespace Numos.CoreSim; - -internal sealed partial class AtmosKernel -{ - internal AtmosConfig DangerousConfiguration => _config; - - /// - /// Injects gas from a solver stage using the normalized heat-capacity and pressure values captured for - /// the current tick. - /// - internal void DangerousInjectGasDuringTick(AtmosChunk chunk, ushort localVoxelIndex, int gasId, - float moles, float temperature) - { - int roomId = chunk.VoxelRoomMap[localVoxelIndex]; - if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) - return; - - chunk.WakeRoom(roomId); - GasInjectionSolver.InjectDuringTick(chunk, localVoxelIndex, gasId, moles, temperature, _tickConfig); - } -} \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs index 5e0a7d6..eb75e5c 100644 --- a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs +++ b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Numos.CoreSim.Datatypes.Primitives; +using Numos.CoreSim.Solvers; using Numos.Maths; namespace Numos.CoreSim; @@ -59,7 +60,7 @@ internal float GetVoxelMixtureVolume( lock (_stateGate) { GetMixtureChunk(position, generation, localVoxelIndex); - return GetVoxelVolume(); + return AtmosSolverMath.GetVoxelVolume(_config); } } @@ -84,7 +85,8 @@ internal float GetVoxelMixturePressure( { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); float totalMoles = GetVoxelTotalMoles(chunk, localVoxelIndex); - return CalculatePressure(totalMoles, chunk.Temperature[localVoxelIndex]); + return AtmosSolverMath.CalculatePressure(_config, totalMoles, + chunk.Temperature[localVoxelIndex]); } } @@ -161,7 +163,7 @@ internal VoxelGasMixtureState CaptureVoxelMixture( Array.Sort(gases, static (left, right) => left.Key.CompareTo(right.Key)); return new VoxelGasMixtureState( - GetVoxelVolume(), + AtmosSolverMath.GetVoxelVolume(_config), chunk.Temperature[localVoxelIndex], gases); } @@ -265,13 +267,14 @@ internal void AddVoxelMixtureGas( localVoxelIndex, chunk.Temperature[localVoxelIndex]); float currentHeatCapacity = currentTotals.HeatCapacity; - float incomingHeatCapacity = moles * GetMolarHeatCapacityAtConstantVolume(gasId); + float incomingHeatCapacity = moles * AtmosSolverMath.GetMolarHeatCapacity(_config, gasId); float combinedHeatCapacity = currentHeatCapacity + incomingHeatCapacity; if (!float.IsFinite(combinedHeatCapacity)) throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); - float currentTemperature = GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]); - float incomingTemperature = GetEffectiveTemperature(temperature); + float currentTemperature = AtmosSolverMath.GetEffectiveTemperature( + _config, chunk.Temperature[localVoxelIndex]); + float incomingTemperature = AtmosSolverMath.GetEffectiveTemperature(_config, temperature); float mixedTemperature = combinedHeatCapacity > 0f ? currentTemperature + (incomingTemperature - currentTemperature) * incomingHeatCapacity / combinedHeatCapacity @@ -367,7 +370,7 @@ internal void ReplaceVoxelMixture( foreach (var (gasId, moles) in gases) { totalMoles += moles; - totalHeatCapacity += moles * GetMolarHeatCapacityAtConstantVolume(gasId); + totalHeatCapacity += moles * AtmosSolverMath.GetMolarHeatCapacity(_config, gasId); } Debug.Assert(float.IsFinite(totalMoles)); @@ -385,7 +388,8 @@ internal void ReplaceVoxelMixture( chunk.Temperature[localVoxelIndex] = temperature; chunk.TotalHeatCapacity[localVoxelIndex] = totalHeatCapacity; - chunk.TotalPressure[localVoxelIndex] = CalculatePressure(totalMoles, temperature); + chunk.TotalPressure[localVoxelIndex] = + AtmosSolverMath.CalculatePressure(_config, totalMoles, temperature); chunk.MarkChanged(); } } @@ -471,13 +475,13 @@ private VoxelGasMixtureTotals CalculateVoxelMixtureTotals( continue; totalMoles += moles; - totalHeatCapacity += moles * GetMolarHeatCapacityAtConstantVolume(gasId); + totalHeatCapacity += moles * AtmosSolverMath.GetMolarHeatCapacity(_config, gasId); } if (!foundOverride && overrideGasId >= 0 && overrideMoles > 0f) { totalMoles += overrideMoles; - totalHeatCapacity += overrideMoles * GetMolarHeatCapacityAtConstantVolume(overrideGasId); + totalHeatCapacity += overrideMoles * AtmosSolverMath.GetMolarHeatCapacity(_config, overrideGasId); } if (!float.IsFinite(totalMoles)) @@ -485,8 +489,7 @@ private VoxelGasMixtureTotals CalculateVoxelMixtureTotals( if (!float.IsFinite(totalHeatCapacity)) throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); - float pressure = totalMoles / GetVoxelVolume() * AtmosPhysicalConstants.MolarGasConstant * - GetEffectiveTemperature(temperature); + float pressure = AtmosSolverMath.CalculatePressure(_config, totalMoles, temperature); if (!float.IsFinite(pressure)) throw new InvalidOperationException("The mixture's pressure exceeds the supported range."); @@ -521,4 +524,4 @@ private AtmosChunk GetMixtureChunk(Int3 position, long generation, ushort localV private readonly record struct VoxelGasMixtureTotals( float HeatCapacity, float Pressure); -} +} \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosKernel.cs b/src/Numos.CoreSim/AtmosKernel.cs index a600097..85cd984 100644 --- a/src/Numos.CoreSim/AtmosKernel.cs +++ b/src/Numos.CoreSim/AtmosKernel.cs @@ -1,85 +1,40 @@ -using System.Buffers; using System.Collections.Concurrent; -using System.Diagnostics; -using Numos.CoreSim.Datatypes.Events; -using Numos.CoreSim.Datatypes.Primitives; using Numos.CoreSim.Solvers; using Numos.Maths; namespace Numos.CoreSim; /// -/// Internal Numos simulation kernel. Exposed to consumers under a safe/dangerous API. +/// Owns simulation state, serialization, and the configured solver pipeline. /// -internal sealed partial class AtmosKernel : IDisposable +internal sealed partial class AtmosKernel : IDisposable, IAtmosSolverWorld { - private readonly ThreadLocal _boundaryBufferPool; - private readonly ConcurrentQueue<(Int3 Key, BoundaryFlowEvent Evt)> _boundaryEvents = new(); - private readonly List<(Int3 Key, BoundaryFlowEvent Evt)> _orderedBoundaryEvents = []; - - // Map of GridPosition to Chunk for neighbor lookups private readonly ConcurrentDictionary _chunkMap = new(); - - // Thread-local buffers sized to maximum boundary surface area - private readonly int _maxBoundaryEvents; - private readonly ThreadLocal _precipBufferPool; private readonly object _stateGate = new(); - private readonly List _activeThermalBoundaryEdges = []; - // Boundary payloads match the float-backed voxel state so the thermal path does not switch precision. - private readonly Dictionary _thermalBoundaryEnergyDeltas = []; - private readonly HashSet _thermalBoundaryEdges = []; - private readonly ThreadLocal _thermalBoundaryBufferPool; - private readonly ConcurrentQueue<(Int3 Key, ThermalBoundaryEvent Evt)> _thermalBoundaryEvents = new(); - private readonly Dictionary _thermalBoundaryIncidentConductances = []; - private readonly List _thermalBoundaryOrderedEdges = []; - private readonly Dictionary _thermalBoundaryStates = []; - - /// - /// High-resolution timestamp ticks spent processing boundary flow since the latest elapsed-time update began. - /// - internal long LastBoundaryTicks; - - /// - /// Number of fixed simulation ticks processed since the kernel was constructed. - /// - internal int TickCount; - - private float _accumulator; - private long _chunkCollectionRevision; private readonly AtmosSolverConfigSnapshot _tickConfig = new(); private readonly AtmosSolverPipeline _solverPipeline; - /// - /// Current that this simulation runs under. - /// - /// The configuration is shared by reference with the public API facade. + private float _accumulator; + private long _chunkCollectionRevision; private AtmosConfig _config = new(); /// - /// Initializes the kernel and sizes its boundary-event buffers for the configured chunk dimensions. + /// High-resolution timestamp ticks spent processing boundary flow since the latest elapsed-time update. /// - /// The number of voxels along each chunk's local x-axis. - /// The number of voxels along each chunk's local y-axis. - /// The number of voxels along each chunk's local z-axis. + internal long LastBoundaryTicks; + + /// Number of fixed simulation ticks processed since construction. + internal int TickCount; + internal AtmosKernel( int chunkWidth = AtmosChunkConstants.DefaultWidth, int chunkHeight = AtmosChunkConstants.DefaultHeight, int chunkDepth = AtmosChunkConstants.DefaultDepth) { - TickCount = 0; - _maxBoundaryEvents = checked(2 * - (chunkWidth * chunkHeight + chunkWidth * chunkDepth + chunkHeight * chunkDepth)); - int maxPrecipitationEvents = checked(chunkWidth * chunkHeight * chunkDepth); - _boundaryBufferPool = new ThreadLocal(() => new BoundaryFlowEvent[_maxBoundaryEvents]); - _precipBufferPool = new ThreadLocal(() => new PrecipitationEvent[maxPrecipitationEvents]); - _thermalBoundaryBufferPool = - new ThreadLocal(() => new ThermalBoundaryEvent[_maxBoundaryEvents]); - _solverPipeline = new AtmosSolverPipeline(CreateDefaultSolverSteps); + var defaultSolvers = new DefaultAtmosSolvers(chunkWidth, chunkHeight, chunkDepth); + _solverPipeline = new AtmosSolverPipeline(defaultSolvers.CreateSteps, defaultSolvers); } - /// - /// Releases every registered chunk and the kernel's worker-local event buffers. - /// public void Dispose() { lock (_stateGate) @@ -88,9 +43,7 @@ public void Dispose() chunk.Release(); _chunkMap.Clear(); - _boundaryBufferPool.Dispose(); - _precipBufferPool.Dispose(); - _thermalBoundaryBufferPool.Dispose(); + _solverPipeline.Dispose(); } } @@ -99,1245 +52,23 @@ private void TickSimulation(AtmosChunk[] chunks) _tickConfig.Capture(_config); TickCount++; - // Producer and consumer stages may be independently disabled. Never carry their transient events into a - // later tick when the consumer is re-enabled. - while (_boundaryEvents.TryDequeue(out _)) - { - } - while (_thermalBoundaryEvents.TryDequeue(out _)) - { - } - - // A revision is advanced once per processed tick. Conditional snapshot consumers can - // consequently retain sleeping chunks without copying their arrays again. foreach (var chunk in chunks) { if (chunk.IsAwake) chunk.MarkChanged(); } - _solverPipeline.Execute(new AtmosSolverExecutionContext(this, chunks)); + var context = new AtmosSolverExecutionContext(this, chunks, _tickConfig, _config, TickCount); + _solverPipeline.Execute(context); } - private static SolverStep[] CreateDefaultSolverSteps() + bool IAtmosSolverWorld.TryGetChunk(Int3 position, out AtmosChunk chunk) { - IAtmosSolver advection = new AdvectionSolver(); - IAtmosSolver boundaryFlow = new BoundaryFlowSolver(); - IAtmosSolver thermodynamics = new ThermodynamicsSolver(); - IAtmosSolver thermalBoundary = new ThermalBoundarySolver(); - return - [ - new SolverStep(AtmosSolverStageNames.Advection, SolverStepKind.BuiltIn, advection.Solve), - new SolverStep(AtmosSolverStageNames.BoundaryFlow, SolverStepKind.BuiltIn, boundaryFlow.Solve), - new SolverStep(AtmosSolverStageNames.Thermodynamics, SolverStepKind.BuiltIn, thermodynamics.Solve), - new SolverStep(AtmosSolverStageNames.ThermalBoundary, SolverStepKind.BuiltIn, thermalBoundary.Solve) - ]; + return _chunkMap.TryGetValue(position, out chunk!); } - internal void SolveAdvection(AtmosChunk[] chunks) + void IAtmosSolverWorld.AddBoundaryProcessingTicks(long elapsedTicks) { - Parallel.ForEach(chunks, chunk => - { - if (!chunk.IsAwake) - return; - - var localBoundaryBuffer = _boundaryBufferPool.Value; - var boundaryCount = 0; - - Debug.Assert(localBoundaryBuffer != null, nameof(localBoundaryBuffer) + " != null"); - Advect(chunk, localBoundaryBuffer, ref boundaryCount); - - for (var i = 0; i < boundaryCount; i++) - { - _boundaryEvents.Enqueue((chunk.GridPosition, localBoundaryBuffer[i])); - } - }); + LastBoundaryTicks += elapsedTicks; } - - internal void SolveBoundaryFlow() - { - long boundaryFlowStart = Stopwatch.GetTimestamp(); - _orderedBoundaryEvents.Clear(); - while (_boundaryEvents.TryDequeue(out var boundaryEvent)) - _orderedBoundaryEvents.Add(boundaryEvent); - _orderedBoundaryEvents.Sort(CompareBoundaryEvents); - foreach (var (key, evt) in _orderedBoundaryEvents) - { - ProcessBoundaryFlow(key, evt); - } - - LastBoundaryTicks += Stopwatch.GetTimestamp() - boundaryFlowStart; - } - - internal void SolveThermodynamics(AtmosChunk[] chunks) - { - if (TickCount % AtmosSolverConstants.ThermodynamicsTickInterval != 0) - return; - - Parallel.ForEach(chunks, chunk => - { - if (!chunk.IsAwake) - return; - - var localPrecipBuffer = _precipBufferPool.Value; - var precipCount = 0; - - var localThermalBuffer = _thermalBoundaryBufferPool.Value; - var thermalBoundaryCount = 0; - - Debug.Assert(localPrecipBuffer != null, nameof(localPrecipBuffer) + " != null"); - Debug.Assert(localThermalBuffer != null, nameof(localThermalBuffer) + " != null"); - ProcessThermodynamics(chunk, localPrecipBuffer, ref precipCount, localThermalBuffer, - ref thermalBoundaryCount); - - for (var i = 0; i < thermalBoundaryCount; i++) - _thermalBoundaryEvents.Enqueue((chunk.GridPosition, localThermalBuffer[i])); - }); - } - - internal void SolveThermalBoundary() - { - if (TickCount % AtmosSolverConstants.ThermodynamicsTickInterval != 0) - return; - - ProcessThermalBoundaryFlows(_thermalBoundaryEvents); - } - - /// - /// Processes the flow of gas across the boundary of a - /// chunk based on the provided . - /// - /// The grid position of the source chunk. - /// - /// The boundary flow event containing the local voxel index. - /// - private void ProcessBoundaryFlow(Int3 sourceKey, BoundaryFlowEvent evt) - { - if (!_chunkMap.TryGetValue(sourceKey, out var sourceChunk)) - return; - var localPosition = sourceChunk.GetXyzInt3(evt.LocalVoxelIndex); - - - TryFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.NegX, Int3.NegX); - TryFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.PosX, Int3.PosX); - TryFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.NegY, Int3.NegY); - TryFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.PosY, Int3.PosY); - - // Working in the Z plane. - if (sourceChunk.Depth > 1) - { - TryFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.NegZ, Int3.NegZ); - TryFlowToNeighbor(sourceChunk, sourceKey, localPosition + Int3.PosZ, Int3.PosZ); - } - } - - /// - /// Attempts to flow gas from a source chunk to a neighboring - /// chunk based on the provided direction and target - /// coordinates. - /// - /// The source chunk from which gas is flowing. - /// The grid position of the source chunk. - /// The target voxel coordinates in the source chunk. - /// The direction to the neighboring chunk. - private void TryFlowToNeighbor(AtmosChunk sourceChunk, Int3 sourceKey, - Int3 targetPosition, Int3 direction) - { - // Back out if we're not out of bounds of our own chunk, as this is not a boundary flow. - if (targetPosition.IsWithin(default, sourceChunk.Dimensions)) - return; - - // Offset the source key by the direction to get the neighbor chunk's grid position. - var neighborPos = sourceKey + direction; - - if (!_chunkMap.TryGetValue(neighborPos, out var neighborChunk)) - return; - - // Calculate the local voxel index in the neighbor chunk, wrapping around if necessary. - var neighborDimensions = neighborChunk.Dimensions; - var neighborLocalPosition = (targetPosition + neighborDimensions) % neighborDimensions; - ushort neighborIdx = neighborChunk.GetIndex(neighborLocalPosition); - - // If we're up against a solid wall in the neighbor chunk then oh well. - if (neighborChunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomSolid) - return; - - // Calculate the source voxel index in the source chunk, which is the voxel adjacent to the neighbor. - var sourceLocalPosition = targetPosition - direction; - ushort srcIdx = sourceChunk.GetIndex(sourceLocalPosition); - - float sourcePressure = sourceChunk.TotalPressure[srcIdx]; - var neighborPressure = 0f; - - // TODO remove code dupe with CheckNeighborAdvect, - // but this is a special case for boundary flow where we don't have the neighbor's pressure pre-calculated. - if (neighborChunk.VoxelRoomMap[neighborIdx] != VoxelClassification.RoomVoid) - { - neighborPressure = neighborChunk.TotalPressure[neighborIdx]; - } - - float pressureDelta = sourcePressure - neighborPressure; - float bulkPressureTransfer = pressureDelta > 0f - ? CalculateBulkPressureTransfer(pressureDelta, sourcePressure) - : 0f; - - // Species diffusion is independent of the total-pressure gradient and may counterflow against advection. - // Cross-chunk advection intentionally uses the same pressure-transfer limiter as intra-chunk advection. - var totalMoles = 0f; - for (var g = 0; g < sourceChunk.ActiveGasCount; g++) - totalMoles += sourceChunk.ActiveGases[g].Moles[srcIdx]; - - if (totalMoles > 0) - { - float temp = _tickConfig.GetEffectiveTemperature(sourceChunk.Temperature[srcIdx]); - float invTemp = 1f / temp; - float advectedMoles = TickPressureToMoles(bulkPressureTransfer, temp); - - bool isVoid = neighborChunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomVoid; - float neighborTemp = isVoid - ? 0f - : _tickConfig.GetEffectiveTemperature(neighborChunk.Temperature[neighborIdx]); - float tempRatio = neighborTemp * invTemp; - - var movedGas = false; - - for (var g = 0; g < sourceChunk.ActiveGasCount; g++) - { - int gasId = sourceChunk.ActiveGases[g].GasId; - float moles = sourceChunk.ActiveGases[g].Moles[srcIdx]; - float moleFraction = moles / totalMoles; - - // 1. Bulk Flow (Advection) - float molesAdvected = advectedMoles * moleFraction; - - // 2. Fickian Partial Pressure Diffusion - var neighborMoles = 0f; - if (!isVoid) - { - for (var ng = 0; ng < neighborChunk.ActiveGasCount; ng++) - { - if (neighborChunk.ActiveGases[ng].GasId == gasId) - { - neighborMoles = neighborChunk.ActiveGases[ng].Moles[neighborIdx]; - break; - } - } - } - - float diffusionCoeff = _tickConfig.GetDiffusionCoefficient(gasId); - var molesDiffused = 0f; - if (diffusionCoeff > 0) - { - float deltaN = moles - neighborMoles * tempRatio; - if (deltaN > 0) - { - molesDiffused = deltaN * diffusionCoeff; - } - } - - float totalMolesToMove = molesAdvected + molesDiffused; - if (totalMolesToMove > moles) - totalMolesToMove = moles; - if (totalMolesToMove <= 0f) - continue; - - float molarHeatCapacityAtConstantVolume = - _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); - float heatCapacityTransferred = totalMolesToMove * molarHeatCapacityAtConstantVolume; - - sourceChunk.ActiveGases[g].Moles[srcIdx] -= totalMolesToMove; - if (sourceChunk.ActiveGases[g].Moles[srcIdx] < 0) - sourceChunk.ActiveGases[g].Moles[srcIdx] = 0; - sourceChunk.TotalHeatCapacity[srcIdx] = - MathF.Max(0f, sourceChunk.TotalHeatCapacity[srcIdx] - heatCapacityTransferred); - movedGas = true; - - if (!isVoid) - { - if (!neighborChunk.IsAwake) - neighborChunk.WakeRoom(neighborChunk.VoxelRoomMap[neighborIdx]); - GasInjectionSolver.InjectDuringTick(neighborChunk, neighborIdx, gasId, totalMolesToMove, - temp, _tickConfig); - } - } - - if (movedGas) - { - var remainingMoles = 0f; - for (var g = 0; g < sourceChunk.ActiveGasCount; g++) - remainingMoles += sourceChunk.ActiveGases[g].Moles[srcIdx]; - - if (sourceChunk.TotalHeatCapacity[srcIdx] > 0f) - sourceChunk.Temperature[srcIdx] = temp; - sourceChunk.TotalPressure[srcIdx] = CalculateTickPressure(remainingMoles, temp); - } - } - } - - /// - /// Performs pressure advection and Fickian diffusion for a given chunk. - /// - /// The chunk to process. - /// - /// A buffer to store boundary flow events. - /// If a boundary event happens, it is queued to be run sequentially in a later processing stage. - /// - /// The count of boundary events generated during processing. - private void Advect(AtmosChunk chunk, BoundaryFlowEvent[] boundaryBuffer, ref int boundaryEventCount) - { - if (!chunk.IsAwake) - return; - - // Used for determining whether to sleep/tick the sleep timer. - var maxPressureDelta = 0f; - - if (chunk.ActiveGasCount > 0) - { - // Refresh total-pressure and total-heat-capacity caches for active voxels. - CalculateTotalPressure(chunk); - CalculateHeatCapacity(chunk); - - int activeGasCount = chunk.ActiveGasCount; - - // Layout: energy deltas occupy [0, VoxelCount); gas g mole deltas occupy - // [(g + 1) * VoxelCount, (g + 2) * VoxelCount). - int activeGasVoxelCount = GetDeltaArrayOffset(activeGasCount, chunk.VoxelCount); - float[] deltas = ArrayPool.Shared.Rent(activeGasVoxelCount); - Array.Clear(deltas, 0, activeGasVoxelCount); - int gasVoxelCount = activeGasCount * chunk.VoxelCount; - // Signed deltas hide gross depletion when several neighbors read the same snapshot. - // Track scheduled gross outflow per gas and voxel in a separate rented buffer. - float[] scheduledOutflows = ArrayPool.Shared.Rent(gasVoxelCount); - Array.Clear(scheduledOutflows, 0, gasVoxelCount); - - float vacuumThreshold = _tickConfig.VacuumThreshold; - - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - var localPosition = chunk.GetXyzInt3(idx); - - float currentPressure = chunk.TotalPressure[idx]; - - // If the current pressure is below the vacuum threshold, - // we can skip processing this voxel and set all gas moles to zero. - if (currentPressure < vacuumThreshold) - { - for (var g = 0; g < activeGasCount; g++) - { - chunk.ActiveGases[g].Moles[idx] = 0f; - } - - chunk.TotalPressure[idx] = 0f; - chunk.TotalHeatCapacity[idx] = 0f; - continue; - } - - // Calculate the total moles of gas in the voxel. - // Skip processing if there are no moles present. - var totalMoles = 0f; - for (var g = 0; g < activeGasCount; g++) - totalMoles += chunk.ActiveGases[g].Moles[idx]; - if (totalMoles <= 0) - continue; - - // Inline Neighbor Checks (4 Directions for 2D, 6 Directions for 3D) - CheckNeighborAdvect(chunk, localPosition + Int3.NegX, idx, currentPressure, totalMoles, - ref maxPressureDelta, deltas, scheduledOutflows); - CheckNeighborAdvect(chunk, localPosition + Int3.PosX, idx, currentPressure, totalMoles, - ref maxPressureDelta, deltas, scheduledOutflows); - CheckNeighborAdvect(chunk, localPosition + Int3.NegY, idx, currentPressure, totalMoles, - ref maxPressureDelta, deltas, scheduledOutflows); - CheckNeighborAdvect(chunk, localPosition + Int3.PosY, idx, currentPressure, totalMoles, - ref maxPressureDelta, deltas, scheduledOutflows); - - // Working in the Z plane. - if (chunk.Depth > 1) - { - CheckNeighborAdvect(chunk, localPosition + Int3.NegZ, idx, currentPressure, totalMoles, - ref maxPressureDelta, deltas, scheduledOutflows); - CheckNeighborAdvect(chunk, localPosition + Int3.PosZ, idx, currentPressure, totalMoles, - ref maxPressureDelta, deltas, scheduledOutflows); - } - - // Emit only gas-bearing boundary voxels that survive this tick's vacuum cleanup. - if (currentPressure >= vacuumThreshold && currentPressure > 0f && - (localPosition.X == 0 || - localPosition.X == chunk.Width - 1 || - localPosition.Y == 0 || - localPosition.Y == chunk.Height - 1 || - chunk.Depth > 1 && (localPosition.Z == 0 || localPosition.Z == chunk.Depth - 1))) - { - if (boundaryEventCount >= boundaryBuffer.Length) - throw new InvalidOperationException("Boundary flow event buffer capacity was exceeded."); - - // Queue a boundary flow event for sequential processing later. - boundaryBuffer[boundaryEventCount] = new BoundaryFlowEvent - { - LocalVoxelIndex = idx - }; - boundaryEventCount++; - } - } - - ApplyDeltas(chunk, deltas); - ArrayPool.Shared.Return(scheduledOutflows); - } - - float sleepEpsilon = _tickConfig.SleepEpsilon; - int sleepThreshold = _tickConfig.SleepThreshold; - - if (maxPressureDelta < sleepEpsilon) - { - chunk.SleepTimer++; - if (chunk.SleepTimer > sleepThreshold) - { - chunk.Sleep(); - } - } - else - { - chunk.SleepTimer = 0; - } - } - - /// - /// Checks a Von Neumann neighbor voxel for advection and diffusion, updating deltas accordingly. - /// - /// The chunk being processed. - /// The local coordinates of the neighbor voxel. - /// The index of the current voxel in the chunk. - /// The total pressure of the current voxel. - /// The total moles of gas in the current voxel. - /// - /// A reference to the maximum pressure delta observed so far, - /// updated if this neighbor has a larger delta. - /// - /// - /// Buffered energy and mole deltas. The first entries are per-voxel - /// energy deltas; gas g mole deltas begin at (g + 1) * VoxelCount. - /// - /// - /// Gross moles already scheduled to leave each gas and voxel during this buffered pass, stored in - /// gas-major order at gasIndex * VoxelCount + voxelIndex. - /// - private void CheckNeighborAdvect(AtmosChunk chunk, Int3 neighborPosition, ushort idx, - float currentPressure, - float totalMoles, // TODO Investigate, there used to be a flowfriction param but it was unused. Might be sussus amogus. - ref float maxPressureDelta, float[] deltas, float[] scheduledOutflows) - { - // Skip if the neighbor coordinates are out of bounds of the chunk. - if (!neighborPosition.IsWithin(default, chunk.Dimensions)) - return; - - // TODO PERF do offsets based on bumping index instead of offsetting a vector3 and doing a lookup. - ushort neighborIdx = chunk.GetIndex(neighborPosition); - int neighborRoom = chunk.VoxelRoomMap[neighborIdx]; - - // Back out if the neighbor voxel is solid, as we cannot flow into it. - if (neighborRoom == VoxelClassification.RoomSolid) - return; - - var neighborPressure = 0f; - bool isVoid = neighborRoom == VoxelClassification.RoomVoid; - - if (!isVoid) - { - // Write into the neighbor pressure if the neighbor is not void. - neighborPressure = chunk.TotalPressure[neighborIdx]; - } - - float pressureDelta = currentPressure - neighborPressure; - - float absDelta = pressureDelta > 0 ? pressureDelta : -pressureDelta; // TODO PERF MathF.Abs trollhaps - // Update max observed pressure if necessary. - if (absDelta > maxPressureDelta) - maxPressureDelta = absDelta; - - float bulkPressureTransfer = pressureDelta > 0f - ? CalculateBulkPressureTransfer(pressureDelta, currentPressure) - : 0f; - - // Species diffusion is independent of the total-pressure gradient and may counterflow against advection. - // Pre-calculate factors to eliminate division in the species loop. - float temp = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); - float invTemp = 1f / temp; - - float advectedMoles = TickPressureToMoles(bulkPressureTransfer, temp); - float neighborTemp = isVoid - ? 0f - : _tickConfig.GetEffectiveTemperature(chunk.Temperature[neighborIdx]); - float tempRatio = neighborTemp * invTemp; - - for (var g = 0; g < chunk.ActiveGasCount; g++) - { - int gasId = chunk.ActiveGases[g].GasId; - float moles = chunk.ActiveGases[g].Moles[idx]; - float moleFraction = moles / totalMoles; - - // 1. Bulk Flow (Advection) - float molesAdvected = advectedMoles * moleFraction; - - // 2. Vectorized Fickian Partial Pressure Diffusion - float neighborMoles = isVoid ? 0f : chunk.ActiveGases[g].Moles[neighborIdx]; - - float diffusionCoeff = _tickConfig.GetDiffusionCoefficient(gasId); - - var molesDiffused = 0f; - if (diffusionCoeff > 0) - { - // Mathematically identical to J = D * (P1 - P2) / T1 = D * (n1 - n2 * T2 / T1) - float deltaN = moles - neighborMoles * tempRatio; - if (deltaN > 0) - { - molesDiffused = deltaN * diffusionCoeff; - } - } - - float totalMolesToMove = molesAdvected + molesDiffused; - int outflowOffset = g * chunk.VoxelCount + idx; - float remainingMoles = MathF.Max(0f, moles - scheduledOutflows[outflowOffset]); - if (totalMolesToMove > remainingMoles) - totalMolesToMove = remainingMoles; - if (totalMolesToMove <= 0f) - continue; - - scheduledOutflows[outflowOffset] += totalMolesToMove; - float molarHeatCapacityAtConstantVolume = - _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); - float energyTransferred = totalMolesToMove * molarHeatCapacityAtConstantVolume * temp; - - // Update the deltas for the current voxel and the neighbor voxel. - int offset = GetDeltaArrayOffset(g, chunk.VoxelCount); - deltas[offset + idx] -= totalMolesToMove; - deltas[idx] -= energyTransferred; - - if (!isVoid) - { - // If the neighbor is not void, we can safely add the moles to move to the neighbor's delta. - deltas[offset + neighborIdx] += totalMolesToMove; - deltas[neighborIdx] += energyTransferred; - } - } - } - - /// - /// Calculates the total pressure for each voxel in the chunk - /// and caches it in the array. - /// - /// The chunk in question: - private void CalculateTotalPressure(AtmosChunk chunk) - { - // TODO SIMD - chunk.TotalPressure.Clear(); - - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - - var molesInVoxel = 0f; - for (var g = 0; g < chunk.ActiveGasCount; g++) - { - molesInVoxel += chunk.ActiveGases[g].Moles[idx]; - } - - float temp = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); - - chunk.TotalPressure[idx] = CalculateTickPressure(molesInVoxel, temp); - } - } - - private void CalculateHeatCapacity(AtmosChunk chunk) - { - chunk.TotalHeatCapacity.Clear(); - for (var g = 0; g < chunk.ActiveGasCount; g++) - { - int gasId = chunk.ActiveGases[g].GasId; - float molarHeatCapacityAtConstantVolume = - _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - if (chunk.ActiveGases[g].Moles[idx] <= 0) - continue; - chunk.TotalHeatCapacity[idx] += molarHeatCapacityAtConstantVolume * chunk.ActiveGases[g].Moles[idx]; - } - } - } - - private float GetMolarHeatCapacityAtConstantVolume(int gasId) - { - float fallbackMolarHeatCapacityAtConstantVolume = _config.DefaultMolarHeatCapacityAtConstantVolume; - if (!IsFinitePositive(fallbackMolarHeatCapacityAtConstantVolume)) - fallbackMolarHeatCapacityAtConstantVolume = - AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; - - var gasRegistry = _config.GasRegistry; - if ((uint)gasId < (uint)gasRegistry.Count) - { - float molarHeatCapacityAtConstantVolume = gasRegistry[gasId].MolarHeatCapacityAtConstantVolume; - if (IsFinitePositive(molarHeatCapacityAtConstantVolume)) - return molarHeatCapacityAtConstantVolume; - } - - return fallbackMolarHeatCapacityAtConstantVolume; - } - - private float GetVoxelVolume() - { - float volume = _config.VoxelVolume; - return IsFinitePositive(volume) ? volume : AtmosConfigDefaults.VoxelVolume; - } - - private float GetPressurePerMoleKelvin() - { - return AtmosPhysicalConstants.MolarGasConstant / GetVoxelVolume(); - } - - private float CalculatePressure(float moles, float temperature) - { - return MathF.Max(0f, moles) * GetEffectiveTemperature(temperature) * - GetPressurePerMoleKelvin(); - } - - private float CalculateTickPressure(float moles, float temperature) - { - return MathF.Max(0f, moles) * _tickConfig.GetEffectiveTemperature(temperature) * - _tickConfig.PressurePerMoleKelvin; - } - - private float TickPressureToMoles(float pressure, float temperature) - { - if (!IsFinitePositive(pressure)) - return 0f; - - float denominator = _tickConfig.PressurePerMoleKelvin * - _tickConfig.GetEffectiveTemperature(temperature); - return pressure / denominator; - } - - private float CalculatePressureAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) - { - var totalMoles = 0f; - for (var g = 0; g < chunk.ActiveGasCount; g++) - totalMoles += MathF.Max(0f, chunk.ActiveGases[g].Moles[localVoxelIndex]); - - return CalculateTickPressure(totalMoles, chunk.Temperature[localVoxelIndex]); - } - - private float GetEffectiveTemperature(float storedTemperature) - { - if (IsFinitePositive(storedTemperature)) - return storedTemperature; - - float fallback = _config.DefaultTemperatureFallback; - return IsFinitePositive(fallback) ? fallback : AtmosConfigDefaults.DefaultTemperatureFallback; - } - - private float CalculateTickHeatCapacityAtVoxel(AtmosChunk chunk, ushort localVoxelIndex) - { - var totalHeatCapacity = 0f; - for (var g = 0; g < chunk.ActiveGasCount; g++) - { - float moles = chunk.ActiveGases[g].Moles[localVoxelIndex]; - if (moles <= 0f) - continue; - - totalHeatCapacity += moles * - _tickConfig.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[g].GasId); - } - - return totalHeatCapacity; - } - - /// - /// Applies buffered energy and mole deltas, then refreshes active-voxel temperature, heat-capacity, - /// and pressure state. - /// - /// The chunk to write deltas to. - /// - /// The buffer whose first entries are per-voxel energy deltas and - /// whose gas g mole deltas begin at (g + 1) * VoxelCount. - /// - private void ApplyDeltas(AtmosChunk chunk, float[] deltas) - { - // TODO PERF SIMD - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - float energyTemperature = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); - float oldEnergy = energyTemperature * chunk.TotalHeatCapacity[idx]; - bool stateChanged = deltas[idx] != 0f; - chunk.TotalHeatCapacity[idx] = 0; - var totalMoles = 0f; - for (var g = 0; g < chunk.ActiveGasCount; g++) - { - int offset = GetDeltaArrayOffset(g, chunk.VoxelCount); - float moleDelta = deltas[offset + idx]; - stateChanged |= moleDelta != 0f; - chunk.ActiveGases[g].Moles[idx] += moleDelta; - if (chunk.ActiveGases[g].Moles[idx] < AtmosSolverConstants.MinimumTrackedMoles) - chunk.ActiveGases[g].Moles[idx] = 0f; - - int gasId = chunk.ActiveGases[g].GasId; - float molarHeatCapacityAtConstantVolume = - _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); - chunk.TotalHeatCapacity[idx] += molarHeatCapacityAtConstantVolume * chunk.ActiveGases[g].Moles[idx]; - totalMoles += chunk.ActiveGases[g].Moles[idx]; - } - - if (stateChanged && chunk.TotalHeatCapacity[idx] > 0f) - { - float newTemperature = (oldEnergy + deltas[idx]) / chunk.TotalHeatCapacity[idx]; - chunk.Temperature[idx] = MathF.Max(0f, newTemperature); - } - - chunk.TotalPressure[idx] = CalculateTickPressure(totalMoles, chunk.Temperature[idx]); - } - - ArrayPool.Shared.Return(deltas); // TODO PERF but what if..... this was threadlocal...... - } - - /// - /// Processes thermodynamic effects in the chunk, including thermal diffusion and phase changes - /// (condensation/precipitation). - /// - /// The chunk to process. - /// - /// A buffer to store precipitation events. If a condensation event occurs, it is queued to be - /// run sequentially in a later processing stage. - /// - /// The count of precipitation events generated during processing. - /// - /// A buffer to store thermal boundary events. If a thermal boundary event occurs, it - /// is queued to be run sequentially in a later processing stage. - /// - /// The count of thermal boundary events generated during processing. - private void ProcessThermodynamics(AtmosChunk chunk, PrecipitationEvent[] precipBuffer, ref int precipCount, - ThermalBoundaryEvent[] thermalBoundaryBuffer, ref int thermalBoundaryCount) - { - // It's genius. - if (!chunk.IsAwake || chunk.ActiveGasCount == 0) - return; - - ProcessThermalDiffusion(chunk, thermalBoundaryBuffer, ref thermalBoundaryCount); - ProcessPhaseChanges(chunk, precipBuffer, ref precipCount); - } - - /// - /// Processes thermal diffusion in the chunk, updating temperatures based on neighboring voxels. - /// - /// The chunk to process. - /// - /// A buffer to store thermal boundary events. - /// If a thermal boundary event occurs, it is queued to be run sequentially in a later processing stage. - /// - /// The count of thermal boundary events generated during processing. - private void ProcessThermalDiffusion(AtmosChunk chunk, ThermalBoundaryEvent[] thermalBoundaryBuffer, - ref int thermalBoundaryCount) - { - float thermalConductance = _tickConfig.ThermalConductance; - float vacuumThreshold = _tickConfig.VacuumThreshold; - if (thermalConductance <= 0f) - return; - - // Keep per-voxel workspace and arithmetic at the same precision as the SoA state. - float[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); - float[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); - Array.Clear(incidentConductances, 0, chunk.VoxelCount); - Array.Clear(energyDeltas, 0, chunk.VoxelCount); - - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - if (chunk.TotalHeatCapacity[idx] <= 0f || chunk.TotalPressure[idx] < vacuumThreshold) - continue; - - var localPosition = chunk.GetXyzInt3(idx); - // Enumerating only positive axes visits each undirected edge exactly once. - AccumulateThermalConductance(chunk, localPosition + Int3.PosX, idx, thermalConductance, - vacuumThreshold, incidentConductances); - AccumulateThermalConductance(chunk, localPosition + Int3.PosY, idx, thermalConductance, - vacuumThreshold, incidentConductances); - if (chunk.Depth > 1) - { - AccumulateThermalConductance(chunk, localPosition + Int3.PosZ, idx, thermalConductance, - vacuumThreshold, incidentConductances); - } - - // Emit thermal boundary events for edge voxels - bool isEdge = localPosition.X == 0 || localPosition.X == chunk.Width - 1 || - localPosition.Y == 0 || localPosition.Y == chunk.Height - 1 || - chunk.Depth > 1 && (localPosition.Z == 0 || localPosition.Z == chunk.Depth - 1); - if (isEdge) - { - if (thermalBoundaryCount >= thermalBoundaryBuffer.Length) - throw new InvalidOperationException("Thermal boundary event buffer capacity was exceeded."); - - thermalBoundaryBuffer[thermalBoundaryCount] = new ThermalBoundaryEvent - { - LocalVoxelIndex = idx - }; - thermalBoundaryCount++; - } - } - - // Apply all fluxes from the same temperature/capacity snapshot. The symmetric row limiter - // makes every final temperature a convex combination of the snapshot temperatures. - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - var localPosition = chunk.GetXyzInt3(idx); - - ApplyThermalFlux(chunk, localPosition + Int3.PosX, idx, thermalConductance, - vacuumThreshold, incidentConductances, energyDeltas); - ApplyThermalFlux(chunk, localPosition + Int3.PosY, idx, thermalConductance, - vacuumThreshold, incidentConductances, energyDeltas); - if (chunk.Depth > 1) - { - ApplyThermalFlux(chunk, localPosition + Int3.PosZ, idx, thermalConductance, - vacuumThreshold, incidentConductances, energyDeltas); - } - } - - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - if (energyDeltas[idx] == 0f || - !TryGetThermalState(chunk, idx, vacuumThreshold, out float oldTemperature, - out float heatCapacity)) - continue; - - // T + ΔE/C is equivalent to (C*T + ΔE)/C without an overflow-prone C*T product. - float newTemperature = MathF.Max(0f, oldTemperature + energyDeltas[idx] / heatCapacity); - chunk.Temperature[idx] = newTemperature; - chunk.TotalPressure[idx] = CalculatePressureAtVoxel(chunk, idx); - } - - ArrayPool.Shared.Return(incidentConductances); - ArrayPool.Shared.Return(energyDeltas); - } - - private void AccumulateThermalConductance(AtmosChunk chunk, Int3 neighborPosition, ushort idx, - float thermalConductance, float vacuumThreshold, float[] incidentConductances) - { - if (!neighborPosition.IsWithin(default, chunk.Dimensions)) - return; - - ushort neighborIdx = chunk.GetIndex(neighborPosition); - if (chunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomSolid) - return; - - if (!TryGetThermalState(chunk, idx, vacuumThreshold, out _, out float currentHeatCapacity) || - !TryGetThermalState(chunk, neighborIdx, vacuumThreshold, out _, out float neighborHeatCapacity)) - return; - - float conductance = CalculateThermalConductance(currentHeatCapacity, neighborHeatCapacity, - thermalConductance); - if (conductance <= 0f) - return; - - incidentConductances[idx] += conductance; - incidentConductances[neighborIdx] += conductance; - } - - private void ApplyThermalFlux(AtmosChunk chunk, Int3 neighborPosition, ushort idx, - float thermalConductance, float vacuumThreshold, float[] incidentConductances, float[] energyDeltas) - { - if (!neighborPosition.IsWithin(default, chunk.Dimensions)) - return; - - ushort neighborIdx = chunk.GetIndex(neighborPosition); - if (chunk.VoxelRoomMap[neighborIdx] == VoxelClassification.RoomSolid) - return; - - if (!TryGetThermalState(chunk, idx, vacuumThreshold, out float currentTemperature, - out float currentHeatCapacity) || - !TryGetThermalState(chunk, neighborIdx, vacuumThreshold, out float neighborTemperature, - out float neighborHeatCapacity)) - return; - - float conductance = CalculateThermalConductance(currentHeatCapacity, neighborHeatCapacity, - thermalConductance); - float currentIncidentConductance = incidentConductances[idx]; - float neighborIncidentConductance = incidentConductances[neighborIdx]; - if (conductance <= 0f || currentIncidentConductance <= 0f || neighborIncidentConductance <= 0f) - return; - - float scale = MathF.Min(1f, MathF.Min( - currentHeatCapacity / currentIncidentConductance, - neighborHeatCapacity / neighborIncidentConductance)); - float heatTransfer = scale * conductance * (currentTemperature - neighborTemperature); - if (heatTransfer == 0f) - return; - - energyDeltas[idx] -= heatTransfer; - energyDeltas[neighborIdx] += heatTransfer; - } - - private bool TryGetThermalState(AtmosChunk chunk, ushort idx, float vacuumThreshold, - out float temperature, out float heatCapacity) - { - float storedHeatCapacity = chunk.TotalHeatCapacity[idx]; - float pressure = chunk.TotalPressure[idx]; - float effectiveTemperature = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); - if (!IsFinitePositive(storedHeatCapacity) || !float.IsFinite(pressure) || - pressure < vacuumThreshold) - { - temperature = 0f; - heatCapacity = 0f; - return false; - } - - temperature = effectiveTemperature; - heatCapacity = storedHeatCapacity; - return true; - } - - private static float CalculateThermalConductance(float sourceHeatCapacity, float targetHeatCapacity, - float thermalConductance) - { - Debug.Assert(float.IsFinite(sourceHeatCapacity) && sourceHeatCapacity > 0f); - Debug.Assert(float.IsFinite(targetHeatCapacity) && targetHeatCapacity > 0f); - Debug.Assert(float.IsFinite(thermalConductance) && thermalConductance > 0f); - - // Algebraically equivalent to C1*C2/(C1+C2), but neither intermediate can exceed the smaller capacity. - float smallerHeatCapacity = MathF.Min(sourceHeatCapacity, targetHeatCapacity); - float largerHeatCapacity = MathF.Max(sourceHeatCapacity, targetHeatCapacity); - float equilibriumConductance = smallerHeatCapacity / - (1f + smallerHeatCapacity / largerHeatCapacity); - return MathF.Min(thermalConductance, equilibriumConductance); - } - - private static bool IsFinitePositive(float value) - { - return float.IsFinite(value) && value > 0f; - } - - private void ProcessPhaseChanges(AtmosChunk chunk, PrecipitationEvent[] precipBuffer, ref int precipCount) - { - float condensationRateFactor = _tickConfig.CondensationRateFactor; - if (condensationRateFactor <= 0f) - return; - float referencePressure = _tickConfig.SaturationReferencePressure; - - for (var g = 0; g < chunk.ActiveGasCount; g++) - { - int gasId = chunk.ActiveGases[g].GasId; - if (!_tickConfig.TryGetGasProperties(gasId, out var props)) - continue; - - if (props.CondensationEnabled) - { - float boilingPoint = props.BoilingPoint; - float molarEnthalpyOfVaporization = props.MolarEnthalpyOfVaporization; - float molarHeatCapacityAtConstantVolume = - _tickConfig.GetMolarHeatCapacityAtConstantVolume(gasId); - - if (!IsFinitePositive(boilingPoint) || !IsFinitePositive(molarEnthalpyOfVaporization)) - continue; - - float invBoilingPoint = 1f / boilingPoint; - - for (var i = 0; i < chunk.ActiveAirCount; i++) - { - ushort idx = chunk.ActiveAirIndices[i]; - float currentTemp = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); - float gasMoles = chunk.ActiveGases[g].Moles[idx]; - - if (gasMoles > AtmosSolverConstants.MinimumMolesForCondensation) - { - // Clausius-Clapeyron calculation of saturation vapor pressure: - // P_sat = P_ref * exp(-L * (1/T - 1/T_boiling)) - float exponent = -molarEnthalpyOfVaporization / AtmosPhysicalConstants.MolarGasConstant * - (1f / currentTemp - invBoilingPoint); - float satVaporPressure = referencePressure * MathF.Exp(exponent); - - float currentPartialPressure = CalculateTickPressure(gasMoles, currentTemp); - - if (currentPartialPressure > satVaporPressure) - { - float excessPressure = currentPartialPressure - satVaporPressure; - - float molesToCondense = TickPressureToMoles(excessPressure, currentTemp) * - condensationRateFactor; - - if (molesToCondense > gasMoles) - molesToCondense = gasMoles; - - chunk.ActiveGases[g].Moles[idx] -= molesToCondense; - - if (precipCount >= precipBuffer.Length) - { - throw new InvalidOperationException( - "Precipitation event buffer capacity was exceeded."); - } - - precipBuffer[precipCount] = new PrecipitationEvent - { - LocalVoxelIndex = idx, - LiquidId = props.LiquidId, - CondensedMoles = molesToCondense, - Temperature = currentTemp - }; - precipCount++; - - float oldHeatCapacity = chunk.TotalHeatCapacity[idx]; - float condensedHeatCapacity = molesToCondense * molarHeatCapacityAtConstantVolume; - float newHeatCapacity = MathF.Max(0f, oldHeatCapacity - condensedHeatCapacity); - float molarInternalEnergyOfVaporization = MathF.Max(0f, - molarEnthalpyOfVaporization - - AtmosPhysicalConstants.MolarGasConstant * currentTemp); - float remainingEnergy = currentTemp * oldHeatCapacity - - currentTemp * condensedHeatCapacity + - molesToCondense * molarInternalEnergyOfVaporization; - chunk.TotalHeatCapacity[idx] = newHeatCapacity; - - if (newHeatCapacity > 0f) - chunk.Temperature[idx] = MathF.Max(0f, remainingEnergy / newHeatCapacity); - - chunk.TotalPressure[idx] = CalculatePressureAtVoxel(chunk, idx); - } - } - } - } - } - } - - /// - /// Calculates the bulk-flow pressure transfer requested between two voxels. - /// - /// The difference in pressure between the source and target voxels. - /// The current pressure of the source voxel. - /// The requested pressure transfer in pascals per tick. - private float CalculateBulkPressureTransfer(float pressureDelta, float currentPressure) - { - float maximumFraction = _tickConfig.MaxPressureTransferFractionPerNeighbor; - if (maximumFraction <= 0f) - return 0f; - - float lowPressureThreshold = _tickConfig.LowPressureDeltaThreshold; - - float pressureTransfer; - // Use the configured per-neighbor fraction directly below the low-delta threshold. - // Helps with equilibrium scenarios where the pressure difference is small, and we want to avoid oscillations. - // Otherwise apply the bulk-flow coefficient and damping factor. - if (pressureDelta < lowPressureThreshold) - pressureTransfer = pressureDelta * maximumFraction; - else - pressureTransfer = pressureDelta * _tickConfig.BulkFlowCoefficient * - _tickConfig.BulkFlowDamping; - - if (pressureTransfer <= 0f || pressureTransfer < _tickConfig.MinimumPressureTransfer) - return 0f; - - // Cap the requested pressure transfer to a fraction of source pressure for this neighbor. - float maximumTransfer = currentPressure * maximumFraction; - return MathF.Min(pressureTransfer, maximumTransfer); - } - - private static int GetDeltaArrayOffset(int g, int VoxelCount) - { - return (g + 1) * VoxelCount; - } - - /// - /// Solves every cross-chunk thermal edge from one immutable boundary snapshot. - /// - /// - /// Each physical face is deduplicated, then the same symmetric row limiter used by the intra-chunk solve - /// caps aggregate conductance at each voxel. This conserves energy, prevents temperature overshoot, and - /// makes the result independent of concurrent boundary-event order. - /// - private void ProcessThermalBoundaryFlows( - ConcurrentQueue<(Int3 Key, ThermalBoundaryEvent Evt)> boundaryEvents) - { - _thermalBoundaryEdges.Clear(); - _thermalBoundaryOrderedEdges.Clear(); - _thermalBoundaryStates.Clear(); - _thermalBoundaryIncidentConductances.Clear(); - _activeThermalBoundaryEdges.Clear(); - _thermalBoundaryEnergyDeltas.Clear(); - - float thermalConductance = _tickConfig.ThermalConductance; - if (thermalConductance <= 0f) - { - while (boundaryEvents.TryDequeue(out _)) - { - } - return; - } - - while (boundaryEvents.TryDequeue(out var boundaryEvent)) - { - var (sourceKey, evt) = boundaryEvent; - CollectThermalBoundaryEdges(sourceKey, evt, _thermalBoundaryEdges); - } - - if (_thermalBoundaryEdges.Count == 0) - return; - - _thermalBoundaryOrderedEdges.AddRange(_thermalBoundaryEdges); - _thermalBoundaryOrderedEdges.Sort(CompareThermalEdges); - - float vacuumThreshold = _tickConfig.VacuumThreshold; - - foreach (var edge in _thermalBoundaryOrderedEdges) - { - if (!TryGetBoundaryThermalState(edge.First, vacuumThreshold, _thermalBoundaryStates, - out var firstState) || - !TryGetBoundaryThermalState(edge.Second, vacuumThreshold, _thermalBoundaryStates, - out var secondState)) - continue; - - float conductance = CalculateThermalConductance(firstState.HeatCapacity, - secondState.HeatCapacity, thermalConductance); - if (conductance <= 0f) - continue; - - AddToDictionary(_thermalBoundaryIncidentConductances, edge.First, conductance); - AddToDictionary(_thermalBoundaryIncidentConductances, edge.Second, conductance); - _activeThermalBoundaryEdges.Add(new ThermalBoundaryConductance(edge, conductance)); - } - - foreach (var (edge, conductance) in _activeThermalBoundaryEdges) - { - ThermalBoundaryState firstState = _thermalBoundaryStates[edge.First]; - ThermalBoundaryState secondState = _thermalBoundaryStates[edge.Second]; - float firstIncident = _thermalBoundaryIncidentConductances[edge.First]; - float secondIncident = _thermalBoundaryIncidentConductances[edge.Second]; - float scale = MathF.Min(1f, MathF.Min( - firstState.HeatCapacity / firstIncident, - secondState.HeatCapacity / secondIncident)); - float heatTransfer = scale * conductance * - (firstState.Temperature - secondState.Temperature); - if (heatTransfer == 0f) - continue; - - AddToDictionary(_thermalBoundaryEnergyDeltas, edge.First, -heatTransfer); - AddToDictionary(_thermalBoundaryEnergyDeltas, edge.Second, heatTransfer); - } - - foreach (var (address, energyDelta) in _thermalBoundaryEnergyDeltas) - { - ThermalBoundaryState state = _thermalBoundaryStates[address]; - // Avoid forming the potentially much larger intermediate C*T. - float newTemperature = state.Temperature + energyDelta / state.HeatCapacity; - if (newTemperature < 0f || !_chunkMap.TryGetValue(address.ChunkPosition, out var chunk)) - continue; - - chunk.Temperature[address.LocalVoxelIndex] = newTemperature; - chunk.TotalPressure[address.LocalVoxelIndex] = - CalculatePressureAtVoxel(chunk, address.LocalVoxelIndex); - chunk.MarkChanged(); - } - } - - private void CollectThermalBoundaryEdges(Int3 sourceKey, ThermalBoundaryEvent evt, - HashSet edges) - { - if (!_chunkMap.TryGetValue(sourceKey, out var sourceChunk)) - return; - - var localPosition = sourceChunk.GetXyzInt3(evt.LocalVoxelIndex); - TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.NegX, Int3.NegX, edges); - TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.PosX, Int3.PosX, edges); - TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.NegY, Int3.NegY, edges); - TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.PosY, Int3.PosY, edges); - if (sourceChunk.Depth > 1) - { - TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.NegZ, Int3.NegZ, edges); - TryAddThermalBoundaryEdge(sourceChunk, sourceKey, localPosition + Int3.PosZ, Int3.PosZ, edges); - } - } - - private void TryAddThermalBoundaryEdge(AtmosChunk sourceChunk, Int3 sourceKey, - Int3 targetPosition, Int3 direction, HashSet edges) - { - if (targetPosition.IsWithin(default, sourceChunk.Dimensions)) - return; - - var neighborPosition = sourceKey + direction; - if (!_chunkMap.TryGetValue(neighborPosition, out var neighborChunk)) - return; - - var neighborLocalPosition = (targetPosition + neighborChunk.Dimensions) % neighborChunk.Dimensions; - ushort neighborIndex = neighborChunk.GetIndex(neighborLocalPosition); - if (neighborChunk.VoxelRoomMap[neighborIndex] == VoxelClassification.RoomSolid) - return; - - ushort sourceIndex = sourceChunk.GetIndex(targetPosition - direction); - var source = new ThermalVoxelAddress(sourceKey, sourceIndex); - var neighbor = new ThermalVoxelAddress(neighborPosition, neighborIndex); - edges.Add(CompareThermalVoxels(source, neighbor) <= 0 - ? new ThermalBoundaryEdge(source, neighbor) - : new ThermalBoundaryEdge(neighbor, source)); - } - - private bool TryGetBoundaryThermalState(ThermalVoxelAddress address, float vacuumThreshold, - Dictionary states, out ThermalBoundaryState state) - { - if (states.TryGetValue(address, out state)) - return true; - - if (!_chunkMap.TryGetValue(address.ChunkPosition, out var chunk)) - return false; - - ushort idx = address.LocalVoxelIndex; - float pressure = CalculatePressureAtVoxel(chunk, idx); - float heatCapacity = CalculateTickHeatCapacityAtVoxel(chunk, idx); - chunk.TotalPressure[idx] = pressure; - chunk.TotalHeatCapacity[idx] = heatCapacity; - float temperature = _tickConfig.GetEffectiveTemperature(chunk.Temperature[idx]); - if (!IsFinitePositive(heatCapacity) || !float.IsFinite(pressure) || pressure < vacuumThreshold) - return false; - - state = new ThermalBoundaryState(temperature, heatCapacity); - states.Add(address, state); - return true; - } - - private static int CompareThermalVoxels(ThermalVoxelAddress left, ThermalVoxelAddress right) - { - int comparison = CompareChunkPositions(left.ChunkPosition, right.ChunkPosition); - return comparison != 0 ? comparison : left.LocalVoxelIndex.CompareTo(right.LocalVoxelIndex); - } - - private static int CompareChunkPositions(Int3 left, Int3 right) - { - int comparison = left.X.CompareTo(right.X); - if (comparison != 0) - return comparison; - comparison = left.Y.CompareTo(right.Y); - if (comparison != 0) - return comparison; - return left.Z.CompareTo(right.Z); - } - - private static int CompareBoundaryEvents( - (Int3 Key, BoundaryFlowEvent Evt) left, - (Int3 Key, BoundaryFlowEvent Evt) right) - { - int comparison = CompareChunkPositions(left.Key, right.Key); - return comparison != 0 - ? comparison - : left.Evt.LocalVoxelIndex.CompareTo(right.Evt.LocalVoxelIndex); - } - - private static int CompareThermalEdges(ThermalBoundaryEdge left, ThermalBoundaryEdge right) - { - int comparison = CompareThermalVoxels(left.First, right.First); - return comparison != 0 ? comparison : CompareThermalVoxels(left.Second, right.Second); - } - - private static void AddToDictionary(Dictionary values, - ThermalVoxelAddress address, float value) - { - values[address] = values.GetValueOrDefault(address) + value; - } - - private readonly record struct ThermalVoxelAddress(Int3 ChunkPosition, ushort LocalVoxelIndex); - private readonly record struct ThermalBoundaryEdge(ThermalVoxelAddress First, ThermalVoxelAddress Second); - private readonly record struct ThermalBoundaryConductance(ThermalBoundaryEdge Edge, float Conductance); - private readonly record struct ThermalBoundaryState(float Temperature, float HeatCapacity); -} +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Datatypes/Events/PrecipitationEvent.cs b/src/Numos.CoreSim/Datatypes/Events/PrecipitationEvent.cs deleted file mode 100644 index 1e0572e..0000000 --- a/src/Numos.CoreSim/Datatypes/Events/PrecipitationEvent.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Numos.CoreSim.Datatypes.Events; - -internal struct PrecipitationEvent -{ - /// Flat index of the voxel where condensation occurred. - public ushort LocalVoxelIndex; - - /// ID of the condensed-phase species. - public int LiquidId; - - /// Amount condensed, in moles (mol). - public float CondensedMoles; - - /// Temperature at condensation, in kelvins (K). - public float Temperature; -} diff --git a/src/Numos.CoreSim/Solvers/AdvectionSolver.cs b/src/Numos.CoreSim/Solvers/AdvectionSolver.cs new file mode 100644 index 0000000..44d5255 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/AdvectionSolver.cs @@ -0,0 +1,292 @@ +using System.Buffers; +using System.Diagnostics; +using Numos.CoreSim.Datatypes.Events; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.Maths; + +namespace Numos.CoreSim.Solvers; + +/// +/// Solves parallel intra-chunk pressure advection and per-species diffusion. +/// +internal sealed class AdvectionSolver : IAtmosSolver, IDisposable +{ + private readonly ThreadLocal _boundaryBuffers; + + internal AdvectionSolver(int maximumBoundaryEvents) + { + _boundaryBuffers = new ThreadLocal( + () => new BoundaryFlowEvent[maximumBoundaryEvents]); + } + + public void Solve(AtmosSolverExecutionContext context) + { + Parallel.ForEach(context.Chunks, chunk => SolveChunk(context, chunk)); + } + + public void Dispose() + { + _boundaryBuffers.Dispose(); + } + + private void SolveChunk(AtmosSolverExecutionContext context, AtmosChunk chunk) + { + if (!chunk.IsAwake) + return; + + BoundaryFlowEvent[]? boundaryBuffer = _boundaryBuffers.Value; + Debug.Assert(boundaryBuffer != null); + var boundaryCount = 0; + Advect(chunk, context.Config, boundaryBuffer, ref boundaryCount); + + for (var index = 0; index < boundaryCount; index++) + context.BoundaryEvents.Enqueue((chunk.GridPosition, boundaryBuffer[index])); + } + + private static void Advect(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + BoundaryFlowEvent[] boundaryBuffer, ref int boundaryEventCount) + { + var maximumPressureDelta = 0f; + if (chunk.ActiveGasCount > 0) + { + RefreshPressureAndHeatCapacity(chunk, config); + ProcessActiveVoxels(chunk, config, boundaryBuffer, ref boundaryEventCount, + ref maximumPressureDelta); + } + + UpdateSleepState(chunk, config, maximumPressureDelta); + } + + private static void ProcessActiveVoxels(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + BoundaryFlowEvent[] boundaryBuffer, ref int boundaryEventCount, ref float maximumPressureDelta) + { + int activeGasCount = chunk.ActiveGasCount; + int deltaLength = GetDeltaArrayOffset(activeGasCount, chunk.VoxelCount); + float[] deltas = ArrayPool.Shared.Rent(deltaLength); + float[] scheduledOutflows = ArrayPool.Shared.Rent(activeGasCount * chunk.VoxelCount); + Array.Clear(deltas, 0, deltaLength); + Array.Clear(scheduledOutflows, 0, activeGasCount * chunk.VoxelCount); + + try + { + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + float currentPressure = chunk.TotalPressure[voxelIndex]; + if (currentPressure < config.VacuumThreshold) + { + ClearVacuumVoxel(chunk, voxelIndex); + continue; + } + + float totalMoles = GetTotalMoles(chunk, voxelIndex); + if (totalMoles <= 0f) + continue; + + Int3 position = chunk.GetXyzInt3(voxelIndex); + ProcessNeighbors(chunk, config, position, voxelIndex, currentPressure, totalMoles, + ref maximumPressureDelta, deltas, scheduledOutflows); + TryAppendBoundaryEvent(chunk, position, voxelIndex, currentPressure, config.VacuumThreshold, + boundaryBuffer, ref boundaryEventCount); + } + + ApplyDeltas(chunk, config, deltas); + } + finally + { + ArrayPool.Shared.Return(scheduledOutflows); + ArrayPool.Shared.Return(deltas); + } + } + + private static void ProcessNeighbors(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + Int3 position, ushort voxelIndex, float currentPressure, float totalMoles, + ref float maximumPressureDelta, float[] deltas, float[] scheduledOutflows) + { + CheckNeighbor(chunk, config, position + Int3.NegX, voxelIndex, currentPressure, totalMoles, + ref maximumPressureDelta, deltas, scheduledOutflows); + CheckNeighbor(chunk, config, position + Int3.PosX, voxelIndex, currentPressure, totalMoles, + ref maximumPressureDelta, deltas, scheduledOutflows); + CheckNeighbor(chunk, config, position + Int3.NegY, voxelIndex, currentPressure, totalMoles, + ref maximumPressureDelta, deltas, scheduledOutflows); + CheckNeighbor(chunk, config, position + Int3.PosY, voxelIndex, currentPressure, totalMoles, + ref maximumPressureDelta, deltas, scheduledOutflows); + if (chunk.Depth <= 1) + return; + + CheckNeighbor(chunk, config, position + Int3.NegZ, voxelIndex, currentPressure, totalMoles, + ref maximumPressureDelta, deltas, scheduledOutflows); + CheckNeighbor(chunk, config, position + Int3.PosZ, voxelIndex, currentPressure, totalMoles, + ref maximumPressureDelta, deltas, scheduledOutflows); + } + + private static void CheckNeighbor(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + Int3 neighborPosition, ushort voxelIndex, float currentPressure, float totalMoles, + ref float maximumPressureDelta, float[] deltas, float[] scheduledOutflows) + { + if (!neighborPosition.IsWithin(default, chunk.Dimensions)) + return; + + ushort neighborIndex = chunk.GetIndex(neighborPosition); + int neighborRoom = chunk.VoxelRoomMap[neighborIndex]; + if (neighborRoom == VoxelClassification.RoomSolid) + return; + + bool isVoid = neighborRoom == VoxelClassification.RoomVoid; + float neighborPressure = isVoid ? 0f : chunk.TotalPressure[neighborIndex]; + float pressureDelta = currentPressure - neighborPressure; + maximumPressureDelta = MathF.Max(maximumPressureDelta, MathF.Abs(pressureDelta)); + + float bulkPressureTransfer = pressureDelta > 0f + ? AtmosSolverMath.CalculateBulkPressureTransfer(config, pressureDelta, currentPressure) + : 0f; + float sourceTemperature = config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]); + float advectedMoles = AtmosSolverMath.PressureToMoles(config, bulkPressureTransfer, sourceTemperature); + float neighborTemperature = isVoid + ? 0f + : config.GetEffectiveTemperature(chunk.Temperature[neighborIndex]); + float temperatureRatio = neighborTemperature / sourceTemperature; + + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + int gasId = chunk.ActiveGases[gas].GasId; + float sourceMoles = chunk.ActiveGases[gas].Moles[voxelIndex]; + float molesAdvected = advectedMoles * (sourceMoles / totalMoles); + float neighborMoles = isVoid ? 0f : chunk.ActiveGases[gas].Moles[neighborIndex]; + float moleImbalance = sourceMoles - neighborMoles * temperatureRatio; + float molesDiffused = moleImbalance > 0f + ? moleImbalance * config.GetDiffusionCoefficient(gasId) + : 0f; + + int outflowOffset = gas * chunk.VoxelCount + voxelIndex; + float remainingMoles = MathF.Max(0f, sourceMoles - scheduledOutflows[outflowOffset]); + float molesToMove = MathF.Min(remainingMoles, molesAdvected + molesDiffused); + if (molesToMove <= 0f) + continue; + + scheduledOutflows[outflowOffset] += molesToMove; + float energyTransferred = molesToMove * + config.GetMolarHeatCapacityAtConstantVolume(gasId) * + sourceTemperature; + int deltaOffset = GetDeltaArrayOffset(gas, chunk.VoxelCount); + deltas[deltaOffset + voxelIndex] -= molesToMove; + deltas[voxelIndex] -= energyTransferred; + if (isVoid) + continue; + + deltas[deltaOffset + neighborIndex] += molesToMove; + deltas[neighborIndex] += energyTransferred; + } + } + + private static void RefreshPressureAndHeatCapacity(AtmosChunk chunk, AtmosSolverConfigSnapshot config) + { + chunk.TotalPressure.Clear(); + chunk.TotalHeatCapacity.Clear(); + + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressure( + config, GetTotalMoles(chunk, voxelIndex), chunk.Temperature[voxelIndex]); + } + + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + float molarHeatCapacity = + config.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[gas].GasId); + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + float moles = chunk.ActiveGases[gas].Moles[voxelIndex]; + if (moles > 0f) + chunk.TotalHeatCapacity[voxelIndex] += molarHeatCapacity * moles; + } + } + } + + private static void ApplyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, float[] deltas) + { + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + float oldEnergy = config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]) * + chunk.TotalHeatCapacity[voxelIndex]; + bool stateChanged = deltas[voxelIndex] != 0f; + chunk.TotalHeatCapacity[voxelIndex] = 0f; + var totalMoles = 0f; + + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + int offset = GetDeltaArrayOffset(gas, chunk.VoxelCount); + float moleDelta = deltas[offset + voxelIndex]; + stateChanged |= moleDelta != 0f; + float moles = chunk.ActiveGases[gas].Moles[voxelIndex] + moleDelta; + if (moles < AtmosSolverConstants.MinimumTrackedMoles) + moles = 0f; + chunk.ActiveGases[gas].Moles[voxelIndex] = moles; + chunk.TotalHeatCapacity[voxelIndex] += moles * + config.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[gas].GasId); + totalMoles += moles; + } + + if (stateChanged && chunk.TotalHeatCapacity[voxelIndex] > 0f) + { + chunk.Temperature[voxelIndex] = MathF.Max(0f, + (oldEnergy + deltas[voxelIndex]) / chunk.TotalHeatCapacity[voxelIndex]); + } + + chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressure( + config, totalMoles, chunk.Temperature[voxelIndex]); + } + } + + private static void TryAppendBoundaryEvent(AtmosChunk chunk, Int3 position, ushort voxelIndex, + float currentPressure, float vacuumThreshold, BoundaryFlowEvent[] buffer, ref int count) + { + bool isBoundary = position.X == 0 || position.X == chunk.Width - 1 || + position.Y == 0 || position.Y == chunk.Height - 1 || + chunk.Depth > 1 && (position.Z == 0 || position.Z == chunk.Depth - 1); + if (!isBoundary || currentPressure < vacuumThreshold || currentPressure <= 0f) + return; + if (count >= buffer.Length) + throw new InvalidOperationException("Boundary flow event buffer capacity was exceeded."); + + buffer[count++] = new BoundaryFlowEvent { LocalVoxelIndex = voxelIndex }; + } + + private static void ClearVacuumVoxel(AtmosChunk chunk, ushort voxelIndex) + { + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + chunk.ActiveGases[gas].Moles[voxelIndex] = 0f; + chunk.TotalPressure[voxelIndex] = 0f; + chunk.TotalHeatCapacity[voxelIndex] = 0f; + } + + private static float GetTotalMoles(AtmosChunk chunk, ushort voxelIndex) + { + var totalMoles = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + totalMoles += chunk.ActiveGases[gas].Moles[voxelIndex]; + return totalMoles; + } + + private static void UpdateSleepState(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + float maximumPressureDelta) + { + if (maximumPressureDelta >= config.SleepEpsilon) + { + chunk.SleepTimer = 0; + return; + } + + chunk.SleepTimer++; + if (chunk.SleepTimer > config.SleepThreshold) + chunk.Sleep(); + } + + private static int GetDeltaArrayOffset(int gasIndex, int voxelCount) + { + return (gasIndex + 1) * voxelCount; + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs b/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs index 0d74649..4f719a5 100644 --- a/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs +++ b/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs @@ -1,3 +1,7 @@ +using System.Collections.Concurrent; +using Numos.CoreSim.Datatypes.Events; +using Numos.Maths; + namespace Numos.CoreSim.Solvers; /// @@ -5,13 +9,30 @@ namespace Numos.CoreSim.Solvers; /// internal sealed class AtmosSolverExecutionContext { - internal AtmosSolverExecutionContext(AtmosKernel kernel, AtmosChunk[] chunks) + internal AtmosSolverExecutionContext(IAtmosSolverWorld world, AtmosChunk[] chunks, + AtmosSolverConfigSnapshot config, AtmosConfig configuration, int tickCount) { - Kernel = kernel; + World = world; Chunks = chunks; + Config = config; + Configuration = configuration; + TickCount = tickCount; } - internal AtmosKernel Kernel { get; } + internal IAtmosSolverWorld World { get; } internal AtmosChunk[] Chunks { get; } - internal int TickCount => Kernel.TickCount; + internal AtmosSolverConfigSnapshot Config { get; } + internal AtmosConfig Configuration { get; } + internal int TickCount { get; } + internal ConcurrentQueue<(Int3 Key, BoundaryFlowEvent Event)> BoundaryEvents { get; } = new(); + internal ConcurrentQueue<(Int3 Key, ThermalBoundaryEvent Event)> ThermalBoundaryEvents { get; } = new(); +} + +/// +/// Minimal world operations needed by cross-chunk solvers. +/// +internal interface IAtmosSolverWorld +{ + bool TryGetChunk(Int3 position, out AtmosChunk chunk); + void AddBoundaryProcessingTicks(long elapsedTicks); } \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs b/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs new file mode 100644 index 0000000..545f901 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs @@ -0,0 +1,134 @@ +using System.Diagnostics; +using Numos.Maths; + +namespace Numos.CoreSim.Solvers; + +/// +/// Shared, side-effect-free atmospheric calculations used across solver stages and mixture operations. +/// +internal static class AtmosSolverMath +{ + internal static float GetMolarHeatCapacity(AtmosConfig config, int gasId) + { + float fallback = IsFinitePositive(config.DefaultMolarHeatCapacityAtConstantVolume) + ? config.DefaultMolarHeatCapacityAtConstantVolume + : AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; + if ((uint)gasId < (uint)config.GasRegistry.Count) + { + float configured = config.GasRegistry[gasId].MolarHeatCapacityAtConstantVolume; + if (IsFinitePositive(configured)) + return configured; + } + + return fallback; + } + + internal static float GetVoxelVolume(AtmosConfig config) + { + return IsFinitePositive(config.VoxelVolume) + ? config.VoxelVolume + : AtmosConfigDefaults.VoxelVolume; + } + + internal static float GetEffectiveTemperature(AtmosConfig config, float storedTemperature) + { + if (IsFinitePositive(storedTemperature)) + return storedTemperature; + + return IsFinitePositive(config.DefaultTemperatureFallback) + ? config.DefaultTemperatureFallback + : AtmosConfigDefaults.DefaultTemperatureFallback; + } + + internal static float CalculatePressure(AtmosConfig config, float moles, float temperature) + { + return MathF.Max(0f, moles) * GetEffectiveTemperature(config, temperature) * + (AtmosPhysicalConstants.MolarGasConstant / GetVoxelVolume(config)); + } + + internal static float CalculatePressure(AtmosSolverConfigSnapshot config, float moles, float temperature) + { + return MathF.Max(0f, moles) * config.GetEffectiveTemperature(temperature) * + config.PressurePerMoleKelvin; + } + + internal static float PressureToMoles(AtmosSolverConfigSnapshot config, float pressure, float temperature) + { + if (!IsFinitePositive(pressure)) + return 0f; + + float denominator = config.PressurePerMoleKelvin * config.GetEffectiveTemperature(temperature); + return pressure / denominator; + } + + internal static float CalculatePressureAtVoxel(AtmosSolverConfigSnapshot config, AtmosChunk chunk, + ushort localVoxelIndex) + { + var totalMoles = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + totalMoles += MathF.Max(0f, chunk.ActiveGases[gas].Moles[localVoxelIndex]); + + return CalculatePressure(config, totalMoles, chunk.Temperature[localVoxelIndex]); + } + + internal static float CalculateHeatCapacityAtVoxel(AtmosSolverConfigSnapshot config, AtmosChunk chunk, + ushort localVoxelIndex) + { + var totalHeatCapacity = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + float moles = chunk.ActiveGases[gas].Moles[localVoxelIndex]; + if (moles <= 0f) + continue; + + totalHeatCapacity += moles * + config.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[gas].GasId); + } + + return totalHeatCapacity; + } + + internal static float CalculateBulkPressureTransfer(AtmosSolverConfigSnapshot config, + float pressureDelta, float currentPressure) + { + float maximumFraction = config.MaxPressureTransferFractionPerNeighbor; + if (maximumFraction <= 0f) + return 0f; + + float pressureTransfer = pressureDelta < config.LowPressureDeltaThreshold + ? pressureDelta * maximumFraction + : pressureDelta * config.BulkFlowCoefficient * config.BulkFlowDamping; + if (pressureTransfer <= 0f || pressureTransfer < config.MinimumPressureTransfer) + return 0f; + + return MathF.Min(pressureTransfer, currentPressure * maximumFraction); + } + + internal static float CalculateThermalConductance(float sourceHeatCapacity, float targetHeatCapacity, + float thermalConductance) + { + Debug.Assert(IsFinitePositive(sourceHeatCapacity)); + Debug.Assert(IsFinitePositive(targetHeatCapacity)); + Debug.Assert(IsFinitePositive(thermalConductance)); + + float smallerHeatCapacity = MathF.Min(sourceHeatCapacity, targetHeatCapacity); + float largerHeatCapacity = MathF.Max(sourceHeatCapacity, targetHeatCapacity); + float equilibriumConductance = smallerHeatCapacity / + (1f + smallerHeatCapacity / largerHeatCapacity); + return MathF.Min(thermalConductance, equilibriumConductance); + } + + internal static int CompareChunkPositions(Int3 left, Int3 right) + { + int comparison = left.X.CompareTo(right.X); + if (comparison != 0) + return comparison; + comparison = left.Y.CompareTo(right.Y); + return comparison != 0 ? comparison : left.Z.CompareTo(right.Z); + } + + internal static bool IsFinitePositive(float value) + { + return float.IsFinite(value) && value > 0f; + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs b/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs index d7869e7..93dd77b 100644 --- a/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs +++ b/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs @@ -3,14 +3,16 @@ namespace Numos.CoreSim.Solvers; /// /// Ordered, mutable collection of solver delegates executed for every fixed tick. /// -internal sealed class AtmosSolverPipeline +internal sealed class AtmosSolverPipeline : IDisposable { private readonly Func _createDefaults; + private readonly IDisposable? _defaultLifetime; private readonly List _steps = []; - internal AtmosSolverPipeline(Func createDefaults) + internal AtmosSolverPipeline(Func createDefaults, IDisposable? defaultLifetime = null) { _createDefaults = createDefaults; + _defaultLifetime = defaultLifetime; Reset(); } @@ -77,6 +79,11 @@ internal void Execute(AtmosSolverExecutionContext context) step.Solver(context); } + public void Dispose() + { + _defaultLifetime?.Dispose(); + } + private static void ValidateName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -101,4 +108,4 @@ internal enum SolverStepKind Dangerous } -internal readonly record struct SolverStepInfo(string Name, bool Enabled, SolverStepKind Kind); +internal readonly record struct SolverStepInfo(string Name, bool Enabled, SolverStepKind Kind); \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs b/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs new file mode 100644 index 0000000..5226394 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs @@ -0,0 +1,161 @@ +using System.Diagnostics; +using Numos.CoreSim.Datatypes.Events; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.Maths; + +namespace Numos.CoreSim.Solvers; + +/// +/// Applies deterministic, sequential gas flow across chunk boundaries. +/// +internal sealed class BoundaryFlowSolver : IAtmosSolver +{ + private readonly List<(Int3 Key, BoundaryFlowEvent Event)> _orderedEvents = []; + + public void Solve(AtmosSolverExecutionContext context) + { + long startedAt = Stopwatch.GetTimestamp(); + _orderedEvents.Clear(); + while (context.BoundaryEvents.TryDequeue(out var boundaryEvent)) + _orderedEvents.Add(boundaryEvent); + _orderedEvents.Sort(CompareEvents); + + foreach (var (chunkPosition, boundaryEvent) in _orderedEvents) + ProcessBoundaryFlow(context, chunkPosition, boundaryEvent); + + context.World.AddBoundaryProcessingTicks(Stopwatch.GetTimestamp() - startedAt); + } + + private static void ProcessBoundaryFlow(AtmosSolverExecutionContext context, Int3 sourcePosition, + BoundaryFlowEvent boundaryEvent) + { + if (!context.World.TryGetChunk(sourcePosition, out var sourceChunk)) + return; + + Int3 localPosition = sourceChunk.GetXyzInt3(boundaryEvent.LocalVoxelIndex); + TryFlowToNeighbor(context, sourceChunk, sourcePosition, localPosition + Int3.NegX, Int3.NegX); + TryFlowToNeighbor(context, sourceChunk, sourcePosition, localPosition + Int3.PosX, Int3.PosX); + TryFlowToNeighbor(context, sourceChunk, sourcePosition, localPosition + Int3.NegY, Int3.NegY); + TryFlowToNeighbor(context, sourceChunk, sourcePosition, localPosition + Int3.PosY, Int3.PosY); + if (sourceChunk.Depth <= 1) + return; + + TryFlowToNeighbor(context, sourceChunk, sourcePosition, localPosition + Int3.NegZ, Int3.NegZ); + TryFlowToNeighbor(context, sourceChunk, sourcePosition, localPosition + Int3.PosZ, Int3.PosZ); + } + + private static void TryFlowToNeighbor(AtmosSolverExecutionContext context, AtmosChunk sourceChunk, + Int3 sourcePosition, Int3 targetPosition, Int3 direction) + { + if (targetPosition.IsWithin(default, sourceChunk.Dimensions)) + return; + if (!context.World.TryGetChunk(sourcePosition + direction, out var neighborChunk)) + return; + + Int3 neighborPosition = (targetPosition + neighborChunk.Dimensions) % neighborChunk.Dimensions; + ushort neighborIndex = neighborChunk.GetIndex(neighborPosition); + int neighborRoom = neighborChunk.VoxelRoomMap[neighborIndex]; + if (neighborRoom == VoxelClassification.RoomSolid) + return; + + ushort sourceIndex = sourceChunk.GetIndex(targetPosition - direction); + float sourcePressure = sourceChunk.TotalPressure[sourceIndex]; + bool isVoid = neighborRoom == VoxelClassification.RoomVoid; + float neighborPressure = isVoid ? 0f : neighborChunk.TotalPressure[neighborIndex]; + float pressureDelta = sourcePressure - neighborPressure; + float bulkPressureTransfer = pressureDelta > 0f + ? AtmosSolverMath.CalculateBulkPressureTransfer(context.Config, pressureDelta, sourcePressure) + : 0f; + + float totalMoles = GetTotalMoles(sourceChunk, sourceIndex); + if (totalMoles <= 0f) + return; + + TransferSpecies(context, sourceChunk, sourceIndex, neighborChunk, neighborIndex, isVoid, + totalMoles, bulkPressureTransfer); + } + + private static void TransferSpecies(AtmosSolverExecutionContext context, AtmosChunk sourceChunk, + ushort sourceIndex, AtmosChunk neighborChunk, ushort neighborIndex, bool isVoid, + float totalMoles, float bulkPressureTransfer) + { + AtmosSolverConfigSnapshot config = context.Config; + float sourceTemperature = config.GetEffectiveTemperature(sourceChunk.Temperature[sourceIndex]); + float neighborTemperature = isVoid + ? 0f + : config.GetEffectiveTemperature(neighborChunk.Temperature[neighborIndex]); + float temperatureRatio = neighborTemperature / sourceTemperature; + float advectedMoles = AtmosSolverMath.PressureToMoles( + config, bulkPressureTransfer, sourceTemperature); + var movedGas = false; + + for (var gas = 0; gas < sourceChunk.ActiveGasCount; gas++) + { + int gasId = sourceChunk.ActiveGases[gas].GasId; + float sourceMoles = sourceChunk.ActiveGases[gas].Moles[sourceIndex]; + float molesAdvected = advectedMoles * (sourceMoles / totalMoles); + float moleImbalance = sourceMoles - + GetGasMoles(neighborChunk, neighborIndex, gasId, isVoid) * temperatureRatio; + float molesDiffused = moleImbalance > 0f + ? moleImbalance * config.GetDiffusionCoefficient(gasId) + : 0f; + float molesToMove = MathF.Min(sourceMoles, molesAdvected + molesDiffused); + if (molesToMove <= 0f) + continue; + + float transferredHeatCapacity = molesToMove * + config.GetMolarHeatCapacityAtConstantVolume(gasId); + sourceChunk.ActiveGases[gas].Moles[sourceIndex] = MathF.Max(0f, sourceMoles - molesToMove); + sourceChunk.TotalHeatCapacity[sourceIndex] = MathF.Max(0f, + sourceChunk.TotalHeatCapacity[sourceIndex] - transferredHeatCapacity); + movedGas = true; + + if (isVoid) + continue; + if (!neighborChunk.IsAwake) + neighborChunk.WakeRoom(neighborChunk.VoxelRoomMap[neighborIndex]); + GasInjectionSolver.InjectDuringTick(neighborChunk, neighborIndex, gasId, molesToMove, + sourceTemperature, config); + } + + if (!movedGas) + return; + + if (sourceChunk.TotalHeatCapacity[sourceIndex] > 0f) + sourceChunk.Temperature[sourceIndex] = sourceTemperature; + sourceChunk.TotalPressure[sourceIndex] = AtmosSolverMath.CalculatePressure( + config, GetTotalMoles(sourceChunk, sourceIndex), sourceTemperature); + } + + private static float GetGasMoles(AtmosChunk chunk, ushort voxelIndex, int gasId, bool isVoid) + { + if (isVoid) + return 0f; + + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + if (chunk.ActiveGases[gas].GasId == gasId) + return chunk.ActiveGases[gas].Moles[voxelIndex]; + } + + return 0f; + } + + private static float GetTotalMoles(AtmosChunk chunk, ushort voxelIndex) + { + var totalMoles = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + totalMoles += chunk.ActiveGases[gas].Moles[voxelIndex]; + return totalMoles; + } + + private static int CompareEvents( + (Int3 Key, BoundaryFlowEvent Event) left, + (Int3 Key, BoundaryFlowEvent Event) right) + { + int comparison = AtmosSolverMath.CompareChunkPositions(left.Key, right.Key); + return comparison != 0 + ? comparison + : left.Event.LocalVoxelIndex.CompareTo(right.Event.LocalVoxelIndex); + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/BuiltInSolvers.cs b/src/Numos.CoreSim/Solvers/BuiltInSolvers.cs deleted file mode 100644 index 5a4a7f3..0000000 --- a/src/Numos.CoreSim/Solvers/BuiltInSolvers.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Numos.CoreSim.Solvers; - -internal sealed class AdvectionSolver : IAtmosSolver -{ - public void Solve(AtmosSolverExecutionContext context) - { - context.Kernel.SolveAdvection(context.Chunks); - } -} - -internal sealed class BoundaryFlowSolver : IAtmosSolver -{ - public void Solve(AtmosSolverExecutionContext context) - { - context.Kernel.SolveBoundaryFlow(); - } -} - -internal sealed class ThermodynamicsSolver : IAtmosSolver -{ - public void Solve(AtmosSolverExecutionContext context) - { - context.Kernel.SolveThermodynamics(context.Chunks); - } -} - -internal sealed class ThermalBoundarySolver : IAtmosSolver -{ - public void Solve(AtmosSolverExecutionContext context) - { - context.Kernel.SolveThermalBoundary(); - } -} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/DefaultAtmosSolvers.cs b/src/Numos.CoreSim/Solvers/DefaultAtmosSolvers.cs new file mode 100644 index 0000000..30d7829 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/DefaultAtmosSolvers.cs @@ -0,0 +1,37 @@ +namespace Numos.CoreSim.Solvers; + +/// +/// Owns and composes the built-in solver stages for one simulation. +/// +internal sealed class DefaultAtmosSolvers : IDisposable +{ + private readonly AdvectionSolver _advection; + private readonly BoundaryFlowSolver _boundaryFlow = new(); + private readonly ThermalBoundarySolver _thermalBoundary = new(); + private readonly ThermodynamicsSolver _thermodynamics; + + internal DefaultAtmosSolvers(int chunkWidth, int chunkHeight, int chunkDepth) + { + int maximumBoundaryEvents = checked(2 * + (chunkWidth * chunkHeight + chunkWidth * chunkDepth + chunkHeight * chunkDepth)); + _advection = new AdvectionSolver(maximumBoundaryEvents); + _thermodynamics = new ThermodynamicsSolver(maximumBoundaryEvents); + } + + internal SolverStep[] CreateSteps() + { + return + [ + new SolverStep(AtmosSolverStageNames.Advection, SolverStepKind.BuiltIn, _advection.Solve), + new SolverStep(AtmosSolverStageNames.BoundaryFlow, SolverStepKind.BuiltIn, _boundaryFlow.Solve), + new SolverStep(AtmosSolverStageNames.Thermodynamics, SolverStepKind.BuiltIn, _thermodynamics.Solve), + new SolverStep(AtmosSolverStageNames.ThermalBoundary, SolverStepKind.BuiltIn, _thermalBoundary.Solve) + ]; + } + + public void Dispose() + { + _advection.Dispose(); + _thermodynamics.Dispose(); + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs b/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs index fb8a756..cb1ea08 100644 --- a/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs +++ b/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs @@ -13,31 +13,20 @@ internal static void Inject(AtmosChunk chunk, ushort localVoxelIndex, int gasId, if (!CanInject(chunk, localVoxelIndex)) return; - float fallbackHeatCapacity = IsFinitePositive(config.DefaultMolarHeatCapacityAtConstantVolume) - ? config.DefaultMolarHeatCapacityAtConstantVolume - : AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; float currentHeatCapacity = 0f; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { float existingMoles = chunk.ActiveGases[gas].Moles[localVoxelIndex]; if (existingMoles <= 0f) continue; - currentHeatCapacity += existingMoles * GetMolarHeatCapacity( - chunk.ActiveGases[gas].GasId, config, fallbackHeatCapacity); + currentHeatCapacity += existingMoles * + AtmosSolverMath.GetMolarHeatCapacity(config, chunk.ActiveGases[gas].GasId); } - float effectiveTemperature = IsFinitePositive(chunk.Temperature[localVoxelIndex]) - ? chunk.Temperature[localVoxelIndex] - : IsFinitePositive(config.DefaultTemperatureFallback) - ? config.DefaultTemperatureFallback - : AtmosConfigDefaults.DefaultTemperatureFallback; - float volume = IsFinitePositive(config.VoxelVolume) - ? config.VoxelVolume - : AtmosConfigDefaults.VoxelVolume; - InjectCore(chunk, localVoxelIndex, gasId, moles, temperature, - GetMolarHeatCapacity(gasId, config, fallbackHeatCapacity), currentHeatCapacity, - effectiveTemperature, AtmosPhysicalConstants.MolarGasConstant / volume); + AtmosSolverMath.GetMolarHeatCapacity(config, gasId), currentHeatCapacity, + AtmosSolverMath.GetEffectiveTemperature(config, chunk.Temperature[localVoxelIndex]), + AtmosPhysicalConstants.MolarGasConstant / AtmosSolverMath.GetVoxelVolume(config)); } internal static void InjectDuringTick(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, @@ -75,27 +64,11 @@ private static void InjectCore(AtmosChunk chunk, ushort localVoxelIndex, int gas float effectiveCurrentTemperature, float pressurePerMoleKelvin) { chunk.TotalHeatCapacity[localVoxelIndex] = currentHeatCapacity; - if (currentHeatCapacity > 0f && !IsFinitePositive(chunk.Temperature[localVoxelIndex])) + if (currentHeatCapacity > 0f && !AtmosSolverMath.IsFinitePositive(chunk.Temperature[localVoxelIndex])) chunk.Temperature[localVoxelIndex] = effectiveCurrentTemperature; chunk.InjectGasToVoxel(localVoxelIndex, gasId, moles, temperature, molarHeatCapacity, pressurePerMoleKelvin); } - private static float GetMolarHeatCapacity(int gasId, AtmosConfig config, float fallback) - { - if ((uint)gasId < (uint)config.GasRegistry.Count) - { - float configured = config.GasRegistry[gasId].MolarHeatCapacityAtConstantVolume; - if (IsFinitePositive(configured)) - return configured; - } - - return fallback; - } - - private static bool IsFinitePositive(float value) - { - return float.IsFinite(value) && value > 0f; - } } \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs new file mode 100644 index 0000000..4d85e85 --- /dev/null +++ b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs @@ -0,0 +1,80 @@ +namespace Numos.CoreSim.Solvers; + +/// +/// Applies configured gas condensation and its constant-volume internal-energy change. +/// +internal sealed class PhaseChangeSolver +{ + internal void Solve(AtmosChunk chunk, AtmosSolverConfigSnapshot config) + { + if (config.CondensationRateFactor <= 0f) + return; + + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + ProcessGas(chunk, config, gas); + } + + private static void ProcessGas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, int gasIndex) + { + int gasId = chunk.ActiveGases[gasIndex].GasId; + if (!config.TryGetGasProperties(gasId, out var properties) || !properties.CondensationEnabled) + return; + if (!AtmosSolverMath.IsFinitePositive(properties.BoilingPoint) || + !AtmosSolverMath.IsFinitePositive(properties.MolarEnthalpyOfVaporization)) + return; + + float inverseBoilingPoint = 1f / properties.BoilingPoint; + float molarHeatCapacity = config.GetMolarHeatCapacityAtConstantVolume(gasId); + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + float gasMoles = chunk.ActiveGases[gasIndex].Moles[voxelIndex]; + if (gasMoles <= AtmosSolverConstants.MinimumMolesForCondensation) + continue; + + float temperature = config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]); + float saturationPressure = CalculateSaturationPressure( + config, properties, temperature, inverseBoilingPoint); + float partialPressure = AtmosSolverMath.CalculatePressure(config, gasMoles, temperature); + if (partialPressure <= saturationPressure) + continue; + + float molesToCondense = AtmosSolverMath.PressureToMoles( + config, partialPressure - saturationPressure, temperature) * + config.CondensationRateFactor; + ApplyCondensation(chunk, config, gasIndex, voxelIndex, temperature, + MathF.Min(gasMoles, molesToCondense), molarHeatCapacity, + properties.MolarEnthalpyOfVaporization); + } + } + + private static float CalculateSaturationPressure(AtmosSolverConfigSnapshot config, + GasProperties properties, float temperature, float inverseBoilingPoint) + { + float exponent = -properties.MolarEnthalpyOfVaporization / + AtmosPhysicalConstants.MolarGasConstant * + (1f / temperature - inverseBoilingPoint); + return config.SaturationReferencePressure * MathF.Exp(exponent); + } + + private static void ApplyCondensation(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + int gasIndex, ushort voxelIndex, float temperature, float condensedMoles, + float molarHeatCapacity, float molarEnthalpyOfVaporization) + { + chunk.ActiveGases[gasIndex].Moles[voxelIndex] -= condensedMoles; + + float oldHeatCapacity = chunk.TotalHeatCapacity[voxelIndex]; + float condensedHeatCapacity = condensedMoles * molarHeatCapacity; + float newHeatCapacity = MathF.Max(0f, oldHeatCapacity - condensedHeatCapacity); + float molarInternalEnergyOfVaporization = MathF.Max(0f, + molarEnthalpyOfVaporization - AtmosPhysicalConstants.MolarGasConstant * temperature); + float remainingEnergy = temperature * oldHeatCapacity - + temperature * condensedHeatCapacity + + condensedMoles * molarInternalEnergyOfVaporization; + chunk.TotalHeatCapacity[voxelIndex] = newHeatCapacity; + if (newHeatCapacity > 0f) + chunk.Temperature[voxelIndex] = MathF.Max(0f, remainingEnergy / newHeatCapacity); + chunk.TotalPressure[voxelIndex] = + AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs b/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs new file mode 100644 index 0000000..9a8521c --- /dev/null +++ b/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs @@ -0,0 +1,195 @@ +using Numos.CoreSim.Datatypes.Events; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.Maths; + +namespace Numos.CoreSim.Solvers; + +/// +/// Solves simultaneous, conservative thermal diffusion across chunk boundaries. +/// +internal sealed class ThermalBoundarySolver : IAtmosSolver +{ + private readonly List _activeEdges = []; + private readonly Dictionary _energyDeltas = []; + private readonly HashSet _edges = []; + private readonly Dictionary _incidentConductances = []; + private readonly List _orderedEdges = []; + private readonly Dictionary _states = []; + + public void Solve(AtmosSolverExecutionContext context) + { + if (context.TickCount % AtmosSolverConstants.ThermodynamicsTickInterval != 0) + return; + + ResetWorkspace(); + if (context.Config.ThermalConductance <= 0f) + return; + + CollectEdges(context); + if (_edges.Count == 0) + return; + + _orderedEdges.AddRange(_edges); + _orderedEdges.Sort(CompareEdges); + AccumulateConductances(context); + AccumulateEnergyDeltas(); + ApplyEnergyDeltas(context); + } + + private void ResetWorkspace() + { + _edges.Clear(); + _orderedEdges.Clear(); + _states.Clear(); + _incidentConductances.Clear(); + _activeEdges.Clear(); + _energyDeltas.Clear(); + } + + private void CollectEdges(AtmosSolverExecutionContext context) + { + while (context.ThermalBoundaryEvents.TryDequeue(out var boundaryEvent)) + CollectEdges(context, boundaryEvent.Key, boundaryEvent.Event); + } + + private void CollectEdges(AtmosSolverExecutionContext context, Int3 sourcePosition, + ThermalBoundaryEvent boundaryEvent) + { + if (!context.World.TryGetChunk(sourcePosition, out var sourceChunk)) + return; + + Int3 localPosition = sourceChunk.GetXyzInt3(boundaryEvent.LocalVoxelIndex); + TryAddEdge(context, sourceChunk, sourcePosition, localPosition + Int3.NegX, Int3.NegX); + TryAddEdge(context, sourceChunk, sourcePosition, localPosition + Int3.PosX, Int3.PosX); + TryAddEdge(context, sourceChunk, sourcePosition, localPosition + Int3.NegY, Int3.NegY); + TryAddEdge(context, sourceChunk, sourcePosition, localPosition + Int3.PosY, Int3.PosY); + if (sourceChunk.Depth <= 1) + return; + + TryAddEdge(context, sourceChunk, sourcePosition, localPosition + Int3.NegZ, Int3.NegZ); + TryAddEdge(context, sourceChunk, sourcePosition, localPosition + Int3.PosZ, Int3.PosZ); + } + + private void TryAddEdge(AtmosSolverExecutionContext context, AtmosChunk sourceChunk, + Int3 sourcePosition, Int3 targetPosition, Int3 direction) + { + if (targetPosition.IsWithin(default, sourceChunk.Dimensions)) + return; + + Int3 neighborPosition = sourcePosition + direction; + if (!context.World.TryGetChunk(neighborPosition, out var neighborChunk)) + return; + + Int3 neighborLocalPosition = (targetPosition + neighborChunk.Dimensions) % neighborChunk.Dimensions; + ushort neighborIndex = neighborChunk.GetIndex(neighborLocalPosition); + if (neighborChunk.VoxelRoomMap[neighborIndex] == VoxelClassification.RoomSolid) + return; + + ushort sourceIndex = sourceChunk.GetIndex(targetPosition - direction); + var source = new ThermalVoxelAddress(sourcePosition, sourceIndex); + var neighbor = new ThermalVoxelAddress(neighborPosition, neighborIndex); + _edges.Add(CompareVoxels(source, neighbor) <= 0 + ? new ThermalBoundaryEdge(source, neighbor) + : new ThermalBoundaryEdge(neighbor, source)); + } + + private void AccumulateConductances(AtmosSolverExecutionContext context) + { + foreach (var edge in _orderedEdges) + { + if (!TryGetState(context, edge.First, out var firstState) || + !TryGetState(context, edge.Second, out var secondState)) + continue; + + float conductance = AtmosSolverMath.CalculateThermalConductance( + firstState.HeatCapacity, secondState.HeatCapacity, context.Config.ThermalConductance); + if (conductance <= 0f) + continue; + + Add(_incidentConductances, edge.First, conductance); + Add(_incidentConductances, edge.Second, conductance); + _activeEdges.Add(new ThermalBoundaryConductance(edge, conductance)); + } + } + + private void AccumulateEnergyDeltas() + { + foreach (var (edge, conductance) in _activeEdges) + { + ThermalBoundaryState firstState = _states[edge.First]; + ThermalBoundaryState secondState = _states[edge.Second]; + float scale = MathF.Min(1f, MathF.Min( + firstState.HeatCapacity / _incidentConductances[edge.First], + secondState.HeatCapacity / _incidentConductances[edge.Second])); + float heatTransfer = scale * conductance * + (firstState.Temperature - secondState.Temperature); + if (heatTransfer == 0f) + continue; + + Add(_energyDeltas, edge.First, -heatTransfer); + Add(_energyDeltas, edge.Second, heatTransfer); + } + } + + private void ApplyEnergyDeltas(AtmosSolverExecutionContext context) + { + foreach (var (address, energyDelta) in _energyDeltas) + { + ThermalBoundaryState state = _states[address]; + float newTemperature = state.Temperature + energyDelta / state.HeatCapacity; + if (newTemperature < 0f || !context.World.TryGetChunk(address.ChunkPosition, out var chunk)) + continue; + + chunk.Temperature[address.LocalVoxelIndex] = newTemperature; + chunk.TotalPressure[address.LocalVoxelIndex] = AtmosSolverMath.CalculatePressureAtVoxel( + context.Config, chunk, address.LocalVoxelIndex); + chunk.MarkChanged(); + } + } + + private bool TryGetState(AtmosSolverExecutionContext context, ThermalVoxelAddress address, + out ThermalBoundaryState state) + { + if (_states.TryGetValue(address, out state)) + return true; + if (!context.World.TryGetChunk(address.ChunkPosition, out var chunk)) + return false; + + ushort voxelIndex = address.LocalVoxelIndex; + float pressure = AtmosSolverMath.CalculatePressureAtVoxel(context.Config, chunk, voxelIndex); + float heatCapacity = AtmosSolverMath.CalculateHeatCapacityAtVoxel(context.Config, chunk, voxelIndex); + chunk.TotalPressure[voxelIndex] = pressure; + chunk.TotalHeatCapacity[voxelIndex] = heatCapacity; + if (!AtmosSolverMath.IsFinitePositive(heatCapacity) || !float.IsFinite(pressure) || + pressure < context.Config.VacuumThreshold) + return false; + + state = new ThermalBoundaryState( + context.Config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]), heatCapacity); + _states.Add(address, state); + return true; + } + + private static int CompareVoxels(ThermalVoxelAddress left, ThermalVoxelAddress right) + { + int comparison = AtmosSolverMath.CompareChunkPositions(left.ChunkPosition, right.ChunkPosition); + return comparison != 0 ? comparison : left.LocalVoxelIndex.CompareTo(right.LocalVoxelIndex); + } + + private static int CompareEdges(ThermalBoundaryEdge left, ThermalBoundaryEdge right) + { + int comparison = CompareVoxels(left.First, right.First); + return comparison != 0 ? comparison : CompareVoxels(left.Second, right.Second); + } + + private static void Add(Dictionary values, + ThermalVoxelAddress address, float value) + { + values[address] = values.GetValueOrDefault(address) + value; + } + + private readonly record struct ThermalVoxelAddress(Int3 ChunkPosition, ushort LocalVoxelIndex); + private readonly record struct ThermalBoundaryEdge(ThermalVoxelAddress First, ThermalVoxelAddress Second); + private readonly record struct ThermalBoundaryConductance(ThermalBoundaryEdge Edge, float Conductance); + private readonly record struct ThermalBoundaryState(float Temperature, float HeatCapacity); +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs b/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs new file mode 100644 index 0000000..ddce63a --- /dev/null +++ b/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs @@ -0,0 +1,188 @@ +using System.Buffers; +using Numos.CoreSim.Datatypes.Events; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.Maths; + +namespace Numos.CoreSim.Solvers; + +/// +/// Solves simultaneous, conservative thermal diffusion inside one chunk. +/// +internal sealed class ThermalDiffusionSolver +{ + internal int Solve(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + ThermalBoundaryEvent[] boundaryBuffer) + { + float thermalConductance = config.ThermalConductance; + if (thermalConductance <= 0f) + return 0; + + float[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); + float[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); + Array.Clear(incidentConductances, 0, chunk.VoxelCount); + Array.Clear(energyDeltas, 0, chunk.VoxelCount); + + try + { + int boundaryCount = AccumulateConductancesAndBoundaries( + chunk, config, incidentConductances, boundaryBuffer); + AccumulateEnergyDeltas(chunk, config, incidentConductances, energyDeltas); + ApplyEnergyDeltas(chunk, config, energyDeltas); + return boundaryCount; + } + finally + { + ArrayPool.Shared.Return(energyDeltas); + ArrayPool.Shared.Return(incidentConductances); + } + } + + private static int AccumulateConductancesAndBoundaries(AtmosChunk chunk, + AtmosSolverConfigSnapshot config, float[] incidentConductances, + ThermalBoundaryEvent[] boundaryBuffer) + { + var boundaryCount = 0; + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + if (chunk.TotalHeatCapacity[voxelIndex] <= 0f || + chunk.TotalPressure[voxelIndex] < config.VacuumThreshold) + continue; + + Int3 position = chunk.GetXyzInt3(voxelIndex); + AccumulateConductance(chunk, config, position + Int3.PosX, voxelIndex, incidentConductances); + AccumulateConductance(chunk, config, position + Int3.PosY, voxelIndex, incidentConductances); + if (chunk.Depth > 1) + AccumulateConductance(chunk, config, position + Int3.PosZ, voxelIndex, incidentConductances); + + if (IsBoundary(chunk, position)) + AppendBoundaryEvent(boundaryBuffer, ref boundaryCount, voxelIndex); + } + + return boundaryCount; + } + + private static void AccumulateEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + float[] incidentConductances, float[] energyDeltas) + { + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + Int3 position = chunk.GetXyzInt3(voxelIndex); + AccumulateFlux(chunk, config, position + Int3.PosX, voxelIndex, + incidentConductances, energyDeltas); + AccumulateFlux(chunk, config, position + Int3.PosY, voxelIndex, + incidentConductances, energyDeltas); + if (chunk.Depth > 1) + { + AccumulateFlux(chunk, config, position + Int3.PosZ, voxelIndex, + incidentConductances, energyDeltas); + } + } + } + + private static void ApplyEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + float[] energyDeltas) + { + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + if (energyDeltas[voxelIndex] == 0f || + !TryGetThermalState(chunk, config, voxelIndex, out float oldTemperature, + out float heatCapacity)) + continue; + + chunk.Temperature[voxelIndex] = MathF.Max(0f, + oldTemperature + energyDeltas[voxelIndex] / heatCapacity); + chunk.TotalPressure[voxelIndex] = + AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); + } + } + + private static void AccumulateConductance(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + Int3 neighborPosition, ushort voxelIndex, float[] incidentConductances) + { + if (!neighborPosition.IsWithin(default, chunk.Dimensions)) + return; + + ushort neighborIndex = chunk.GetIndex(neighborPosition); + if (chunk.VoxelRoomMap[neighborIndex] == VoxelClassification.RoomSolid) + return; + if (!TryGetThermalState(chunk, config, voxelIndex, out _, out float currentHeatCapacity) || + !TryGetThermalState(chunk, config, neighborIndex, out _, out float neighborHeatCapacity)) + return; + + float conductance = AtmosSolverMath.CalculateThermalConductance( + currentHeatCapacity, neighborHeatCapacity, config.ThermalConductance); + if (conductance <= 0f) + return; + + incidentConductances[voxelIndex] += conductance; + incidentConductances[neighborIndex] += conductance; + } + + private static void AccumulateFlux(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + Int3 neighborPosition, ushort voxelIndex, float[] incidentConductances, float[] energyDeltas) + { + if (!neighborPosition.IsWithin(default, chunk.Dimensions)) + return; + + ushort neighborIndex = chunk.GetIndex(neighborPosition); + if (chunk.VoxelRoomMap[neighborIndex] == VoxelClassification.RoomSolid) + return; + if (!TryGetThermalState(chunk, config, voxelIndex, out float currentTemperature, + out float currentHeatCapacity) || + !TryGetThermalState(chunk, config, neighborIndex, out float neighborTemperature, + out float neighborHeatCapacity)) + return; + + float conductance = AtmosSolverMath.CalculateThermalConductance( + currentHeatCapacity, neighborHeatCapacity, config.ThermalConductance); + float currentIncident = incidentConductances[voxelIndex]; + float neighborIncident = incidentConductances[neighborIndex]; + if (conductance <= 0f || currentIncident <= 0f || neighborIncident <= 0f) + return; + + float scale = MathF.Min(1f, MathF.Min( + currentHeatCapacity / currentIncident, + neighborHeatCapacity / neighborIncident)); + float heatTransfer = scale * conductance * (currentTemperature - neighborTemperature); + if (heatTransfer == 0f) + return; + + energyDeltas[voxelIndex] -= heatTransfer; + energyDeltas[neighborIndex] += heatTransfer; + } + + private static bool TryGetThermalState(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + ushort voxelIndex, out float temperature, out float heatCapacity) + { + heatCapacity = chunk.TotalHeatCapacity[voxelIndex]; + float pressure = chunk.TotalPressure[voxelIndex]; + if (!AtmosSolverMath.IsFinitePositive(heatCapacity) || !float.IsFinite(pressure) || + pressure < config.VacuumThreshold) + { + temperature = 0f; + heatCapacity = 0f; + return false; + } + + temperature = config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]); + return true; + } + + private static bool IsBoundary(AtmosChunk chunk, Int3 position) + { + return position.X == 0 || position.X == chunk.Width - 1 || + position.Y == 0 || position.Y == chunk.Height - 1 || + chunk.Depth > 1 && (position.Z == 0 || position.Z == chunk.Depth - 1); + } + + private static void AppendBoundaryEvent(ThermalBoundaryEvent[] buffer, ref int count, + ushort voxelIndex) + { + if (count >= buffer.Length) + throw new InvalidOperationException("Thermal boundary event buffer capacity was exceeded."); + buffer[count++] = new ThermalBoundaryEvent { LocalVoxelIndex = voxelIndex }; + } +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/ThermodynamicsSolver.cs b/src/Numos.CoreSim/Solvers/ThermodynamicsSolver.cs new file mode 100644 index 0000000..9bfc36a --- /dev/null +++ b/src/Numos.CoreSim/Solvers/ThermodynamicsSolver.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using Numos.CoreSim.Datatypes.Events; + +namespace Numos.CoreSim.Solvers; + +/// +/// Coordinates the lower-frequency thermal-diffusion and phase-change operations. +/// +internal sealed class ThermodynamicsSolver : IAtmosSolver, IDisposable +{ + private readonly PhaseChangeSolver _phaseChanges = new(); + private readonly ThermalDiffusionSolver _thermalDiffusion = new(); + private readonly ThreadLocal _thermalBoundaryBuffers; + + internal ThermodynamicsSolver(int maximumBoundaryEvents) + { + _thermalBoundaryBuffers = new ThreadLocal( + () => new ThermalBoundaryEvent[maximumBoundaryEvents]); + } + + public void Solve(AtmosSolverExecutionContext context) + { + if (context.TickCount % AtmosSolverConstants.ThermodynamicsTickInterval != 0) + return; + + Parallel.ForEach(context.Chunks, chunk => SolveChunk(context, chunk)); + } + + public void Dispose() + { + _thermalBoundaryBuffers.Dispose(); + } + + private void SolveChunk(AtmosSolverExecutionContext context, AtmosChunk chunk) + { + if (!chunk.IsAwake || chunk.ActiveGasCount == 0) + return; + + ThermalBoundaryEvent[]? boundaryBuffer = _thermalBoundaryBuffers.Value; + Debug.Assert(boundaryBuffer != null); + int boundaryCount = _thermalDiffusion.Solve(chunk, context.Config, boundaryBuffer); + _phaseChanges.Solve(chunk, context.Config); + + for (var index = 0; index < boundaryCount; index++) + context.ThermalBoundaryEvents.Enqueue((chunk.GridPosition, boundaryBuffer[index])); + } +} \ No newline at end of file From b85c3b2edfb855449cb3fe5645b59cba21fcfba0 Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:34:17 -0500 Subject: [PATCH 07/14] Added support for typed solver-owned configuration in AtmosSolver and AtmosDangerousSolver pipelines. --- README.md | 2 +- docs/atmospherics_technical_documentation.md | 31 ++++++++++++---- .../AtmosDangerousSolver.cs | 19 ++++++++++ .../AtmosDangerousSolverPipeline.cs | 26 ++++++++++++++ src/Numos.API/AtmosSolver.cs | 19 ++++++++++ src/Numos.API/AtmosSolverPipeline.cs | 26 ++++++++++++++ .../AtmosDangerousApiTests.cs | 35 ++++++++++++++++++- .../AtmosSolverPipelineTests.cs | 30 ++++++++++++++++ 8 files changed, 179 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 31ca481..f23b64e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The project will follow a regular semantic versioning structure when I feel comf - Engine-agnostic, with a supported `Numos.API` facade over an internal simulation kernel - Multithreaded intra-chunk advection and thermodynamics - Singlethreaded cross-chunk boundary flow -- Ordered solver pipeline with replaceable/disableable built-in stages and custom delegates +- Ordered solver pipeline with replaceable/disableable built-in stages, custom delegates, and typed solver-owned configuration - Separate supported solver context and opt-in `Numos.API.Dangerous` live-span context - Ideal-gas pressure in pascals (`P = nRT/V`) with configurable, uniform voxel volume - Sensible internal-energy transport using per-species molar heat capacity at constant volume diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 42b117f..99e3922 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -371,21 +371,38 @@ Pipeline edits made by a callback take effect on the next tick. Gas and thermal per-tick execution context, so disabling a consumer stage cannot replay stale events when it is later re-enabled. Solver-specific settings should remain with the solver instead of expanding `AtmosConfig` with unrelated game -configuration. A delegate naturally captures a dedicated configuration object: +configuration. Implement `IAtmosSolver` to make that ownership explicit: ```csharp -var reactionConfig = new ReactionSolverConfig { Rate = 0.25f }; -var reactionSolver = new ReactionSolver(reactionConfig); +public sealed class ReactionSolverConfig +{ + public float Rate { get; set; } = 0.25f; +} + +public sealed class ReactionSolver : IAtmosSolver +{ + public ReactionSolverConfig Config { get; } = new(); + + public void Solve(AtmosSolverContext context) + { + // Read snapshots and apply validated mutations through context. + } +} + +var reactionSolver = new ReactionSolver(); simulation.Solvers.RegisterAfter( AtmosBuiltInSolvers.Advection, "game-reactions", - reactionSolver.Solve); + reactionSolver); + +reactionSolver.Config.Rate = 0.5f; ``` -`AtmosSolverContext.Config` still exposes the simulation-wide physical configuration for stages that need it. The -same ownership pattern applies to dangerous solvers; only state access, not configuration ownership, determines -which package a custom stage belongs in. +The pipeline retains the solver instance through its callback, so its typed configuration remains editable after +registration. `AtmosSolverContext.Config` still exposes the simulation-wide physical configuration for stages that +need it. Dangerous solvers can implement `IAtmosDangerousSolver` for the same ownership pattern; only state +access, not configuration ownership, determines which package a custom stage belongs in. Solvers that have a measured need to avoid snapshot copies can opt into live storage through the separate dangerous package: diff --git a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs index 0c38066..1d08c11 100644 --- a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs +++ b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs @@ -14,6 +14,25 @@ namespace Numos.API.Dangerous; /// public delegate void AtmosDangerousSolver(AtmosDangerousSolverContext context); +/// +/// A low-level custom solver that owns its strongly typed configuration. +/// +/// The solver-specific reference type retained by the solver. +/// +/// This interface provides configuration ownership only. Its solver still receives live, unchecked storage +/// and therefore has the same compatibility and invariant-maintenance responsibilities as +/// . +/// +public interface IAtmosDangerousSolver where TConfig : class +{ + /// The configuration owned by this solver. + TConfig Config { get; } + + /// Executes the solver against live simulation storage. + /// The unchecked simulation surface for the current tick. + void Solve(AtmosDangerousSolverContext context); +} + /// /// Low-level state supplied to a dangerous solver for one fixed tick. /// diff --git a/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs b/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs index a9106a5..20c31ce 100644 --- a/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs +++ b/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs @@ -25,6 +25,14 @@ public void Register(string name, AtmosDangerousSolver solver) context => solver(new AtmosDangerousSolverContext(context))); } + /// Appends a dangerous solver that owns strongly typed configuration. + [PublicAPI] + public void Register(string name, IAtmosDangerousSolver solver) where TConfig : class + { + ArgumentNullException.ThrowIfNull(solver); + Register(name, solver.Solve); + } + /// Registers a dangerous solver immediately before an existing stage. [PublicAPI] public void RegisterBefore(string existingName, string name, AtmosDangerousSolver solver) @@ -34,6 +42,15 @@ public void RegisterBefore(string existingName, string name, AtmosDangerousSolve context => solver(new AtmosDangerousSolverContext(context))); } + /// Registers a configured dangerous solver immediately before an existing stage. + [PublicAPI] + public void RegisterBefore(string existingName, string name, IAtmosDangerousSolver solver) + where TConfig : class + { + ArgumentNullException.ThrowIfNull(solver); + RegisterBefore(existingName, name, solver.Solve); + } + /// Registers a dangerous solver immediately after an existing stage. [PublicAPI] public void RegisterAfter(string existingName, string name, AtmosDangerousSolver solver) @@ -42,4 +59,13 @@ public void RegisterAfter(string existingName, string name, AtmosDangerousSolver _simulation.Kernel.RegisterSolverAfter(existingName, name, SolverStepKind.Dangerous, context => solver(new AtmosDangerousSolverContext(context))); } + + /// Registers a configured dangerous solver immediately after an existing stage. + [PublicAPI] + public void RegisterAfter(string existingName, string name, IAtmosDangerousSolver solver) + where TConfig : class + { + ArgumentNullException.ThrowIfNull(solver); + RegisterAfter(existingName, name, solver.Solve); + } } \ No newline at end of file diff --git a/src/Numos.API/AtmosSolver.cs b/src/Numos.API/AtmosSolver.cs index 6bbc48a..075323f 100644 --- a/src/Numos.API/AtmosSolver.cs +++ b/src/Numos.API/AtmosSolver.cs @@ -11,6 +11,25 @@ namespace Numos.API; /// The supported simulation surface for the current tick. public delegate void AtmosSolver(AtmosSolverContext context); +/// +/// A supported custom solver that owns its strongly typed configuration. +/// +/// The solver-specific reference type retained by the solver. +/// +/// Keep game- or solver-specific settings here rather than adding them to the simulation-wide +/// . The pipeline retains the solver instance through its registered +/// callback, so callers may edit after registration. +/// +public interface IAtmosSolver where TConfig : class +{ + /// The configuration owned by this solver. + TConfig Config { get; } + + /// Executes the solver through the supported simulation API. + /// The supported simulation surface for the current tick. + void Solve(AtmosSolverContext context); +} + /// /// Identifies the origin and compatibility boundary of a registered solver stage. /// diff --git a/src/Numos.API/AtmosSolverPipeline.cs b/src/Numos.API/AtmosSolverPipeline.cs index a28498b..6f976b5 100644 --- a/src/Numos.API/AtmosSolverPipeline.cs +++ b/src/Numos.API/AtmosSolverPipeline.cs @@ -45,6 +45,14 @@ public void Register(string name, AtmosSolver solver) context => solver(new AtmosSolverContext(_simulation, context))); } + /// Appends a supported solver that owns strongly typed configuration. + [PublicAPI] + public void Register(string name, IAtmosSolver solver) where TConfig : class + { + ArgumentNullException.ThrowIfNull(solver); + Register(name, solver.Solve); + } + /// Registers a supported custom solver immediately before an existing stage. [PublicAPI] public void RegisterBefore(string existingName, string name, AtmosSolver solver) @@ -54,6 +62,15 @@ public void RegisterBefore(string existingName, string name, AtmosSolver solver) context => solver(new AtmosSolverContext(_simulation, context))); } + /// Registers a configured supported solver immediately before an existing stage. + [PublicAPI] + public void RegisterBefore(string existingName, string name, IAtmosSolver solver) + where TConfig : class + { + ArgumentNullException.ThrowIfNull(solver); + RegisterBefore(existingName, name, solver.Solve); + } + /// Registers a supported custom solver immediately after an existing stage. [PublicAPI] public void RegisterAfter(string existingName, string name, AtmosSolver solver) @@ -63,6 +80,15 @@ public void RegisterAfter(string existingName, string name, AtmosSolver solver) context => solver(new AtmosSolverContext(_simulation, context))); } + /// Registers a configured supported solver immediately after an existing stage. + [PublicAPI] + public void RegisterAfter(string existingName, string name, IAtmosSolver solver) + where TConfig : class + { + ArgumentNullException.ThrowIfNull(solver); + RegisterAfter(existingName, name, solver.Solve); + } + /// Removes a stage by name. [PublicAPI] public bool Unregister(string name) diff --git a/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs b/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs index b23d895..954b8e8 100644 --- a/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs +++ b/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs @@ -87,4 +87,37 @@ public void DangerousInjection_UsesCurrentVoxelShcFromTickSnapshot() var snapshot = simulation.GetChunkSnapshot(chunk); Assert.That(snapshot.Temperature[0], Is.EqualTo(525f).Within(0.0001f)); } -} + + [Test] + public void ConfiguredDangerousSolver_RetainsEditableTypedConfiguration() + { + using var simulation = new AtmosSimulation(1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, + new Numos.CoreSim.Datatypes.Primitives.VoxelClassification(7)); + var solver = new ConfiguredDangerousInjectionSolver(); + simulation.Dangerous().Solvers.RegisterBefore( + AtmosBuiltInSolvers.Advection, "configured-injection", solver); + + solver.Config.Moles = 3f; + simulation.Tick(); + + Assert.That(simulation.GetChunkSnapshot(chunk).Gases.Single().Moles[0], Is.EqualTo(3f)); + } + + private sealed class ConfiguredDangerousInjectionSolver : + IAtmosDangerousSolver + { + public DangerousInjectionSolverConfig Config { get; } = new(); + + public void Solve(AtmosDangerousSolverContext context) + { + context.InjectGasToVoxel(0, 0, 0, Config.Moles, 300f); + } + } + + private sealed class DangerousInjectionSolverConfig + { + internal float Moles { get; set; } + } +} \ No newline at end of file diff --git a/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs b/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs index 9e4bd66..a0cfc5c 100644 --- a/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs +++ b/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs @@ -82,6 +82,21 @@ public void StandardSolver_UsesDetachedReadsAndValidatedMutations() Assert.That(snapshot.Temperature[0], Is.EqualTo(350f)); } + [Test] + public void ConfiguredSolver_RetainsEditableTypedConfiguration() + { + using var simulation = new AtmosSimulation(1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(7)); + var solver = new ConfiguredInjectionSolver(); + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "configured-injection", solver); + + solver.Config.Moles = 2.5f; + simulation.Tick(); + + Assert.That(simulation.GetChunkSnapshot(chunk).Gases.Single().Moles[0], Is.EqualTo(2.5f)); + } + [Test] public void ResetToDefaults_RemovesCustomizations() { @@ -100,4 +115,19 @@ public void ResetToDefaults_RemovesCustomizations() (AtmosBuiltInSolvers.ThermalBoundary, true) })); } + + private sealed class ConfiguredInjectionSolver : IAtmosSolver + { + public InjectionSolverConfig Config { get; } = new(); + + public void Solve(AtmosSolverContext context) + { + context.AddGasToVoxel(context.Chunks[0], 0, 0, Config.Moles, 300f); + } + } + + private sealed class InjectionSolverConfig + { + internal float Moles { get; set; } + } } \ No newline at end of file From f9b18b8e58015090e1a75fc5ada1a286d8440396 Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:13:34 -0500 Subject: [PATCH 08/14] Refactored solver interfaces and enhanced AtmosKernel tick lifecycle safety. Added tests for solver ownership, configuration stability, and lifecycle changes. Improved gas injection and thermal diffusion logic. --- docs/atmospherics_technical_documentation.md | 123 +++++++--- .../AtmosDangerousSolver.cs | 13 +- .../AtmosDangerousSolverPipeline.cs | 4 + src/Numos.API/AtmosSimulation.cs | 26 +- src/Numos.API/AtmosSolver.cs | 4 +- src/Numos.API/AtmosSolverContext.cs | 15 +- src/Numos.API/AtmosSolverPipeline.cs | 5 + src/Numos.CoreSim/AtmosKernel.API.cs | 26 +- src/Numos.CoreSim/AtmosKernel.cs | 31 ++- src/Numos.CoreSim/GasProperties.cs | 8 +- src/Numos.CoreSim/Solvers/AdvectionSolver.cs | 93 +++---- .../Solvers/AtmosSolverExecutionContext.cs | 11 +- src/Numos.CoreSim/Solvers/AtmosSolverMath.cs | 39 ++- .../Solvers/BoundaryFlowSolver.cs | 25 +- .../Solvers/DefaultAtmosSolvers.cs | 15 +- .../Solvers/GasInjectionSolver.cs | 23 +- .../{IAtmosSolver.cs => IAtmosSolverStage.cs} | 6 +- .../Solvers/PhaseChangeSolver.cs | 36 +-- .../Solvers/ThermalBoundarySolver.cs | 61 ++--- .../Solvers/ThermalDiffusionSolver.cs | 84 ++++--- .../Solvers/ThermodynamicsSolver.cs | 8 +- .../AtmosDangerousApiTests.cs | 21 ++ .../AtmosSolverPipelineTests.cs | 226 ++++++++++++++++++ .../CrossChunkFlowTests.cs | 103 +++++++- .../IntraChunkFlowTests.cs | 29 ++- .../ThermodynamicsIntegrationTests.cs | 101 +++++++- 26 files changed, 881 insertions(+), 255 deletions(-) rename src/Numos.CoreSim/Solvers/{IAtmosSolver.cs => IAtmosSolverStage.cs} (52%) diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 99e3922..3aaf140 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -103,6 +103,11 @@ The dangerous package must be referenced separately and imported through `Numos. solvers are stack-scoped callbacks over live chunk arrays and gas-channel spans; they are responsible for maintaining cache, topology, and revision invariants after raw writes. +The standard context is the stable solver extension surface. Its mutation methods keep pressure/heat-capacity caches, +room activation, topology indices, sleep state, and observable revisions coherent as applicable. A solver should use +the dangerous package only when it must directly traverse or mutate backing storage and can maintain those coupled +invariants itself. Downstream standard solvers consequently depend on API behavior rather than chunk-array layout. + The dangerous package translates internal state into callback-scoped `ref struct` views. It does not add raw-access members to `AtmosKernel`; lifecycle and tick orchestration therefore remain separate from the opt-in integration surface. `AtmosKernel`, `AtmosChunk`, and gas-channel representations remain internal CLR types and are never @@ -231,7 +236,7 @@ Each gas species is defined by a `GasProperties` struct: | `BoilingPoint` | `float` | Normal boiling temperature (K) at `SaturationReferencePressure` | | `CondensationEnabled` | `bool` | Enables this species in the condensation model. | | `MolarEnthalpyOfVaporization` | `float` | Vaporization enthalpy in J/mol, used by Clausius–Clapeyron and converted to an approximate constant-volume internal-energy change for condensation. | -| `LiquidId` | `int` | ID of the liquid this gas condenses into (for a separate liquid simulation system) | +| `LiquidId` | `int` | Reserved integration ID. The built-in solver does not currently create liquid state or emit a condensation event. | | `DiffusionCoefficient` | `float` | Dimensionless fraction of the per-species mole imbalance mixed per simulation tick; finite values are clamped to [0, 1], and non-finite values disable species diffusion. | The registry is stored as a `List` indexed by gas ID; zero is a valid gas ID. @@ -296,16 +301,18 @@ The `Temperature` setter stores its raw value for parity with direct voxel tooli temperatures are interpreted through `DefaultTemperatureFallback` when pressure or sensible energy is calculated. Creation and incoming-gas operations still require finite, nonnegative temperatures. -At the start of each simulation tick, the solver captures one normalized configuration and gas-property snapshot. -This keeps the tick internally consistent while retaining the public live-configuration model, and avoids repeating -configuration validation in the per-neighbor and per-species loops. +At the start of each simulation tick, the solver captures the current `AtmosConfig` reference and a normalized +configuration/gas-property snapshot. Built-in stages use the normalized snapshot for the whole tick. Standard and +dangerous contexts expose the captured live reference, so mutating it is visible as ordinary object mutation, but +replacing the simulation configuration during a callback does not change the reference seen by later callbacks. +Either kind of configuration change affects normalized built-in settings on the next tick. -Persistent voxel state, per-voxel thermal work buffers, and production atmos calculations use single precision -end-to-end. Overflow-prone formulas use algebraically equivalent float forms: thermal equilibrium conductance is -evaluated without forming `C1 * C2`, temperature updates use `T + ΔE/C`, and heat-capacity-weighted mixing uses -bounded interpolation. Finite-range checks reject results that cannot be represented by the float-backed state. -Double precision is reserved for test/reference reductions, avoiding production float-to-double conversions while -preserving an independent, higher-precision conservation check. +Persistent voxel state and advection work buffers use single precision. Overflow-prone formulas use stable algebraic +forms: thermal equilibrium conductance is evaluated without forming `C1 * C2`, heat-capacity-weighted mixing uses +bounded interpolation, and condensation computes the temperature increment without subtracting large sensible-energy +terms. Thermal diffusion alone accumulates conductance and equal-and-opposite energy deltas in `double`; temperatures, +pressures, heat capacities, and gas inventories remain `float`. This prevents a representable temperature result from +being lost when an intermediate `C * ΔT` exceeds the `float` range. ```csharp var canister = simulation.CreateGasMixture(volume: 0.07f, temperature: 293.15f); @@ -343,14 +350,27 @@ Each frame: `AtmosKernel` owns chunk lifecycle, tick state, and pipeline execution. Physics is implemented by focused components under `Numos.CoreSim.Solvers`; the kernel does not contain advection, boundary-flow, thermodynamics, or phase-change -algorithms. Each tick captures one chunk/configuration snapshot, increments the tick counter, constructs a fresh -execution context, and executes the ordered `simulation.Solvers` pipeline. Its default stages are: +algorithms. A direct `Tick` snapshots the current chunk set; `Update` snapshots it once for its fixed-step batch. +Every fixed tick captures the live configuration reference plus its normalized built-in settings, increments the +tick counter, constructs a fresh execution context, and executes the ordered `simulation.Solvers` pipeline. Its +default stages are: 1. `advection` 2. `boundary-flow` 3. `thermodynamics` 4. `thermal-boundary` +| Stage | Reads / writes | Tick-scoped output consumed by | +|-------|----------------|---------------------------------| +| `advection` | Refreshes pressure/heat-capacity caches; applies intra-chunk gas and energy deltas | Gas boundary events → `boundary-flow` | +| `boundary-flow` | Applies deterministic cross-chunk gas transfers and refreshes affected caches | None | +| `thermodynamics` | On even ticks, applies intra-chunk thermal diffusion and condensation | Thermal boundary events → `thermal-boundary` | +| `thermal-boundary` | On even ticks, applies simultaneous cross-chunk thermal diffusion | None | + +The producer/consumer order is part of the default contract. Removing or disabling a producer makes its consumer a +no-op for that tick. Moving a consumer before its producer also makes it observe an empty queue; events are never +carried into a later tick. + Stages can be enabled, disabled, removed, or restored with `ResetToDefaults`. Standard delegates can be appended or inserted before/after any named stage: @@ -369,6 +389,9 @@ simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Thermodynamics, false); Pipeline edits made by a callback take effect on the next tick. Gas and thermal boundary events are stored in the per-tick execution context, so disabling a consumer stage cannot replay stale events when it is later re-enabled. +Recursive `Tick`/`Update`, simulation disposal, and chunk registration/removal are rejected during a callback because +they would invalidate or escape the current chunk snapshot. Perform those lifecycle operations outside the solver +tick. Solver-specific settings should remain with the solver instead of expanding `AtmosConfig` with unrelated game configuration. Implement `IAtmosSolver` to make that ownership explicit: @@ -400,8 +423,9 @@ reactionSolver.Config.Rate = 0.5f; ``` The pipeline retains the solver instance through its callback, so its typed configuration remains editable after -registration. `AtmosSolverContext.Config` still exposes the simulation-wide physical configuration for stages that -need it. Dangerous solvers can implement `IAtmosDangerousSolver` for the same ownership pattern; only state +registration. It does not own or dispose custom solvers; callers remain responsible for an `IDisposable` solver's +lifetime. `AtmosSolverContext.Config` exposes the simulation-wide physical configuration reference captured for the +tick. Dangerous solvers can implement `IAtmosDangerousSolver` for the same ownership pattern; only state access, not configuration ownership, determines which package a custom stage belongs in. Solvers that have a measured need to avoid snapshot copies can opt into live storage through the separate dangerous @@ -453,7 +477,10 @@ This is the core fluid dynamics step. It runs in parallel across chunks. 4. **Apply deltas**: After all voxels have been processed, the accumulated mole deltas are applied and per-species amounts below `AtmosSolverConstants.MinimumTrackedMoles` (currently 0.0001 mol) are snapped to 0. Each voxel's heat capacity is recalculated from its new composition, then its temperature is recovered from `newTemperature = (oldTotalHeatCapacity * oldEffectiveTemperature + energyDelta) / newTotalHeatCapacity`. A voxel with no heat capacity retains its stored temperature. The pressure cache is refreshed from the resulting moles and temperature before boundary processing. -5. **Emit boundary events**: If a voxel is on the edge of the chunk (coordinate is 0 or `Size - 1`) and has positive pressure at or above the normalized `VacuumThreshold`, a `BoundaryFlowEvent` is emitted for cross-chunk processing. +5. **Emit boundary events**: Every gas-bearing voxel that survives vacuum cleanup and lies on a chunk edge + (coordinate is 0 or `Size - 1`) emits one `BoundaryFlowEvent`. Eligibility is based on gas inventory rather than + a second positive-pressure check: an extremely small representable pressure can underflow to zero while the + species mole imbalance still supports diffusion. ### 4.4 Stage 2 — Cross-Chunk Boundary Flow @@ -478,7 +505,10 @@ For each boundary event: Each species carries `molesMoved * c_effective * sourceEffectiveTemperature` of sensible energy during the direct transfer. The source and target heat-capacity caches, temperatures, and pressures are updated immediately by energy balance. Before injection, the target voxel's existing heat capacity is recalculated from its current moles and the normalized gas registry captured for the tick, including for a target chunk that was sleeping before the transfer. -If the adjacent chunk is not registered or the mapped target is solid, no transfer occurs. A non-void target room is woken before it receives gas. A void target is an energy sink: transferred moles and their carried energy are removed from the source without being added to a target voxel. +If the adjacent chunk is not registered or the mapped target is solid, no transfer occurs. A non-void target room is +woken before it receives gas. Any source that moves gas is also kept awake with its sleep timer reset, because the +intra-chunk sleep scan cannot observe a cross-chunk gradient. A void target is an energy sink: transferred moles and +their carried energy are removed from the source without being added to a target voxel. ### 4.5 Stages 3 and 4 — Thermodynamics and Thermal Boundaries @@ -497,11 +527,16 @@ s_ij = min(1, C_i / G_i, C_j / G_j) Q_ij = s_ij * g_ij * (T_i - T_j) ``` -The first pass accumulates each voxel's incident conductance `G`; the second recomputes the same edges and buffers equal-and-opposite energy deltas. The symmetric scale ensures the total applied conductance at either endpoint cannot exceed that endpoint's heat capacity, so each result is a convex combination of the snapshot temperatures. This removes traversal-direction bias, conserves energy, and prevents new temperature extrema. Voxels with zero heat capacity do not participate and retain their stored temperature. +The first pass accumulates each voxel's incident conductance `G`; the second recomputes the same edges and buffers +equal-and-opposite energy deltas. Conductance sums and energy deltas use double-precision work storage to avoid +intermediate overflow, while the persistent result remains single precision. The symmetric scale ensures the total +applied conductance at either endpoint cannot exceed that endpoint's heat capacity, so each result is a convex +combination of the snapshot temperatures. This removes traversal-direction bias, conserves energy, and prevents new +temperature extrema. Voxels with zero heat capacity do not participate and retain their stored temperature. **Phase Changes (Condensation)**: See §8. These run after intra-chunk thermal temperatures have been applied and before thermal-boundary events are drained. -**Cross-Chunk Thermal Diffusion**: Boundary faces are deduplicated, their post-phase-change temperatures and heat capacities are snapshotted, and the same `g`, `G`, `s`, and `Q` equations are applied across the entire boundary set. Equal-and-opposite energy deltas are buffered before any boundary temperature is written, eliminating concurrent-queue traversal bias. Solid voxels block conduction, voxels below `VacuumThreshold` are excluded, and a missing adjacent chunk receives no heat. Depth-one chunks do not conduct through their Z faces. Thermal transfer can update a sleeping neighbor without waking it. +**Cross-Chunk Thermal Diffusion**: Boundary faces are deduplicated, their post-phase-change temperatures and heat capacities are snapshotted, and the same `g`, `G`, `s`, and `Q` equations are applied across the entire boundary set. Equal-and-opposite energy deltas are buffered before any boundary temperature is written, eliminating concurrent-queue traversal bias. Solid and void voxels do not conduct, voxels below `VacuumThreshold` are excluded, and a missing adjacent chunk receives no heat. Depth-one chunks do not conduct through their Z faces. Thermal transfer can update a sleeping neighbor without waking it. --- @@ -540,14 +575,21 @@ Voxels with `TotalPressure < VacuumThreshold` (1.0) have all gas moles zeroed ou ### 5.5 Delta Buffers (Ordering Scope) -Mole and sensible-energy transfers within a chunk are not applied directly during the neighbor scan. They are accumulated into a rented `float[]` whose first `VoxelCount` entries are the per-voxel energy-delta lane and whose remaining gas-major lanes hold mole deltas at `(gasIndex + 1) * VoxelCount + voxelIndex`. After every active source voxel has been scanned, the mole and energy deltas are applied together in a single pass. +Mole and sensible-energy transfers within a chunk are not applied directly during the neighbor scan. Gas-major mole +deltas are accumulated in a rented `float[]` at `gasIndex * VoxelCount + voxelIndex`. Equal-and-opposite sensible +energy deltas use a separate rented `double[]`, preventing a representable final temperature from being lost when an +intermediate `moles * C_v * temperature` exceeds the `float` range. After every active source voxel has been scanned, +the mole and energy deltas are applied together in a single pass and persistent state is stored as `float`. This buffering prevents an earlier voxel's applied result from changing the snapshot read by a later voxel, so results do not depend on active-voxel iteration order when the neighbor order is held fixed. It does not make every permutation equivalent: the separate `scheduledOutflows` safety cap is consumed in fixed neighbor order, as described in §5.1, and can favor earlier directions when a source saturates. -Both the delta array and the gas-major `scheduledOutflows` array are rented from `ArrayPool` and returned after application. +The mole-delta and gas-major `scheduledOutflows` arrays are rented from `ArrayPool`; the energy-delta array is +rented from `ArrayPool`. All are returned after application. > [!NOTE] -> Cross-chunk gas and thermal transfers do **not** use these buffers. They update current state immediately during sequential boundary processing, which introduces event-order dependence when multiple boundary events affect the same voxel. +> Cross-chunk gas flow is deterministic but sequential and updates current state immediately, so a later boundary +> event observes earlier transfers. Cross-chunk thermal diffusion is different: it deduplicates edges, snapshots +> their states, and buffers equal-and-opposite energy deltas before applying any temperature. --- @@ -564,6 +606,9 @@ A sleeping chunk is woken when: - `InjectGasToVoxel` is called on it (the sleep timer is reset). - A boundary flow event targets one of its voxels (the target room is woken via `WakeRoom`). +A chunk that sends gas across a boundary is kept awake and has its sleep timer reset. The sleep criterion itself is +pressure-based; a temperature gradient alone does not wake or keep a chunk active. + The sleep system is the primary mechanism for achieving the "work-proportional cost" goal. In a station with 500 chunks, only the handful with active pressure gradients consume CPU. Unit tests confirm convergence to sleep for L-shaped, donut-shaped, and zigzag room geometries, with pressure equilibrating to within 1.0 moles of the average across all voxels. @@ -638,32 +683,32 @@ For a registered species, phase-change processing first requires `CondensationEn if gasIsRegistered && CondensationEnabled && gasMoles > 0.01 && T_effective > 0: P_sat = SaturationReferencePressure * exp(-(MolarEnthalpyOfVaporization / R) * (1/T_effective - 1/T_boiling)) - currentPartialPressure = gasMoles * R * T_effective / VoxelVolume - if currentPartialPressure > P_sat: - excessPressure = currentPartialPressure - P_sat - requestedMoles = (excessPressure * VoxelVolume / (R * T_effective)) - * CondensationRateFactor - molesToCondense = min(gasMoles, requestedMoles) + saturationMoles = P_sat / ((R / VoxelVolume) * T_effective) + if gasMoles > saturationMoles: + molesToCondense = (gasMoles - saturationMoles) * CondensationRateFactor ``` Dividing molar vaporization enthalpy by `R` makes the exponential dimensionless. This integrated Clausius–Clapeyron form assumes ideal vapor and approximately constant vaporization enthalpy over the modeled temperature interval. Subject to the gates above, this model allows condensation at any temperature where the gas is supersaturated rather than only below a fixed temperature. Gas IDs without a registry entry, invalid boiling points, and invalid or nonpositive vaporization enthalpies are skipped. -The approximation and its assumptions match the integrated ideal-vapor derivation summarized in [NISTIR 5321](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir5321.pdf). +The direct mole-space calculation is algebraically equivalent to converting excess partial pressure back to moles, +but it avoids an overflow-prone pressure round trip for large inventories. The approximation and its assumptions +match the integrated ideal-vapor derivation summarized in [NISTIR 5321](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir5321.pdf). ### 8.2 Phase-Change Internal-Energy Balance -Condensation removes both the condensed gas's heat capacity and the sensible energy that gas carried. Clausius–Clapeyron uses vaporization enthalpy, but this is a constant-volume internal-energy balance, so the released energy per mole is approximated as `ΔU_vap = max(0, ΔH_vap - RT)`. Let `n_condensed` be the number of moles condensed, `c_effective` the species' effective molar `C_v`, and `C_before` the voxel's total heat capacity before condensation: +Condensation removes both the condensed gas's heat capacity and the sensible energy that gas carried. Clausius–Clapeyron uses vaporization enthalpy, but this is a constant-volume internal-energy balance, so the released energy per mole is approximated as `ΔU_vap = max(0, ΔH_vap - RT)`. Let `n_condensed` be the number of moles condensed and `C_after` the heat capacity recalculated from the remaining composition: ``` -C_after = max(0, C_before - n_condensed * c_effective) -E_after = T_effective * C_before - - T_effective * n_condensed * c_effective - + n_condensed * max(0, MolarEnthalpyOfVaporization - R * T_effective) +C_after = sum(remainingMoles[g] * c_effective[g]) if C_after > 0: - T_after = max(0, E_after / C_after) + T_after = max(0, T_effective + (n_condensed / C_after) * ΔU_vap) ``` -The temperature division is performed only when `C_after > 0`. The voxel's cached `TotalHeatCapacity` and `TotalPressure` are updated immediately. As elsewhere in the energy model, a non-finite or nonpositive configured `MolarHeatCapacityAtConstantVolume` uses the normalized `DefaultMolarHeatCapacityAtConstantVolume`. +This temperature form is the simplified constant-volume energy equation after the departing vapor's sensible energy +has canceled. It avoids computing and subtracting two potentially overflowing `T*C` terms. The temperature update is +performed only when `C_after > 0`. The voxel's cached `TotalHeatCapacity` and `TotalPressure` are updated immediately. +As elsewhere in the energy model, a non-finite or nonpositive configured `MolarHeatCapacityAtConstantVolume` uses the +normalized `DefaultMolarHeatCapacityAtConstantVolume`. Phase-change energy generally warms the remaining gas, which raises saturation pressure and slows further condensation. Accounting for both the ideal-gas `pV` term and the condensed gas's departing sensible energy avoids assigning enthalpy directly to a constant-volume internal-energy state. @@ -727,9 +772,15 @@ All networking methods are stubs with comments indicating where real implementat 2. **Unidirectional flow in advection.** The advection loop only processes flow from high pressure to low (`pressureDelta > 0`). Due to the delta buffer, each voxel-pair transfer is computed from the higher-pressure side and applied after the neighbor scan. +3. **Sleep is pressure-driven.** The chunk sleep criterion observes intra-chunk pressure deltas, not temperature +gradients. Thermal diffusion can update an already participating sleeping neighbor across a boundary, but a thermal +gradient alone does not wake a chunk or keep its thermodynamics stage active. + ### Performance -3. **Per-tick chunk snapshot via `.ToArray()`.** Each tick, the simulation calls `_chunkMap.Values.ToArray()` to snapshot the chunk collection. This allocates a new array every tick. For large chunk counts at 20 Hz, this generates significant GC pressure. +4. **Chunk snapshot allocation.** A direct `Tick` snapshots `_chunkMap.Values` with `.ToArray()`. `Update` performs one +snapshot for its batch of up to five fixed steps. Frequent direct ticks or updates with large chunk counts therefore +generate array-allocation pressure. --- diff --git a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs index 1d08c11..747520e 100644 --- a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs +++ b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs @@ -21,7 +21,7 @@ namespace Numos.API.Dangerous; /// /// This interface provides configuration ownership only. Its solver still receives live, unchecked storage /// and therefore has the same compatibility and invariant-maintenance responsibilities as -/// . +/// . Registration does not transfer ownership or disposal responsibility. /// public interface IAtmosDangerousSolver where TConfig : class { @@ -48,7 +48,8 @@ internal AtmosDangerousSolverContext(AtmosSolverExecutionContext context) /// The one-based tick number currently being solved. public int TickCount => _context.TickCount; - /// The mutable live configuration retained by the simulation. + /// The mutable configuration reference captured at the beginning of this tick. + /// Replacing the simulation configuration during this tick does not replace this reference. public AtmosConfig Config => _context.Configuration; /// The number of chunks in the tick snapshot. @@ -73,7 +74,7 @@ public void InjectGasToVoxel(int chunkIndex, ushort localVoxelIndex, int gasId, chunk.WakeRoom(roomId); GasInjectionSolver.InjectDuringTick( - chunk, localVoxelIndex, gasId, moles, temperature, _context.Config); + chunk, localVoxelIndex, gasId, moles, temperature, _context.TickConfig); } } @@ -98,7 +99,7 @@ internal AtmosDangerousChunk(AtmosChunk chunk) /// The number of addressable voxels. public int VoxelCount => _chunk.VoxelCount; - /// Whether built-in solver stages currently process the chunk. + /// Whether built-in stages that honor sleeping currently process this chunk. public bool IsAwake => _chunk.IsAwake; /// Gets or sets the unchecked sleep counter. @@ -145,13 +146,13 @@ public ushort GetVoxelIndex(int x, int y, int z) return _chunk.GetIndex(x, y, z); } - /// Wakes and activates a room using the kernel operation. + /// Wakes and activates a room using the chunk topology operation. public void WakeRoom(int roomId) { _chunk.WakeRoom(roomId); } - /// Puts the chunk to sleep using the kernel operation. + /// Puts the chunk to sleep using the chunk lifecycle operation. public void Sleep() { _chunk.Sleep(); diff --git a/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs b/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs index 20c31ce..84f31c9 100644 --- a/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs +++ b/src/Numos.API.Dangerous/AtmosDangerousSolverPipeline.cs @@ -7,6 +7,10 @@ namespace Numos.API.Dangerous; /// /// Registers custom stages that receive unchecked live simulation views. /// +/// +/// Pipeline edits made by a running stage take effect on the next tick. Registered custom solver instances +/// remain caller-owned and are not disposed by the pipeline. +/// public readonly struct AtmosDangerousSolverPipeline { private readonly AtmosSimulation _simulation; diff --git a/src/Numos.API/AtmosSimulation.cs b/src/Numos.API/AtmosSimulation.cs index e1018f0..718c9b1 100644 --- a/src/Numos.API/AtmosSimulation.cs +++ b/src/Numos.API/AtmosSimulation.cs @@ -13,7 +13,9 @@ namespace Numos.API; /// The simulation owns every chunk created through . Call /// when the simulation is no longer needed to release those chunks and its /// worker-local buffers. Unless otherwise noted, members that access kernel state throw -/// after disposal. +/// after disposal. A solver callback may use its context and edit the +/// solver pipeline, but it must not recursively execute or dispose the simulation or change chunk ownership +/// during the current tick. /// public sealed partial class AtmosSimulation : IDisposable { @@ -181,7 +183,8 @@ public long LastBoundaryTicks /// /// Releases all registered chunks and resources owned by the simulation. /// - /// Disposal is idempotent. + /// Disposal is idempotent outside solver execution. + /// Called from a solver callback. [PublicAPI] public void Dispose() { @@ -204,6 +207,7 @@ public void Dispose() /// update processes at most five fixed steps and discards time beyond that backlog limit. /// /// The simulation has been disposed. + /// Called recursively from a solver callback. [PublicAPI] public void Update(float elapsedSeconds) { @@ -220,9 +224,13 @@ public void Update(float elapsedSeconds) /// /// is . /// The simulation has been disposed. + /// Called recursively from a solver callback. [PublicAPI] public void Update(float elapsedSeconds, AtmosConfig config) { + ArgumentNullException.ThrowIfNull(config); + ThrowIfDisposed(); + _kernel.EnsureCanExecuteTick(); SetAtmosConfig(config); Update(elapsedSeconds); } @@ -261,7 +269,9 @@ public void SetAtmosConfig(AtmosConfig config) /// access to mutable kernel state. /// /// is zero or negative. - /// A chunk is already registered at . + /// + /// A chunk is already registered at , or this is called from a solver callback. + /// /// The simulation has been disposed. [PublicAPI] public AtmosChunkHandle CreateAndRegisterChunk( @@ -284,6 +294,7 @@ public AtmosChunkHandle CreateAndRegisterChunk( /// position. Callers are responsible for keeping handles associated with their owning simulation. /// /// The simulation has been disposed. + /// Called from a solver callback. [PublicAPI] public bool UnregisterChunk(AtmosChunkHandle chunk) { @@ -544,8 +555,8 @@ public void SetVoxelClassification(AtmosChunkHandle chunk, int x, int y, int z, /// The raw temperature value to store, in kelvins. /// /// The supplied value is stored without validation or eager normalization. Snapshots expose that raw value - /// until a later operation overwrites it. Pressure and sensible-energy calculations treat a gas-bearing - /// voxel's non-finite or nonpositive stored value as + /// until a later operation overwrites it. The pressure cache is refreshed immediately; pressure and + /// sensible-energy calculations treat a gas-bearing voxel's non-finite or nonpositive stored value as /// for that calculation. /// /// is outside the chunk. @@ -568,8 +579,8 @@ public void SetVoxelTemperature(AtmosChunkHandle chunk, ushort localVoxelIndex, /// The raw temperature value to store, in kelvins. /// /// The supplied value is stored without validation or eager normalization. Snapshots expose that raw value - /// until a later operation overwrites it. Pressure and sensible-energy calculations treat a gas-bearing - /// voxel's non-finite or nonpositive stored value as + /// until a later operation overwrites it. The pressure cache is refreshed immediately; pressure and + /// sensible-energy calculations treat a gas-bearing voxel's non-finite or nonpositive stored value as /// for that calculation. /// /// A local coordinate is outside the chunk. @@ -694,6 +705,7 @@ public void SleepChunk(AtmosChunkHandle chunk) /// increments by one. /// /// The simulation has been disposed. + /// Called recursively from a solver callback. [PublicAPI] public void Tick() { diff --git a/src/Numos.API/AtmosSolver.cs b/src/Numos.API/AtmosSolver.cs index 075323f..f87d117 100644 --- a/src/Numos.API/AtmosSolver.cs +++ b/src/Numos.API/AtmosSolver.cs @@ -18,7 +18,9 @@ namespace Numos.API; /// /// Keep game- or solver-specific settings here rather than adding them to the simulation-wide /// . The pipeline retains the solver instance through its registered -/// callback, so callers may edit after registration. +/// callback, so callers may edit after registration. Registration does not transfer +/// ownership: if the implementation is disposable, the caller remains responsible for disposing it after +/// unregistering it or disposing the simulation. /// public interface IAtmosSolver where TConfig : class { diff --git a/src/Numos.API/AtmosSolverContext.cs b/src/Numos.API/AtmosSolverContext.cs index 32c2e04..6810a35 100644 --- a/src/Numos.API/AtmosSolverContext.cs +++ b/src/Numos.API/AtmosSolverContext.cs @@ -10,7 +10,8 @@ namespace Numos.API; /// /// /// Reads are detached snapshots and writes use the same validation as . The -/// chunk list is captured at the beginning of the tick; registration changes apply to the next tick. +/// chunk list is fixed for the direct tick or fixed-step update batch. Chunk lifecycle changes are not allowed +/// from a solver callback. /// public sealed class AtmosSolverContext { @@ -21,14 +22,20 @@ internal AtmosSolverContext(AtmosSimulation simulation, AtmosSolverExecutionCont { _simulation = simulation; TickCount = context.TickCount; + Config = context.Configuration; _chunks = context.Chunks.Select(static chunk => new AtmosChunkHandle(chunk.GridPosition)).ToArray(); } /// The one-based tick number currently being solved. public int TickCount { get; } - /// The simulation's live configuration. - public AtmosConfig Config => _simulation.Config; + /// The mutable configuration reference captured at the beginning of this tick. + /// + /// Replacing the simulation configuration during a callback does not change the reference observed by + /// later callbacks in the same tick. Built-in stages use a normalized snapshot captured before any + /// callback ran, so configuration edits take effect on the next tick. + /// + public AtmosConfig Config { get; } /// Chunks captured for the current tick. public IReadOnlyList Chunks => _chunks; @@ -52,7 +59,7 @@ public void SetVoxelClassification(AtmosChunkHandle chunk, ushort localVoxelInde _simulation.SetVoxelClassification(chunk, localVoxelIndex, classification); } - /// Changes one voxel temperature through the validated API. + /// Changes one voxel temperature and refreshes its pressure cache through the validated API. public void SetVoxelTemperature(AtmosChunkHandle chunk, ushort localVoxelIndex, float temperature) { _simulation.SetVoxelTemperature(chunk, localVoxelIndex, temperature); diff --git a/src/Numos.API/AtmosSolverPipeline.cs b/src/Numos.API/AtmosSolverPipeline.cs index 6f976b5..fbe9576 100644 --- a/src/Numos.API/AtmosSolverPipeline.cs +++ b/src/Numos.API/AtmosSolverPipeline.cs @@ -6,6 +6,11 @@ namespace Numos.API; /// /// Configures the ordered solver stages executed by an . /// +/// +/// The enabled stage list is snapshotted before each tick. Registration, removal, and enablement changes made +/// by a running solver therefore take effect on the next tick. Registered custom solver instances remain +/// caller-owned; this pipeline does not dispose them. +/// public sealed class AtmosSolverPipeline { private readonly AtmosSimulation _simulation; diff --git a/src/Numos.CoreSim/AtmosKernel.API.cs b/src/Numos.CoreSim/AtmosKernel.API.cs index 576cf8f..20ca0b2 100644 --- a/src/Numos.CoreSim/AtmosKernel.API.cs +++ b/src/Numos.CoreSim/AtmosKernel.API.cs @@ -137,6 +137,7 @@ internal void Update(float elapsedSeconds) { lock (_stateGate) { + ThrowIfTickExecuting("update the simulation recursively"); _accumulator += elapsedSeconds; if (_accumulator > AtmosSolverConstants.FixedTimeStep * AtmosSolverConstants.MaximumStepsPerUpdate) @@ -147,9 +148,9 @@ internal void Update(float elapsedSeconds) LastBoundaryTicks = 0; - // Snapshot chunks + // One elapsed-time update is one externally atomic batch. Solver callbacks may edit the pipeline, but + // chunk lifecycle changes are rejected until the batch completes. var chunks = _chunkMap.Values.ToArray(); - var steps = 0; while (_accumulator >= AtmosSolverConstants.FixedTimeStep && steps < AtmosSolverConstants.MaximumStepsPerUpdate) @@ -161,6 +162,15 @@ internal void Update(float elapsedSeconds) } } + /// Rejects public operations that would begin another tick from a running solver callback. + internal void EnsureCanExecuteTick() + { + lock (_stateGate) + { + ThrowIfTickExecuting("update the simulation recursively"); + } + } + /// /// Replaces the live configuration used by subsequent simulation ticks. /// @@ -200,6 +210,7 @@ internal bool UnregisterChunk(Int3 position) { lock (_stateGate) { + ThrowIfTickExecuting("unregister a chunk used by the current tick"); if (!_chunkMap.TryRemove(position, out var chunk)) return false; @@ -222,6 +233,7 @@ internal void CreateAndRegisterChunk(Int3 position, int width, int height, int d { lock (_stateGate) { + ThrowIfTickExecuting("register a chunk during the current tick"); var chunk = new AtmosChunk(width, height, depth, maxActiveRooms); chunk.Initialize(position, width, height, depth, maxActiveRooms); RegisterChunk(chunk); @@ -489,6 +501,8 @@ internal void SetVoxelTemperature(Int3 position, ushort localVoxelIndex, float t var chunk = GetChunk(position); ValidateVoxelIndex(chunk, localVoxelIndex); chunk.Temperature[localVoxelIndex] = temperature; + chunk.TotalPressure[localVoxelIndex] = + AtmosSolverMath.CalculatePressureAtVoxel(_config, chunk, localVoxelIndex); chunk.MarkChanged(); } } @@ -532,7 +546,11 @@ internal void AddGasToVoxel(Int3 position, ushort localVoxelIndex, int gasId, fl ValidateVoxelIndex(chunk, localVoxelIndex); ValidateGasInjection(gasId, moles, temperature); - chunk.WakeRoom(chunk.VoxelRoomMap[localVoxelIndex]); + int roomId = chunk.VoxelRoomMap[localVoxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return; + + chunk.WakeRoom(roomId); GasInjectionSolver.Inject(chunk, localVoxelIndex, gasId, moles, temperature, _config); } } @@ -655,4 +673,4 @@ private static void ValidateGasInjection(int gasId, float moles, float temperatu "Temperature must be nonnegative and finite."); } } -} +} \ No newline at end of file diff --git a/src/Numos.CoreSim/AtmosKernel.cs b/src/Numos.CoreSim/AtmosKernel.cs index 85cd984..22f4fec 100644 --- a/src/Numos.CoreSim/AtmosKernel.cs +++ b/src/Numos.CoreSim/AtmosKernel.cs @@ -17,6 +17,7 @@ internal sealed partial class AtmosKernel : IDisposable, IAtmosSolverWorld private float _accumulator; private long _chunkCollectionRevision; private AtmosConfig _config = new(); + private bool _isTickExecuting; /// /// High-resolution timestamp ticks spent processing boundary flow since the latest elapsed-time update. @@ -39,6 +40,7 @@ public void Dispose() { lock (_stateGate) { + ThrowIfTickExecuting("dispose the simulation"); foreach (var chunk in _chunkMap.Values) chunk.Release(); @@ -49,17 +51,32 @@ public void Dispose() private void TickSimulation(AtmosChunk[] chunks) { - _tickConfig.Capture(_config); - TickCount++; + ThrowIfTickExecuting("run a recursive simulation tick"); + _isTickExecuting = true; + try + { + _tickConfig.Capture(_config); + TickCount++; + + foreach (var chunk in chunks) + { + if (chunk.IsAwake) + chunk.MarkChanged(); + } - foreach (var chunk in chunks) + var context = new AtmosSolverExecutionContext(this, chunks, _tickConfig, _config, TickCount); + _solverPipeline.Execute(context); + } + finally { - if (chunk.IsAwake) - chunk.MarkChanged(); + _isTickExecuting = false; } + } - var context = new AtmosSolverExecutionContext(this, chunks, _tickConfig, _config, TickCount); - _solverPipeline.Execute(context); + private void ThrowIfTickExecuting(string operation) + { + if (_isTickExecuting) + throw new InvalidOperationException($"A solver callback cannot {operation}."); } bool IAtmosSolverWorld.TryGetChunk(Int3 position, out AtmosChunk chunk) diff --git a/src/Numos.CoreSim/GasProperties.cs b/src/Numos.CoreSim/GasProperties.cs index 7b6e03c..70f44ec 100644 --- a/src/Numos.CoreSim/GasProperties.cs +++ b/src/Numos.CoreSim/GasProperties.cs @@ -41,8 +41,12 @@ public struct GasProperties public float MolarEnthalpyOfVaporization; /// - /// ID of the liquid this gas condenses to. Currently unused but can be passed to a separate fluid sim. + /// Reserved ID for a liquid produced by condensation. /// + /// + /// Numos currently removes condensed vapor without producing liquid state or an event, so this field is + /// not consumed by the built-in solver. A custom liquid integration may interpret it. + /// /// TODO FAR FUTURE fluid sim :godo: public int LiquidId; @@ -51,4 +55,4 @@ public struct GasProperties /// /// Values are clamped to [0, 1]; non-finite values disable diffusion for this species. public float DiffusionCoefficient; -} +} \ No newline at end of file diff --git a/src/Numos.CoreSim/Solvers/AdvectionSolver.cs b/src/Numos.CoreSim/Solvers/AdvectionSolver.cs index 44d5255..949e830 100644 --- a/src/Numos.CoreSim/Solvers/AdvectionSolver.cs +++ b/src/Numos.CoreSim/Solvers/AdvectionSolver.cs @@ -9,7 +9,7 @@ namespace Numos.CoreSim.Solvers; /// /// Solves parallel intra-chunk pressure advection and per-species diffusion. /// -internal sealed class AdvectionSolver : IAtmosSolver, IDisposable +internal sealed class AdvectionSolver : IAtmosSolverStage, IDisposable { private readonly ThreadLocal _boundaryBuffers; @@ -37,7 +37,7 @@ private void SolveChunk(AtmosSolverExecutionContext context, AtmosChunk chunk) BoundaryFlowEvent[]? boundaryBuffer = _boundaryBuffers.Value; Debug.Assert(boundaryBuffer != null); var boundaryCount = 0; - Advect(chunk, context.Config, boundaryBuffer, ref boundaryCount); + Advect(chunk, context.TickConfig, boundaryBuffer, ref boundaryCount); for (var index = 0; index < boundaryCount; index++) context.BoundaryEvents.Enqueue((chunk.GridPosition, boundaryBuffer[index])); @@ -61,10 +61,12 @@ private static void ProcessActiveVoxels(AtmosChunk chunk, AtmosSolverConfigSnaps BoundaryFlowEvent[] boundaryBuffer, ref int boundaryEventCount, ref float maximumPressureDelta) { int activeGasCount = chunk.ActiveGasCount; - int deltaLength = GetDeltaArrayOffset(activeGasCount, chunk.VoxelCount); - float[] deltas = ArrayPool.Shared.Rent(deltaLength); + int moleDeltaLength = activeGasCount * chunk.VoxelCount; + float[] moleDeltas = ArrayPool.Shared.Rent(moleDeltaLength); + double[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); float[] scheduledOutflows = ArrayPool.Shared.Rent(activeGasCount * chunk.VoxelCount); - Array.Clear(deltas, 0, deltaLength); + Array.Clear(moleDeltas, 0, moleDeltaLength); + Array.Clear(energyDeltas, 0, chunk.VoxelCount); Array.Clear(scheduledOutflows, 0, activeGasCount * chunk.VoxelCount); try @@ -85,44 +87,47 @@ private static void ProcessActiveVoxels(AtmosChunk chunk, AtmosSolverConfigSnaps Int3 position = chunk.GetXyzInt3(voxelIndex); ProcessNeighbors(chunk, config, position, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, deltas, scheduledOutflows); - TryAppendBoundaryEvent(chunk, position, voxelIndex, currentPressure, config.VacuumThreshold, - boundaryBuffer, ref boundaryEventCount); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); + TryAppendBoundaryEvent(chunk, position, voxelIndex, boundaryBuffer, + ref boundaryEventCount); } - ApplyDeltas(chunk, config, deltas); + ApplyDeltas(chunk, config, moleDeltas, energyDeltas); } finally { ArrayPool.Shared.Return(scheduledOutflows); - ArrayPool.Shared.Return(deltas); + ArrayPool.Shared.Return(energyDeltas); + ArrayPool.Shared.Return(moleDeltas); } } private static void ProcessNeighbors(AtmosChunk chunk, AtmosSolverConfigSnapshot config, Int3 position, ushort voxelIndex, float currentPressure, float totalMoles, - ref float maximumPressureDelta, float[] deltas, float[] scheduledOutflows) + ref float maximumPressureDelta, float[] moleDeltas, double[] energyDeltas, + float[] scheduledOutflows) { CheckNeighbor(chunk, config, position + Int3.NegX, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, deltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); CheckNeighbor(chunk, config, position + Int3.PosX, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, deltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); CheckNeighbor(chunk, config, position + Int3.NegY, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, deltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); CheckNeighbor(chunk, config, position + Int3.PosY, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, deltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); if (chunk.Depth <= 1) return; CheckNeighbor(chunk, config, position + Int3.NegZ, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, deltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); CheckNeighbor(chunk, config, position + Int3.PosZ, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, deltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); } private static void CheckNeighbor(AtmosChunk chunk, AtmosSolverConfigSnapshot config, Int3 neighborPosition, ushort voxelIndex, float currentPressure, float totalMoles, - ref float maximumPressureDelta, float[] deltas, float[] scheduledOutflows) + ref float maximumPressureDelta, float[] moleDeltas, double[] energyDeltas, + float[] scheduledOutflows) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; @@ -145,37 +150,36 @@ private static void CheckNeighbor(AtmosChunk chunk, AtmosSolverConfigSnapshot co float neighborTemperature = isVoid ? 0f : config.GetEffectiveTemperature(chunk.Temperature[neighborIndex]); - float temperatureRatio = neighborTemperature / sourceTemperature; - for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { int gasId = chunk.ActiveGases[gas].GasId; float sourceMoles = chunk.ActiveGases[gas].Moles[voxelIndex]; float molesAdvected = advectedMoles * (sourceMoles / totalMoles); float neighborMoles = isVoid ? 0f : chunk.ActiveGases[gas].Moles[neighborIndex]; - float moleImbalance = sourceMoles - neighborMoles * temperatureRatio; + float moleImbalance = AtmosSolverMath.CalculateMoleImbalance( + sourceMoles, sourceTemperature, neighborMoles, neighborTemperature); float molesDiffused = moleImbalance > 0f ? moleImbalance * config.GetDiffusionCoefficient(gasId) : 0f; int outflowOffset = gas * chunk.VoxelCount + voxelIndex; - float remainingMoles = MathF.Max(0f, sourceMoles - scheduledOutflows[outflowOffset]); + float remainingMoles = sourceMoles - scheduledOutflows[outflowOffset]; float molesToMove = MathF.Min(remainingMoles, molesAdvected + molesDiffused); if (molesToMove <= 0f) continue; scheduledOutflows[outflowOffset] += molesToMove; - float energyTransferred = molesToMove * - config.GetMolarHeatCapacityAtConstantVolume(gasId) * - sourceTemperature; - int deltaOffset = GetDeltaArrayOffset(gas, chunk.VoxelCount); - deltas[deltaOffset + voxelIndex] -= molesToMove; - deltas[voxelIndex] -= energyTransferred; + double energyTransferred = (double)molesToMove * + config.GetMolarHeatCapacityAtConstantVolume(gasId) * + sourceTemperature; + int deltaOffset = gas * chunk.VoxelCount; + moleDeltas[deltaOffset + voxelIndex] -= molesToMove; + energyDeltas[voxelIndex] -= energyTransferred; if (isVoid) continue; - deltas[deltaOffset + neighborIndex] += molesToMove; - deltas[neighborIndex] += energyTransferred; + moleDeltas[deltaOffset + neighborIndex] += molesToMove; + energyDeltas[neighborIndex] += energyTransferred; } } @@ -205,21 +209,22 @@ private static void RefreshPressureAndHeatCapacity(AtmosChunk chunk, AtmosSolver } } - private static void ApplyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, float[] deltas) + private static void ApplyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + float[] moleDeltas, double[] energyDeltas) { for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; - float oldEnergy = config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]) * - chunk.TotalHeatCapacity[voxelIndex]; - bool stateChanged = deltas[voxelIndex] != 0f; + double oldEnergy = (double)config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]) * + chunk.TotalHeatCapacity[voxelIndex]; + bool stateChanged = energyDeltas[voxelIndex] != 0d; chunk.TotalHeatCapacity[voxelIndex] = 0f; var totalMoles = 0f; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { - int offset = GetDeltaArrayOffset(gas, chunk.VoxelCount); - float moleDelta = deltas[offset + voxelIndex]; + int offset = gas * chunk.VoxelCount; + float moleDelta = moleDeltas[offset + voxelIndex]; stateChanged |= moleDelta != 0f; float moles = chunk.ActiveGases[gas].Moles[voxelIndex] + moleDelta; if (moles < AtmosSolverConstants.MinimumTrackedMoles) @@ -233,7 +238,8 @@ private static void ApplyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot conf if (stateChanged && chunk.TotalHeatCapacity[voxelIndex] > 0f) { chunk.Temperature[voxelIndex] = MathF.Max(0f, - (oldEnergy + deltas[voxelIndex]) / chunk.TotalHeatCapacity[voxelIndex]); + (float)((oldEnergy + energyDeltas[voxelIndex]) / + chunk.TotalHeatCapacity[voxelIndex])); } chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressure( @@ -242,16 +248,15 @@ private static void ApplyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot conf } private static void TryAppendBoundaryEvent(AtmosChunk chunk, Int3 position, ushort voxelIndex, - float currentPressure, float vacuumThreshold, BoundaryFlowEvent[] buffer, ref int count) + BoundaryFlowEvent[] buffer, ref int count) { bool isBoundary = position.X == 0 || position.X == chunk.Width - 1 || position.Y == 0 || position.Y == chunk.Height - 1 || chunk.Depth > 1 && (position.Z == 0 || position.Z == chunk.Depth - 1); - if (!isBoundary || currentPressure < vacuumThreshold || currentPressure <= 0f) + if (!isBoundary) return; - if (count >= buffer.Length) - throw new InvalidOperationException("Boundary flow event buffer capacity was exceeded."); + // DefaultAtmosSolvers allocates one slot for every geometrically distinct boundary voxel. buffer[count++] = new BoundaryFlowEvent { LocalVoxelIndex = voxelIndex }; } @@ -285,8 +290,4 @@ private static void UpdateSleepState(AtmosChunk chunk, AtmosSolverConfigSnapshot chunk.Sleep(); } - private static int GetDeltaArrayOffset(int gasIndex, int voxelCount) - { - return (gasIndex + 1) * voxelCount; - } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs b/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs index 4f719a5..b896e48 100644 --- a/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs +++ b/src/Numos.CoreSim/Solvers/AtmosSolverExecutionContext.cs @@ -5,7 +5,7 @@ namespace Numos.CoreSim.Solvers; /// -/// Stable inputs shared by every solver stage in one tick. +/// Tick-scoped inputs shared by every solver stage. /// internal sealed class AtmosSolverExecutionContext { @@ -14,14 +14,17 @@ internal AtmosSolverExecutionContext(IAtmosSolverWorld world, AtmosChunk[] chunk { World = world; Chunks = chunks; - Config = config; + TickConfig = config; Configuration = configuration; TickCount = tickCount; } internal IAtmosSolverWorld World { get; } internal AtmosChunk[] Chunks { get; } - internal AtmosSolverConfigSnapshot Config { get; } + /// Normalized built-in solver settings captured before this tick began. + internal AtmosSolverConfigSnapshot TickConfig { get; } + + /// The live public configuration reference captured before this tick began. internal AtmosConfig Configuration { get; } internal int TickCount { get; } internal ConcurrentQueue<(Int3 Key, BoundaryFlowEvent Event)> BoundaryEvents { get; } = new(); @@ -35,4 +38,4 @@ internal interface IAtmosSolverWorld { bool TryGetChunk(Int3 position, out AtmosChunk chunk); void AddBoundaryProcessingTicks(long elapsedTicks); -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs b/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs index 545f901..98d3400 100644 --- a/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs +++ b/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs @@ -48,13 +48,13 @@ internal static float CalculatePressure(AtmosConfig config, float moles, float t internal static float CalculatePressure(AtmosSolverConfigSnapshot config, float moles, float temperature) { - return MathF.Max(0f, moles) * config.GetEffectiveTemperature(temperature) * - config.PressurePerMoleKelvin; + Debug.Assert(float.IsFinite(moles) && moles >= 0f); + return moles * config.GetEffectiveTemperature(temperature) * config.PressurePerMoleKelvin; } internal static float PressureToMoles(AtmosSolverConfigSnapshot config, float pressure, float temperature) { - if (!IsFinitePositive(pressure)) + if (pressure <= 0f || float.IsNaN(pressure)) return 0f; float denominator = config.PressurePerMoleKelvin * config.GetEffectiveTemperature(temperature); @@ -66,7 +66,18 @@ internal static float CalculatePressureAtVoxel(AtmosSolverConfigSnapshot config, { var totalMoles = 0f; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) - totalMoles += MathF.Max(0f, chunk.ActiveGases[gas].Moles[localVoxelIndex]); + totalMoles += chunk.ActiveGases[gas].Moles[localVoxelIndex]; + + return CalculatePressure(config, totalMoles, chunk.Temperature[localVoxelIndex]); + } + + /// Recalculates a voxel pressure using the normalized values in a live public configuration. + internal static float CalculatePressureAtVoxel(AtmosConfig config, AtmosChunk chunk, + ushort localVoxelIndex) + { + var totalMoles = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + totalMoles += chunk.ActiveGases[gas].Moles[localVoxelIndex]; return CalculatePressure(config, totalMoles, chunk.Temperature[localVoxelIndex]); } @@ -104,6 +115,24 @@ internal static float CalculateBulkPressureTransfer(AtmosSolverConfigSnapshot co return MathF.Min(pressureTransfer, currentPressure * maximumFraction); } + /// + /// Returns the source-relative species imbalance used by explicit Fickian diffusion. + /// + internal static float CalculateMoleImbalance(float sourceMoles, float sourceTemperature, + float targetMoles, float targetTemperature) + { + Debug.Assert(sourceMoles >= 0f && targetMoles >= 0f); + Debug.Assert(IsFinitePositive(sourceTemperature)); + + // Mathematically an empty target contributes zero regardless of the temperature ratio. Handling it first + // prevents 0 * infinity from turning a valid outward imbalance into NaN at extreme temperatures. + if (targetMoles == 0f) + return sourceMoles; + + Debug.Assert(IsFinitePositive(targetTemperature)); + return sourceMoles - targetMoles * (targetTemperature / sourceTemperature); + } + internal static float CalculateThermalConductance(float sourceHeatCapacity, float targetHeatCapacity, float thermalConductance) { @@ -131,4 +160,4 @@ internal static bool IsFinitePositive(float value) { return float.IsFinite(value) && value > 0f; } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs b/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs index 5226394..4bbcf89 100644 --- a/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs +++ b/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs @@ -8,7 +8,7 @@ namespace Numos.CoreSim.Solvers; /// /// Applies deterministic, sequential gas flow across chunk boundaries. /// -internal sealed class BoundaryFlowSolver : IAtmosSolver +internal sealed class BoundaryFlowSolver : IAtmosSolverStage { private readonly List<(Int3 Key, BoundaryFlowEvent Event)> _orderedEvents = []; @@ -59,12 +59,16 @@ private static void TryFlowToNeighbor(AtmosSolverExecutionContext context, Atmos return; ushort sourceIndex = sourceChunk.GetIndex(targetPosition - direction); + int sourceRoom = sourceChunk.VoxelRoomMap[sourceIndex]; + if (sourceRoom == VoxelClassification.RoomSolid || sourceRoom == VoxelClassification.RoomVoid) + return; + float sourcePressure = sourceChunk.TotalPressure[sourceIndex]; bool isVoid = neighborRoom == VoxelClassification.RoomVoid; float neighborPressure = isVoid ? 0f : neighborChunk.TotalPressure[neighborIndex]; float pressureDelta = sourcePressure - neighborPressure; float bulkPressureTransfer = pressureDelta > 0f - ? AtmosSolverMath.CalculateBulkPressureTransfer(context.Config, pressureDelta, sourcePressure) + ? AtmosSolverMath.CalculateBulkPressureTransfer(context.TickConfig, pressureDelta, sourcePressure) : 0f; float totalMoles = GetTotalMoles(sourceChunk, sourceIndex); @@ -79,12 +83,11 @@ private static void TransferSpecies(AtmosSolverExecutionContext context, AtmosCh ushort sourceIndex, AtmosChunk neighborChunk, ushort neighborIndex, bool isVoid, float totalMoles, float bulkPressureTransfer) { - AtmosSolverConfigSnapshot config = context.Config; + AtmosSolverConfigSnapshot config = context.TickConfig; float sourceTemperature = config.GetEffectiveTemperature(sourceChunk.Temperature[sourceIndex]); float neighborTemperature = isVoid ? 0f : config.GetEffectiveTemperature(neighborChunk.Temperature[neighborIndex]); - float temperatureRatio = neighborTemperature / sourceTemperature; float advectedMoles = AtmosSolverMath.PressureToMoles( config, bulkPressureTransfer, sourceTemperature); var movedGas = false; @@ -94,8 +97,9 @@ private static void TransferSpecies(AtmosSolverExecutionContext context, AtmosCh int gasId = sourceChunk.ActiveGases[gas].GasId; float sourceMoles = sourceChunk.ActiveGases[gas].Moles[sourceIndex]; float molesAdvected = advectedMoles * (sourceMoles / totalMoles); - float moleImbalance = sourceMoles - - GetGasMoles(neighborChunk, neighborIndex, gasId, isVoid) * temperatureRatio; + float moleImbalance = AtmosSolverMath.CalculateMoleImbalance( + sourceMoles, sourceTemperature, + GetGasMoles(neighborChunk, neighborIndex, gasId, isVoid), neighborTemperature); float molesDiffused = moleImbalance > 0f ? moleImbalance * config.GetDiffusionCoefficient(gasId) : 0f; @@ -105,7 +109,7 @@ private static void TransferSpecies(AtmosSolverExecutionContext context, AtmosCh float transferredHeatCapacity = molesToMove * config.GetMolarHeatCapacityAtConstantVolume(gasId); - sourceChunk.ActiveGases[gas].Moles[sourceIndex] = MathF.Max(0f, sourceMoles - molesToMove); + sourceChunk.ActiveGases[gas].Moles[sourceIndex] = sourceMoles - molesToMove; sourceChunk.TotalHeatCapacity[sourceIndex] = MathF.Max(0f, sourceChunk.TotalHeatCapacity[sourceIndex] - transferredHeatCapacity); movedGas = true; @@ -125,6 +129,11 @@ private static void TransferSpecies(AtmosSolverExecutionContext context, AtmosCh sourceChunk.Temperature[sourceIndex] = sourceTemperature; sourceChunk.TotalPressure[sourceIndex] = AtmosSolverMath.CalculatePressure( config, GetTotalMoles(sourceChunk, sourceIndex), sourceTemperature); + // Intra-chunk sleep detection cannot see cross-chunk gradients. A boundary transfer therefore keeps + // its source eligible for the next tick, just as injection keeps the target awake. + sourceChunk.IsAwake = true; + sourceChunk.SleepTimer = 0; + sourceChunk.MarkChanged(); } private static float GetGasMoles(AtmosChunk chunk, ushort voxelIndex, int gasId, bool isVoid) @@ -158,4 +167,4 @@ private static int CompareEvents( ? comparison : left.Event.LocalVoxelIndex.CompareTo(right.Event.LocalVoxelIndex); } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/DefaultAtmosSolvers.cs b/src/Numos.CoreSim/Solvers/DefaultAtmosSolvers.cs index 30d7829..ee499e6 100644 --- a/src/Numos.CoreSim/Solvers/DefaultAtmosSolvers.cs +++ b/src/Numos.CoreSim/Solvers/DefaultAtmosSolvers.cs @@ -12,8 +12,7 @@ internal sealed class DefaultAtmosSolvers : IDisposable internal DefaultAtmosSolvers(int chunkWidth, int chunkHeight, int chunkDepth) { - int maximumBoundaryEvents = checked(2 * - (chunkWidth * chunkHeight + chunkWidth * chunkDepth + chunkHeight * chunkDepth)); + int maximumBoundaryEvents = GetBoundaryVoxelCount(chunkWidth, chunkHeight, chunkDepth); _advection = new AdvectionSolver(maximumBoundaryEvents); _thermodynamics = new ThermodynamicsSolver(maximumBoundaryEvents); } @@ -34,4 +33,14 @@ public void Dispose() _advection.Dispose(); _thermodynamics.Dispose(); } -} \ No newline at end of file + + private static int GetBoundaryVoxelCount(int width, int height, int depth) + { + int voxelCount = checked(width * height * depth); + int interiorWidth = Math.Max(0, width - 2); + int interiorHeight = Math.Max(0, height - 2); + int interiorDepth = depth > 1 ? Math.Max(0, depth - 2) : 1; + int interiorVoxelCount = checked(interiorWidth * interiorHeight * interiorDepth); + return voxelCount - interiorVoxelCount; + } +} diff --git a/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs b/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs index cb1ea08..d94ed33 100644 --- a/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs +++ b/src/Numos.CoreSim/Solvers/GasInjectionSolver.cs @@ -1,18 +1,17 @@ -using Numos.CoreSim.Datatypes.Primitives; - namespace Numos.CoreSim.Solvers; /// /// Applies one gas injection while keeping mixture SHC, temperature, and pressure coherent. /// +/// +/// Callers validate the target and wake its room before entry. +/// remains the single invariant guard at the storage boundary. +/// internal static class GasInjectionSolver { internal static void Inject(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, float temperature, AtmosConfig config) { - if (!CanInject(chunk, localVoxelIndex)) - return; - float currentHeatCapacity = 0f; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { @@ -32,9 +31,6 @@ internal static void Inject(AtmosChunk chunk, ushort localVoxelIndex, int gasId, internal static void InjectDuringTick(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, float temperature, AtmosSolverConfigSnapshot config) { - if (!CanInject(chunk, localVoxelIndex)) - return; - float currentHeatCapacity = 0f; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { @@ -50,15 +46,6 @@ internal static void InjectDuringTick(AtmosChunk chunk, ushort localVoxelIndex, config.GetEffectiveTemperature(chunk.Temperature[localVoxelIndex]), config.PressurePerMoleKelvin); } - private static bool CanInject(AtmosChunk chunk, ushort localVoxelIndex) - { - if (!chunk.IsAwake) - return false; - - int room = chunk.VoxelRoomMap[localVoxelIndex]; - return room != VoxelClassification.RoomSolid && room != VoxelClassification.RoomVoid; - } - private static void InjectCore(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles, float temperature, float molarHeatCapacity, float currentHeatCapacity, float effectiveCurrentTemperature, float pressurePerMoleKelvin) @@ -71,4 +58,4 @@ private static void InjectCore(AtmosChunk chunk, ushort localVoxelIndex, int gas pressurePerMoleKelvin); } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/IAtmosSolver.cs b/src/Numos.CoreSim/Solvers/IAtmosSolverStage.cs similarity index 52% rename from src/Numos.CoreSim/Solvers/IAtmosSolver.cs rename to src/Numos.CoreSim/Solvers/IAtmosSolverStage.cs index 4b0c986..f068b89 100644 --- a/src/Numos.CoreSim/Solvers/IAtmosSolver.cs +++ b/src/Numos.CoreSim/Solvers/IAtmosSolverStage.cs @@ -1,9 +1,9 @@ namespace Numos.CoreSim.Solvers; /// -/// One atomic stage in an atmospheric simulation tick. +/// One built-in atomic stage in an atmospheric simulation tick. /// -internal interface IAtmosSolver +internal interface IAtmosSolverStage { void Solve(AtmosSolverExecutionContext context); -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs index 4d85e85..2a54f72 100644 --- a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs +++ b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs @@ -24,7 +24,6 @@ private static void ProcessGas(AtmosChunk chunk, AtmosSolverConfigSnapshot confi return; float inverseBoilingPoint = 1f / properties.BoilingPoint; - float molarHeatCapacity = config.GetMolarHeatCapacityAtConstantVolume(gasId); for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; @@ -35,16 +34,19 @@ private static void ProcessGas(AtmosChunk chunk, AtmosSolverConfigSnapshot confi float temperature = config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]); float saturationPressure = CalculateSaturationPressure( config, properties, temperature, inverseBoilingPoint); - float partialPressure = AtmosSolverMath.CalculatePressure(config, gasMoles, temperature); - if (partialPressure <= saturationPressure) + float saturationMoles = AtmosSolverMath.PressureToMoles( + config, saturationPressure, temperature); + if (gasMoles <= saturationMoles) + continue; + + // Since P = nRT/V at fixed T and V, the pressure excess can be converted directly into a + // mole excess. This avoids an overflow-prone pressure round trip for large inventories. + float molesToCondense = (gasMoles - saturationMoles) * config.CondensationRateFactor; + if (molesToCondense <= 0f) continue; - float molesToCondense = AtmosSolverMath.PressureToMoles( - config, partialPressure - saturationPressure, temperature) * - config.CondensationRateFactor; ApplyCondensation(chunk, config, gasIndex, voxelIndex, temperature, - MathF.Min(gasMoles, molesToCondense), molarHeatCapacity, - properties.MolarEnthalpyOfVaporization); + molesToCondense, properties.MolarEnthalpyOfVaporization); } } @@ -59,22 +61,22 @@ private static float CalculateSaturationPressure(AtmosSolverConfigSnapshot confi private static void ApplyCondensation(AtmosChunk chunk, AtmosSolverConfigSnapshot config, int gasIndex, ushort voxelIndex, float temperature, float condensedMoles, - float molarHeatCapacity, float molarEnthalpyOfVaporization) + float molarEnthalpyOfVaporization) { chunk.ActiveGases[gasIndex].Moles[voxelIndex] -= condensedMoles; - float oldHeatCapacity = chunk.TotalHeatCapacity[voxelIndex]; - float condensedHeatCapacity = condensedMoles * molarHeatCapacity; - float newHeatCapacity = MathF.Max(0f, oldHeatCapacity - condensedHeatCapacity); + float newHeatCapacity = AtmosSolverMath.CalculateHeatCapacityAtVoxel(config, chunk, voxelIndex); float molarInternalEnergyOfVaporization = MathF.Max(0f, molarEnthalpyOfVaporization - AtmosPhysicalConstants.MolarGasConstant * temperature); - float remainingEnergy = temperature * oldHeatCapacity - - temperature * condensedHeatCapacity + - condensedMoles * molarInternalEnergyOfVaporization; chunk.TotalHeatCapacity[voxelIndex] = newHeatCapacity; if (newHeatCapacity > 0f) - chunk.Temperature[voxelIndex] = MathF.Max(0f, remainingEnergy / newHeatCapacity); + { + // Algebraically this is (T*C_remaining + n_condensed*U_vap) / C_remaining. Dividing + // before multiplying avoids both C*T overflow and the cancellation of two large energies. + chunk.Temperature[voxelIndex] = MathF.Max(0f, + temperature + condensedMoles / newHeatCapacity * molarInternalEnergyOfVaporization); + } chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs b/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs index 9a8521c..4eb1b89 100644 --- a/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs +++ b/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs @@ -7,12 +7,12 @@ namespace Numos.CoreSim.Solvers; /// /// Solves simultaneous, conservative thermal diffusion across chunk boundaries. /// -internal sealed class ThermalBoundarySolver : IAtmosSolver +internal sealed class ThermalBoundarySolver : IAtmosSolverStage { private readonly List _activeEdges = []; - private readonly Dictionary _energyDeltas = []; + private readonly Dictionary _energyDeltas = []; private readonly HashSet _edges = []; - private readonly Dictionary _incidentConductances = []; + private readonly Dictionary _incidentConductances = []; private readonly List _orderedEdges = []; private readonly Dictionary _states = []; @@ -22,7 +22,7 @@ public void Solve(AtmosSolverExecutionContext context) return; ResetWorkspace(); - if (context.Config.ThermalConductance <= 0f) + if (context.TickConfig.ThermalConductance <= 0f) return; CollectEdges(context); @@ -82,10 +82,16 @@ private void TryAddEdge(AtmosSolverExecutionContext context, AtmosChunk sourceCh Int3 neighborLocalPosition = (targetPosition + neighborChunk.Dimensions) % neighborChunk.Dimensions; ushort neighborIndex = neighborChunk.GetIndex(neighborLocalPosition); - if (neighborChunk.VoxelRoomMap[neighborIndex] == VoxelClassification.RoomSolid) + int neighborRoom = neighborChunk.VoxelRoomMap[neighborIndex]; + if (neighborRoom == VoxelClassification.RoomSolid || + neighborRoom == VoxelClassification.RoomVoid) return; ushort sourceIndex = sourceChunk.GetIndex(targetPosition - direction); + int sourceRoom = sourceChunk.VoxelRoomMap[sourceIndex]; + if (sourceRoom == VoxelClassification.RoomSolid || sourceRoom == VoxelClassification.RoomVoid) + return; + var source = new ThermalVoxelAddress(sourcePosition, sourceIndex); var neighbor = new ThermalVoxelAddress(neighborPosition, neighborIndex); _edges.Add(CompareVoxels(source, neighbor) <= 0 @@ -102,10 +108,7 @@ private void AccumulateConductances(AtmosSolverExecutionContext context) continue; float conductance = AtmosSolverMath.CalculateThermalConductance( - firstState.HeatCapacity, secondState.HeatCapacity, context.Config.ThermalConductance); - if (conductance <= 0f) - continue; - + firstState.HeatCapacity, secondState.HeatCapacity, context.TickConfig.ThermalConductance); Add(_incidentConductances, edge.First, conductance); Add(_incidentConductances, edge.Second, conductance); _activeEdges.Add(new ThermalBoundaryConductance(edge, conductance)); @@ -118,12 +121,12 @@ private void AccumulateEnergyDeltas() { ThermalBoundaryState firstState = _states[edge.First]; ThermalBoundaryState secondState = _states[edge.Second]; - float scale = MathF.Min(1f, MathF.Min( + double scale = Math.Min(1d, Math.Min( firstState.HeatCapacity / _incidentConductances[edge.First], secondState.HeatCapacity / _incidentConductances[edge.Second])); - float heatTransfer = scale * conductance * - (firstState.Temperature - secondState.Temperature); - if (heatTransfer == 0f) + double heatTransfer = scale * conductance * + ((double)firstState.Temperature - secondState.Temperature); + if (heatTransfer == 0d) continue; Add(_energyDeltas, edge.First, -heatTransfer); @@ -136,14 +139,13 @@ private void ApplyEnergyDeltas(AtmosSolverExecutionContext context) foreach (var (address, energyDelta) in _energyDeltas) { ThermalBoundaryState state = _states[address]; - float newTemperature = state.Temperature + energyDelta / state.HeatCapacity; - if (newTemperature < 0f || !context.World.TryGetChunk(address.ChunkPosition, out var chunk)) - continue; + float newTemperature = MathF.Max(0f, + state.Temperature + (float)(energyDelta / state.HeatCapacity)); - chunk.Temperature[address.LocalVoxelIndex] = newTemperature; - chunk.TotalPressure[address.LocalVoxelIndex] = AtmosSolverMath.CalculatePressureAtVoxel( - context.Config, chunk, address.LocalVoxelIndex); - chunk.MarkChanged(); + state.Chunk.Temperature[address.LocalVoxelIndex] = newTemperature; + state.Chunk.TotalPressure[address.LocalVoxelIndex] = AtmosSolverMath.CalculatePressureAtVoxel( + context.TickConfig, state.Chunk, address.LocalVoxelIndex); + state.Chunk.MarkChanged(); } } @@ -156,16 +158,16 @@ private bool TryGetState(AtmosSolverExecutionContext context, ThermalVoxelAddres return false; ushort voxelIndex = address.LocalVoxelIndex; - float pressure = AtmosSolverMath.CalculatePressureAtVoxel(context.Config, chunk, voxelIndex); - float heatCapacity = AtmosSolverMath.CalculateHeatCapacityAtVoxel(context.Config, chunk, voxelIndex); + float pressure = AtmosSolverMath.CalculatePressureAtVoxel(context.TickConfig, chunk, voxelIndex); + float heatCapacity = AtmosSolverMath.CalculateHeatCapacityAtVoxel(context.TickConfig, chunk, voxelIndex); chunk.TotalPressure[voxelIndex] = pressure; chunk.TotalHeatCapacity[voxelIndex] = heatCapacity; if (!AtmosSolverMath.IsFinitePositive(heatCapacity) || !float.IsFinite(pressure) || - pressure < context.Config.VacuumThreshold) + pressure < context.TickConfig.VacuumThreshold) return false; - state = new ThermalBoundaryState( - context.Config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]), heatCapacity); + state = new ThermalBoundaryState(chunk, + context.TickConfig.GetEffectiveTemperature(chunk.Temperature[voxelIndex]), heatCapacity); _states.Add(address, state); return true; } @@ -182,8 +184,8 @@ private static int CompareEdges(ThermalBoundaryEdge left, ThermalBoundaryEdge ri return comparison != 0 ? comparison : CompareVoxels(left.Second, right.Second); } - private static void Add(Dictionary values, - ThermalVoxelAddress address, float value) + private static void Add(Dictionary values, + ThermalVoxelAddress address, double value) { values[address] = values.GetValueOrDefault(address) + value; } @@ -191,5 +193,6 @@ private static void Add(Dictionary values, private readonly record struct ThermalVoxelAddress(Int3 ChunkPosition, ushort LocalVoxelIndex); private readonly record struct ThermalBoundaryEdge(ThermalVoxelAddress First, ThermalVoxelAddress Second); private readonly record struct ThermalBoundaryConductance(ThermalBoundaryEdge Edge, float Conductance); - private readonly record struct ThermalBoundaryState(float Temperature, float HeatCapacity); -} \ No newline at end of file + private readonly record struct ThermalBoundaryState( + AtmosChunk Chunk, float Temperature, float HeatCapacity); +} diff --git a/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs b/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs index ddce63a..755ab15 100644 --- a/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs +++ b/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Diagnostics; using Numos.CoreSim.Datatypes.Events; using Numos.CoreSim.Datatypes.Primitives; using Numos.Maths; @@ -17,8 +18,8 @@ internal int Solve(AtmosChunk chunk, AtmosSolverConfigSnapshot config, if (thermalConductance <= 0f) return 0; - float[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); - float[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); + double[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); + double[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); Array.Clear(incidentConductances, 0, chunk.VoxelCount); Array.Clear(energyDeltas, 0, chunk.VoxelCount); @@ -32,28 +33,32 @@ internal int Solve(AtmosChunk chunk, AtmosSolverConfigSnapshot config, } finally { - ArrayPool.Shared.Return(energyDeltas); - ArrayPool.Shared.Return(incidentConductances); + ArrayPool.Shared.Return(energyDeltas); + ArrayPool.Shared.Return(incidentConductances); } } private static int AccumulateConductancesAndBoundaries(AtmosChunk chunk, - AtmosSolverConfigSnapshot config, float[] incidentConductances, + AtmosSolverConfigSnapshot config, double[] incidentConductances, ThermalBoundaryEvent[] boundaryBuffer) { var boundaryCount = 0; for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; - if (chunk.TotalHeatCapacity[voxelIndex] <= 0f || - chunk.TotalPressure[voxelIndex] < config.VacuumThreshold) + if (!TryGetThermalState(chunk, config, voxelIndex, out _, out float heatCapacity)) continue; Int3 position = chunk.GetXyzInt3(voxelIndex); - AccumulateConductance(chunk, config, position + Int3.PosX, voxelIndex, incidentConductances); - AccumulateConductance(chunk, config, position + Int3.PosY, voxelIndex, incidentConductances); + AccumulateConductance(chunk, config, position + Int3.PosX, voxelIndex, heatCapacity, + incidentConductances); + AccumulateConductance(chunk, config, position + Int3.PosY, voxelIndex, heatCapacity, + incidentConductances); if (chunk.Depth > 1) - AccumulateConductance(chunk, config, position + Int3.PosZ, voxelIndex, incidentConductances); + { + AccumulateConductance(chunk, config, position + Int3.PosZ, voxelIndex, heatCapacity, + incidentConductances); + } if (IsBoundary(chunk, position)) AppendBoundaryEvent(boundaryBuffer, ref boundaryCount, voxelIndex); @@ -63,26 +68,30 @@ private static int AccumulateConductancesAndBoundaries(AtmosChunk chunk, } private static void AccumulateEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, - float[] incidentConductances, float[] energyDeltas) + double[] incidentConductances, double[] energyDeltas) { for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + if (!TryGetThermalState(chunk, config, voxelIndex, out float temperature, + out float heatCapacity)) + continue; + Int3 position = chunk.GetXyzInt3(voxelIndex); - AccumulateFlux(chunk, config, position + Int3.PosX, voxelIndex, + AccumulateFlux(chunk, config, position + Int3.PosX, voxelIndex, temperature, heatCapacity, incidentConductances, energyDeltas); - AccumulateFlux(chunk, config, position + Int3.PosY, voxelIndex, + AccumulateFlux(chunk, config, position + Int3.PosY, voxelIndex, temperature, heatCapacity, incidentConductances, energyDeltas); if (chunk.Depth > 1) { - AccumulateFlux(chunk, config, position + Int3.PosZ, voxelIndex, + AccumulateFlux(chunk, config, position + Int3.PosZ, voxelIndex, temperature, heatCapacity, incidentConductances, energyDeltas); } } } private static void ApplyEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, - float[] energyDeltas) + double[] energyDeltas) { for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { @@ -93,61 +102,61 @@ private static void ApplyEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapsho continue; chunk.Temperature[voxelIndex] = MathF.Max(0f, - oldTemperature + energyDeltas[voxelIndex] / heatCapacity); + oldTemperature + (float)(energyDeltas[voxelIndex] / heatCapacity)); chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); } } private static void AccumulateConductance(AtmosChunk chunk, AtmosSolverConfigSnapshot config, - Int3 neighborPosition, ushort voxelIndex, float[] incidentConductances) + Int3 neighborPosition, ushort voxelIndex, float currentHeatCapacity, + double[] incidentConductances) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; ushort neighborIndex = chunk.GetIndex(neighborPosition); - if (chunk.VoxelRoomMap[neighborIndex] == VoxelClassification.RoomSolid) + int neighborRoom = chunk.VoxelRoomMap[neighborIndex]; + if (neighborRoom == VoxelClassification.RoomSolid || + neighborRoom == VoxelClassification.RoomVoid) return; - if (!TryGetThermalState(chunk, config, voxelIndex, out _, out float currentHeatCapacity) || - !TryGetThermalState(chunk, config, neighborIndex, out _, out float neighborHeatCapacity)) + if (!TryGetThermalState(chunk, config, neighborIndex, out _, out float neighborHeatCapacity)) return; float conductance = AtmosSolverMath.CalculateThermalConductance( currentHeatCapacity, neighborHeatCapacity, config.ThermalConductance); - if (conductance <= 0f) - return; - incidentConductances[voxelIndex] += conductance; incidentConductances[neighborIndex] += conductance; } private static void AccumulateFlux(AtmosChunk chunk, AtmosSolverConfigSnapshot config, - Int3 neighborPosition, ushort voxelIndex, float[] incidentConductances, float[] energyDeltas) + Int3 neighborPosition, ushort voxelIndex, float currentTemperature, float currentHeatCapacity, + double[] incidentConductances, double[] energyDeltas) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; ushort neighborIndex = chunk.GetIndex(neighborPosition); - if (chunk.VoxelRoomMap[neighborIndex] == VoxelClassification.RoomSolid) + int neighborRoom = chunk.VoxelRoomMap[neighborIndex]; + if (neighborRoom == VoxelClassification.RoomSolid || + neighborRoom == VoxelClassification.RoomVoid) return; - if (!TryGetThermalState(chunk, config, voxelIndex, out float currentTemperature, - out float currentHeatCapacity) || - !TryGetThermalState(chunk, config, neighborIndex, out float neighborTemperature, + if (!TryGetThermalState(chunk, config, neighborIndex, out float neighborTemperature, out float neighborHeatCapacity)) return; float conductance = AtmosSolverMath.CalculateThermalConductance( currentHeatCapacity, neighborHeatCapacity, config.ThermalConductance); - float currentIncident = incidentConductances[voxelIndex]; - float neighborIncident = incidentConductances[neighborIndex]; - if (conductance <= 0f || currentIncident <= 0f || neighborIncident <= 0f) - return; + double currentIncident = incidentConductances[voxelIndex]; + double neighborIncident = incidentConductances[neighborIndex]; + Debug.Assert(currentIncident > 0d && neighborIncident > 0d); - float scale = MathF.Min(1f, MathF.Min( + double scale = Math.Min(1d, Math.Min( currentHeatCapacity / currentIncident, neighborHeatCapacity / neighborIncident)); - float heatTransfer = scale * conductance * (currentTemperature - neighborTemperature); - if (heatTransfer == 0f) + double heatTransfer = scale * conductance * + ((double)currentTemperature - neighborTemperature); + if (heatTransfer == 0d) return; energyDeltas[voxelIndex] -= heatTransfer; @@ -181,8 +190,7 @@ private static bool IsBoundary(AtmosChunk chunk, Int3 position) private static void AppendBoundaryEvent(ThermalBoundaryEvent[] buffer, ref int count, ushort voxelIndex) { - if (count >= buffer.Length) - throw new InvalidOperationException("Thermal boundary event buffer capacity was exceeded."); + // DefaultAtmosSolvers allocates one slot for every geometrically distinct boundary voxel. buffer[count++] = new ThermalBoundaryEvent { LocalVoxelIndex = voxelIndex }; } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/ThermodynamicsSolver.cs b/src/Numos.CoreSim/Solvers/ThermodynamicsSolver.cs index 9bfc36a..c28dafb 100644 --- a/src/Numos.CoreSim/Solvers/ThermodynamicsSolver.cs +++ b/src/Numos.CoreSim/Solvers/ThermodynamicsSolver.cs @@ -6,7 +6,7 @@ namespace Numos.CoreSim.Solvers; /// /// Coordinates the lower-frequency thermal-diffusion and phase-change operations. /// -internal sealed class ThermodynamicsSolver : IAtmosSolver, IDisposable +internal sealed class ThermodynamicsSolver : IAtmosSolverStage, IDisposable { private readonly PhaseChangeSolver _phaseChanges = new(); private readonly ThermalDiffusionSolver _thermalDiffusion = new(); @@ -38,10 +38,10 @@ private void SolveChunk(AtmosSolverExecutionContext context, AtmosChunk chunk) ThermalBoundaryEvent[]? boundaryBuffer = _thermalBoundaryBuffers.Value; Debug.Assert(boundaryBuffer != null); - int boundaryCount = _thermalDiffusion.Solve(chunk, context.Config, boundaryBuffer); - _phaseChanges.Solve(chunk, context.Config); + int boundaryCount = _thermalDiffusion.Solve(chunk, context.TickConfig, boundaryBuffer); + _phaseChanges.Solve(chunk, context.TickConfig); for (var index = 0; index < boundaryCount; index++) context.ThermalBoundaryEvents.Enqueue((chunk.GridPosition, boundaryBuffer[index])); } -} \ No newline at end of file +} diff --git a/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs b/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs index 954b8e8..73364a3 100644 --- a/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs +++ b/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs @@ -105,6 +105,27 @@ public void ConfiguredDangerousSolver_RetainsEditableTypedConfiguration() Assert.That(simulation.GetChunkSnapshot(chunk).Gases.Single().Moles[0], Is.EqualTo(3f)); } + [Test] + public void DangerousSolver_ConfigReferenceIsStableForTheTick() + { + var original = new Numos.CoreSim.AtmosConfig(); + var replacement = new Numos.CoreSim.AtmosConfig(); + using var simulation = new AtmosSimulation(original); + var observed = new List(); + simulation.Dangerous().Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "replace-config", context => + { + if (context.TickCount == 1) + simulation.SetAtmosConfig(replacement); + }); + simulation.Dangerous().Solvers.RegisterAfter("replace-config", "observe-config", + context => observed.Add(context.Config)); + + simulation.Tick(); + simulation.Tick(); + + Assert.That(observed, Is.EqualTo(new[] { original, replacement })); + } + private sealed class ConfiguredDangerousInjectionSolver : IAtmosDangerousSolver { diff --git a/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs b/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs index a0cfc5c..8425aaa 100644 --- a/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs +++ b/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs @@ -1,5 +1,6 @@ using Numos.CoreSim; using Numos.CoreSim.Datatypes.Primitives; +using Numos.Maths; namespace Numos.API.Tests; @@ -97,6 +98,216 @@ public void ConfiguredSolver_RetainsEditableTypedConfiguration() Assert.That(simulation.GetChunkSnapshot(chunk).Gases.Single().Moles[0], Is.EqualTo(2.5f)); } + [Test] + public void ConfiguredSolver_RemainsCallerOwnedAfterSimulationDisposal() + { + var simulation = new AtmosSimulation(); + var solver = new DisposableConfiguredSolver(); + simulation.Solvers.Register("caller-owned", solver); + + simulation.Dispose(); + + Assert.That(solver.IsDisposed, Is.False); + solver.Dispose(); + Assert.That(solver.IsDisposed, Is.True); + } + + [Test] + public void StandardSolver_ConfigReferenceIsStableForTheTick() + { + var original = new AtmosConfig(); + var replacement = new AtmosConfig(); + using var simulation = new AtmosSimulation(original); + var observed = new List(); + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "replace-config", context => + { + if (context.TickCount == 1) + simulation.SetAtmosConfig(replacement); + }); + simulation.Solvers.RegisterAfter("replace-config", "observe-config", + context => observed.Add(context.Config)); + + simulation.Tick(); + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(observed, Is.EqualTo(new[] { original, replacement })); + Assert.That(simulation.Config, Is.SameAs(replacement)); + }); + } + + [Test] + public void PipelineEditDuringSolverExecution_TakesEffectOnNextTick() + { + using var simulation = new AtmosSimulation(); + var calls = new List(); + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "register-later", context => + { + if (context.TickCount == 1) + simulation.Solvers.RegisterAfter("register-later", "later", later => calls.Add(later.TickCount)); + }); + + simulation.Tick(); + simulation.Tick(); + + Assert.That(calls, Is.EqualTo(new[] { 2 })); + } + + [Test] + public void SolverCallback_CannotInvalidateOrRecursivelyExecuteCurrentTick() + { + using var simulation = new AtmosSimulation(1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + AtmosConfig originalConfig = simulation.Config; + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "invalid-lifecycle", _ => + { + Assert.Multiple(() => + { + Assert.That(simulation.Tick, Throws.InvalidOperationException); + Assert.That(() => simulation.Update(1f / AtmosSimulation.SimulationRate), + Throws.InvalidOperationException); + Assert.That(() => simulation.Update(1f / AtmosSimulation.SimulationRate, new AtmosConfig()), + Throws.InvalidOperationException); + Assert.That(() => simulation.CreateAndRegisterChunk(Int3.PosX), + Throws.InvalidOperationException); + Assert.That(() => simulation.UnregisterChunk(chunk), Throws.InvalidOperationException); + Assert.That(simulation.Dispose, Throws.InvalidOperationException); + }); + }); + + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(simulation.TickCount, Is.EqualTo(1)); + Assert.That(simulation.ChunkCount, Is.EqualTo(1)); + Assert.That(simulation.Config, Is.SameAs(originalConfig)); + }); + } + + [Test] + public void StandardTemperatureMutation_RefreshesPressureBeforeThermodynamics() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 100f, + ThermalConductance = 1f, + SleepThreshold = int.MaxValue, + GasRegistry = [new GasProperties { MolarHeatCapacityAtConstantVolume = 1f }] + }; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(chunk, 1, 0, 1f, 300f); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.Thermodynamics, "cool", context => + { + if (context.TickCount == 2) + context.SetVoxelTemperature(chunk, 0, 1f); + }); + + simulation.Tick(); + simulation.Tick(); + + Assert.That(simulation.GetVoxelSnapshot(chunk, 0).Temperature, Is.EqualTo(1f)); + } + + [Test] + public void DisabledConsumer_DoesNotReplayProducerEventsOnALaterTick() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MinimumPressureTransfer = 0f, + BulkFlowCoefficient = 0.25f, + BulkFlowDamping = 0.5f, + MaxPressureTransferFractionPerNeighbor = 0.16f, + SleepThreshold = int.MaxValue, + GasRegistry = [new GasProperties()] + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = simulation.CreateAndRegisterChunk(default); + var target = simulation.CreateAndRegisterChunk(new Int3(1, 0, 0)); + simulation.SetChunkClassification(source, new VoxelClassification(1)); + simulation.SetChunkClassification(target, new VoxelClassification(2)); + simulation.AddGasToVoxel(source, 0, 0, 2f, 300f); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.BoundaryFlow, false); + + simulation.Tick(); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.BoundaryFlow, true); + simulation.Tick(); + + Assert.That(simulation.GetVoxelSnapshot(target, 0).Gases, Is.Empty); + } + + [Test] + public void BoundaryConsumer_RevalidatesSourceTopologyAfterCustomStage() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MinimumPressureTransfer = 0f, + BulkFlowCoefficient = 0.25f, + BulkFlowDamping = 0.5f, + MaxPressureTransferFractionPerNeighbor = 0.16f, + SleepThreshold = int.MaxValue, + GasRegistry = [new GasProperties()] + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = simulation.CreateAndRegisterChunk(default); + var target = simulation.CreateAndRegisterChunk(Int3.PosX); + simulation.SetChunkClassification(source, new VoxelClassification(1)); + simulation.SetChunkClassification(target, new VoxelClassification(2)); + simulation.AddGasToVoxel(source, 0, 0, 2f, 300f); + simulation.Solvers.RegisterAfter(AtmosBuiltInSolvers.Advection, "seal-source", + context => context.SetVoxelClassification(source, 0, VoxelClassification.RoomSolid)); + + simulation.Tick(); + + Assert.That(simulation.GetVoxelSnapshot(target, 0).Gases, Is.Empty); + } + + [Test] + public void ThermalBoundaryConsumer_RevalidatesSourceTopologyAfterCustomStage() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + ThermalConductance = 1f, + SleepThreshold = int.MaxValue, + GasRegistry = [new GasProperties { MolarHeatCapacityAtConstantVolume = 1f }] + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var hot = simulation.CreateAndRegisterChunk(default); + var cold = simulation.CreateAndRegisterChunk(Int3.PosX); + simulation.SetChunkClassification(hot, new VoxelClassification(1)); + simulation.SetChunkClassification(cold, new VoxelClassification(2)); + simulation.AddGasToVoxel(hot, 0, 0, 1f, 400f); + simulation.AddGasToVoxel(cold, 0, 0, 1f, 200f); + simulation.Solvers.RegisterAfter(AtmosBuiltInSolvers.Thermodynamics, "seal-hot", context => + { + if (context.TickCount == 2) + context.SetVoxelClassification(hot, 0, VoxelClassification.RoomSolid); + }); + + simulation.Tick(); + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(simulation.GetVoxelSnapshot(hot, 0).Temperature, Is.EqualTo(400f)); + Assert.That(simulation.GetVoxelSnapshot(cold, 0).Temperature, Is.EqualTo(200f)); + }); + } + [Test] public void ResetToDefaults_RemovesCustomizations() { @@ -130,4 +341,19 @@ private sealed class InjectionSolverConfig { internal float Moles { get; set; } } + + private sealed class DisposableConfiguredSolver : IAtmosSolver, IDisposable + { + public object Config { get; } = new(); + internal bool IsDisposed { get; private set; } + + public void Solve(AtmosSolverContext context) + { + } + + public void Dispose() + { + IsDisposed = true; + } + } } \ No newline at end of file diff --git a/tests/Numos.CoreSim.IntegrationTests/CrossChunkFlowTests.cs b/tests/Numos.CoreSim.IntegrationTests/CrossChunkFlowTests.cs index 3ae1052..759f203 100644 --- a/tests/Numos.CoreSim.IntegrationTests/CrossChunkFlowTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/CrossChunkFlowTests.cs @@ -110,6 +110,59 @@ public void BoundaryDiffusion_WakesTargetWhenBulkFlowIsDisabled() }); } + [Test] + public void BoundaryFlow_TransferKeepsSourceAwakeForSubsequentTicks() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.SleepThreshold = 0; + config.SleepEpsilon = 1f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = CreateIsolatedVoxel(simulation, default, 0, 0, 0, SimTestHelpers.RoomId); + var target = CreateIsolatedVoxel(simulation, Int3.PosX, 0, 0, 0, SimTestHelpers.RoomId + 1); + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + + simulation.Tick(); + var afterFirstTick = simulation.GetChunkSnapshot(target); + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(simulation.GetChunkSnapshot(source).IsAwake, Is.True); + Assert.That(SimTestHelpers.Moles(afterFirstTick, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(0.25f).Within(SimTestHelpers.Tolerance)); + Assert.That(SimTestHelpers.Moles(simulation.GetChunkSnapshot(target), + SimTestHelpers.FirstGasId, 0), + Is.GreaterThan(0.25f)); + }); + } + + [Test] + public void BoundaryDiffusion_DoesNotRequireRepresentablePressure() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.VoxelVolume = float.MaxValue; + config.MaxPressureTransferFractionPerNeighbor = 0f; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.DiffusionCoefficient = 0.1f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = CreateIsolatedVoxel(simulation, default, 0, 0, 0, SimTestHelpers.RoomId); + var target = CreateIsolatedVoxel(simulation, Int3.PosX, 0, 0, 0, SimTestHelpers.RoomId + 1); + simulation.SetVoxelTemperature(source, 0, float.Epsilon); + simulation.AddGasToVoxel(source, 0, SimTestHelpers.FirstGasId, 1f, float.Epsilon); + + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(simulation.GetVoxelSnapshot(source, 0).Pressure, Is.Zero); + Assert.That(SimTestHelpers.Moles(simulation.GetChunkSnapshot(target), + SimTestHelpers.FirstGasId, 0), + Is.EqualTo(0.1f).Within(SimTestHelpers.Tolerance)); + }); + } + [Test] public void BoundaryFlow_WithUnequalMolarHeatCapacities_ConservesThermalEnergy() { @@ -260,15 +313,15 @@ public void DenseBoundary_DoesNotDropTheHighestIndexedFaceEvent() SimTestHelpers.SetAllTemperatures(simulation, source, size, size, size); for (var z = 0; z < size; z++) - for (var y = 0; y < size; y++) - for (var x = 0; x < size; x++) - { - if (x == 0 || x == size - 1 || y == 0 || y == size - 1 || z == 0 || z == size - 1) - { - simulation.AddGasToVoxel(source, x, y, z, - SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); - } - } + for (var y = 0; y < size; y++) + for (var x = 0; x < size; x++) + { + if (x == 0 || x == size - 1 || y == 0 || y == size - 1 || z == 0 || z == size - 1) + { + simulation.AddGasToVoxel(source, x, y, z, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + } + } var target = CreateIsolatedVoxel(simulation, new Int3(0, 0, 1), size - 1, size - 1, 0, SimTestHelpers.RoomId + 1); @@ -288,6 +341,36 @@ public void DenseBoundary_DoesNotDropTheHighestIndexedFaceEvent() }); } + [Test] + public void DenseDepthOneBoundary_FitsExactBoundaryBufferCapacity() + { + const int size = 4; + var config = SimTestHelpers.CreateDeterministicConfig(); + using var simulation = new AtmosSimulation(config, size, size, 1); + var source = SimTestHelpers.CreateOpenChunk(simulation, default); + SimTestHelpers.SetAllTemperatures(simulation, source, size, size, 1); + + for (var y = 0; y < size; y++) + for (var x = 0; x < size; x++) + { + if (x == 0 || x == size - 1 || y == 0 || y == size - 1) + { + simulation.AddGasToVoxel(source, x, y, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + } + } + + var target = CreateIsolatedVoxel(simulation, Int3.PosY, + size - 1, 0, 0, SimTestHelpers.RoomId + 1); + + simulation.Tick(); + + int targetIndex = SimTestHelpers.Index(size - 1, 0, 0, size, size); + Assert.That(SimTestHelpers.Moles(simulation.GetChunkSnapshot(target), + SimTestHelpers.FirstGasId, targetIndex), + Is.EqualTo(0.25f).Within(SimTestHelpers.Tolerance)); + } + private static AtmosChunkHandle CreateIsolatedVoxel(AtmosSimulation simulation, Int3 position, int x, int y, int z, VoxelClassification classification) { @@ -297,4 +380,4 @@ private static AtmosChunkHandle CreateIsolatedVoxel(AtmosSimulation simulation, simulation.SetVoxelTemperature(chunk, x, y, z, SimTestHelpers.DefaultTemperature); return chunk; } -} +} \ No newline at end of file diff --git a/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs b/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs index 82aab48..add954c 100644 --- a/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs @@ -477,6 +477,33 @@ public void SolidVoxel_BlocksFlowWithoutLosingMass() }); } + [Test] + public void Diffusion_MaximumHeatCapacityAvoidsSensibleEnergyOverflow() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.MaxPressureTransferFractionPerNeighbor = 0f; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.DiffusionCoefficient = 0.1f; + gas.MolarHeatCapacityAtConstantVolume = float.MaxValue; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 400f); + simulation.AddGasToVoxel(chunk, 1, 0, 0, SimTestHelpers.FirstGasId, 0.5f, 200f); + + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(snapshot.Temperature[0], + Is.EqualTo(400f).Within(SimTestHelpers.Tolerance)); + Assert.That(snapshot.Temperature[1], + Is.EqualTo(226.08696f).Within(SimTestHelpers.Tolerance)); + Assert.That(snapshot.Temperature.All(float.IsFinite), Is.True); + }); + } + [Test] public void VoidVoxel_RemovesGasThatFlowsIntoIt() { @@ -517,4 +544,4 @@ public void VacuumCleanup_UsesStrictPressureThreshold(float initialMoles, float Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), Is.EqualTo(expectedMoles).Within(SimTestHelpers.Tolerance)); } -} +} \ No newline at end of file diff --git a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs index 6a6c4fc..d9a0b46 100644 --- a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs @@ -375,7 +375,7 @@ public void IntraChunkThermalDiffusion_LargeHeatCapacitiesAvoidIntermediateOverf config.MaxPressureTransferFractionPerNeighbor = 0f; config.ThermalConductance = float.MaxValue; var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; - gas.MolarHeatCapacityAtConstantVolume = 1e30f; + gas.MolarHeatCapacityAtConstantVolume = float.MaxValue; config.GasRegistry[SimTestHelpers.FirstGasId] = gas; using var simulation = new AtmosSimulation(config, 2, 1, 1); @@ -390,6 +390,35 @@ public void IntraChunkThermalDiffusion_LargeHeatCapacitiesAvoidIntermediateOverf Assert.That(snapshot.Temperature, Is.All.EqualTo(300f).Within(SimTestHelpers.Tolerance)); } + [Test] + public void CrossChunkThermalDiffusion_MaximumHeatCapacitiesAvoidIntermediateOverflow() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; + config.ThermalConductance = float.MaxValue; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.MolarHeatCapacityAtConstantVolume = float.MaxValue; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var hot = SimTestHelpers.CreateOpenChunk(simulation, default); + var cold = SimTestHelpers.CreateOpenChunk(simulation, Int3.PosX); + simulation.AddGasToVoxel(hot, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 400f); + simulation.AddGasToVoxel(cold, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 200f); + + simulation.Tick(); + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(simulation.GetVoxelSnapshot(hot, 0).Temperature, + Is.EqualTo(300f).Within(SimTestHelpers.Tolerance)); + Assert.That(simulation.GetVoxelSnapshot(cold, 0).Temperature, + Is.EqualTo(300f).Within(SimTestHelpers.Tolerance)); + }); + } + [Test] public void DepthOneChunks_DoNotTreatZAsAThermalFlowPlaneWhenAnotherEdgeEmitsAnEvent() { @@ -470,6 +499,29 @@ public void CrossChunkThermalDiffusion_IgnoresVacuumNeighbor() }); } + [Test] + public void IntraChunkThermalDiffusion_VoidNeighborWithRetainedGasDoesNotConduct() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 1f, 400f); + simulation.AddGasToVoxel(chunk, 1, 0, 0, SimTestHelpers.FirstGasId, 1f, 200f); + simulation.SetVoxelClassification(chunk, 1, VoxelClassification.RoomVoid); + + simulation.Tick(); + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(snapshot.Temperature[0], Is.EqualTo(400f)); + Assert.That(snapshot.Temperature[1], Is.EqualTo(200f)); + }); + } + [Test] public void Condensation_RunsOnEvenTickAndReleasesInternalPhaseChangeEnergy() { @@ -607,6 +659,51 @@ public void Condensation_ClausiusClapeyronExponentUsesMolarGasConstant() Is.EqualTo(initialMoles - expectedCondensedMoles).Within(SimTestHelpers.Tolerance)); } + [Test] + public void Condensation_LargeHeatCapacityAvoidsEnergyCancellationOverflow() + { + var config = CreateCondensationConfig(); + config.SaturationReferencePressure = 200f; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.MolarHeatCapacityAtConstantVolume = 1e37f; + gas.MolarEnthalpyOfVaporization = AtmosPhysicalConstants.MolarGasConstant * 200f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 2f, 200f); + + simulation.Tick(); + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(1.5f).Within(SimTestHelpers.Tolerance)); + Assert.That(snapshot.Temperature[0], Is.EqualTo(200f)); + Assert.That(float.IsFinite(snapshot.Temperature[0]), Is.True); + }); + } + + [Test] + public void Condensation_InfiniteSaturationPressureDoesNotCondense() + { + var config = CreateCondensationConfig(); + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.BoilingPoint = 1f; + gas.MolarEnthalpyOfVaporization = float.MaxValue; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 10f, 200f); + + simulation.Tick(); + simulation.Tick(); + + Assert.That(SimTestHelpers.Moles(simulation.GetChunkSnapshot(chunk), + SimTestHelpers.FirstGasId, 0), Is.EqualTo(10f)); + } + [Test] public void Condensation_SkipsGasMissingFromRegistry() { @@ -655,4 +752,4 @@ private static AtmosChunkHandle CreateIsolatedVoxel(AtmosSimulation simulation, simulation.SetVoxelTemperature(chunk, x, y, z, temperature); return chunk; } -} +} \ No newline at end of file From 6848cf836282d0b4cf5d1c2dff450c4ab5966c0e Mon Sep 17 00:00:00 2001 From: riccardi48 Date: Wed, 19 Aug 2026 21:53:46 +0100 Subject: [PATCH 09/14] condensation fix --- .../Solvers/PhaseChangeSolver.cs | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs index 2a54f72..23bf5cb 100644 --- a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs +++ b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs @@ -39,9 +39,46 @@ private static void ProcessGas(AtmosChunk chunk, AtmosSolverConfigSnapshot confi if (gasMoles <= saturationMoles) continue; - // Since P = nRT/V at fixed T and V, the pressure excess can be converted directly into a - // mole excess. This avoids an overflow-prone pressure round trip for large inventories. - float molesToCondense = (gasMoles - saturationMoles) * config.CondensationRateFactor; + float currentPartialPressure = saturationPressure * (gasMoles / saturationMoles); + float excessPressure = currentPartialPressure - saturationPressure; + float oldHeatCapacity = chunk.TotalHeatCapacity[voxelIndex]; + + // Latent heat released per mole condensed. + float molarInternalEnergyOfVaporization = MathF.Max(0f, + properties.MolarEnthalpyOfVaporization - AtmosPhysicalConstants.MolarGasConstant * temperature); + + float pressureDropPerMole = currentPartialPressure / gasMoles; + float tempRisePerMole = molarInternalEnergyOfVaporization / oldHeatCapacity; + + // Predict moles-to-condense using the closing rate at the starting temp. + float satSlopeStart = saturationPressure * properties.MolarEnthalpyOfVaporization / + (AtmosPhysicalConstants.MolarGasConstant * temperature * temperature); + float satRisePerMoleStart = satSlopeStart * tempRisePerMole; + float closingRateStart = pressureDropPerMole + satRisePerMoleStart; + + float predictedMoles = closingRateStart > 0f + ? MathF.Min(excessPressure / closingRateStart, gasMoles) + : 0f; + + // Correct using the closing rate at the predicted end temp, since + // P_sat rises faster than a straight line as T increases. + float predictedTemp = temperature + tempRisePerMole * predictedMoles; + float exponentEnd = -properties.MolarEnthalpyOfVaporization / AtmosPhysicalConstants.MolarGasConstant * + (1f / predictedTemp - 1f / temperature); + float satVaporPressureEnd = config.SaturationReferencePressure * MathF.Exp(exponentEnd); + float satSlopeEnd = satVaporPressureEnd * properties.MolarEnthalpyOfVaporization / + (AtmosPhysicalConstants.MolarGasConstant * predictedTemp * predictedTemp); + float satRisePerMoleEnd = satSlopeEnd * tempRisePerMole; + + // Average of start/end rates gives a better estimate than either alone. + float closingRateAvg = pressureDropPerMole + 0.5f * (satRisePerMoleStart + satRisePerMoleEnd); + + float molesToCondense = closingRateAvg > 0f + ? excessPressure / closingRateAvg + : 0f; + + molesToCondense = MathF.Min(molesToCondense, gasMoles) * config.CondensationRateFactor; + if (molesToCondense <= 0f) continue; @@ -79,4 +116,4 @@ private static void ApplyCondensation(AtmosChunk chunk, AtmosSolverConfigSnapsho chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); } -} +} \ No newline at end of file From c1c3d1bfc4500a2c3bcf32e92c12a36067d8862f Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:41:53 -0500 Subject: [PATCH 10/14] Solve heat-coupled condensation equilibrium --- docs/atmospherics_technical_documentation.md | 32 ++- src/Numos.CoreSim/AtmosConfig.cs | 5 +- src/Numos.CoreSim/AtmosConfigDefaults.cs | 4 +- .../Solvers/PhaseChangeSolver.cs | 219 ++++++++++++++---- .../ThermodynamicsIntegrationTests.cs | 173 +++++++++++++- 5 files changed, 361 insertions(+), 72 deletions(-) diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 3aaf140..4f98ad4 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -268,7 +268,7 @@ single definition in `VoxelClassification`. | `SleepThreshold` | 100 | Consecutive ticks below `SleepEpsilon` before a chunk goes to sleep. Negative values normalize to zero. | | `SleepEpsilon` | 3.5 | Maximum pressure delta considered "at rest" (Pa). Invalid or negative values normalize to zero. | | `ThermalConductance` | 0.05 | Effective per-face conductance in J/K per thermodynamics tick. Multiplying it by a temperature difference produces a candidate energy transfer, which is bounded for explicit-solver stability. Invalid or nonpositive values disable thermal diffusion. | -| `CondensationRateFactor` | 0.5 | Dimensionless fraction of supersaturated vapor condensed per thermodynamics tick. Finite values are clamped to [0, 1]; non-finite values disable condensation. | +| `CondensationRateFactor` | 0.5 | Dimensionless fraction of the heat-coupled equilibrium condensation amount applied per thermodynamics tick. Finite values are clamped to [0, 1]; non-finite values disable condensation. | | `MaxPressureTransferFractionPerNeighbor` | 0.16 | Maximum fraction of a voxel's pressure requested as bulk flow to one neighbor per tick. Finite values are clamped to [0, 1]; non-finite values disable bulk flow. | ### 3.7 Container and Voxel Gas Mixtures @@ -677,22 +677,29 @@ T_effective = storedTemperature > 0 && isFinite(storedTemperature) `SaturationReferencePressure` defaults to one standard atmosphere (`101325 Pa`) and is the pressure at which the configured `BoilingPoint` applies. -For a registered species, phase-change processing first requires `CondensationEnabled`, more than `0.01` moles in the voxel, and a positive effective temperature. Condensation then occurs when partial pressure exceeds saturation: +For a registered species, phase-change processing first requires `CondensationEnabled`, more than `0.01` moles in the voxel, and a positive effective temperature. Condensation then occurs when partial pressure exceeds saturation. Let `n0` and `T0` be the initial vapor amount and temperature, `x` the candidate condensed amount, `Cv` the condensing species' effective molar heat capacity, `C_other` the heat capacity of every other gas, and `K = R / VoxelVolume`: ``` if gasIsRegistered && CondensationEnabled && gasMoles > 0.01 && T_effective > 0: - P_sat = SaturationReferencePressure - * exp(-(MolarEnthalpyOfVaporization / R) * (1/T_effective - 1/T_boiling)) - saturationMoles = P_sat / ((R / VoxelVolume) * T_effective) - if gasMoles > saturationMoles: - molesToCondense = (gasMoles - saturationMoles) * CondensationRateFactor + deltaU = max(0, MolarEnthalpyOfVaporization - R * T0) + C_after(x) = C_other + (n0 - x) * Cv + T_after(x) = T0 + x * deltaU / C_after(x) + P_vapor(x) = (n0 - x) * K * T_after(x) + P_sat(x) = SaturationReferencePressure + * exp(-(MolarEnthalpyOfVaporization / R) + * (1/T_after(x) - 1/T_boiling)) + solve P_vapor(x_equilibrium) = P_sat(x_equilibrium), 0 <= x_equilibrium <= n0 + molesToCondense = x_equilibrium * CondensationRateFactor ``` Dividing molar vaporization enthalpy by `R` makes the exponential dimensionless. This integrated Clausius–Clapeyron form assumes ideal vapor and approximately constant vaporization enthalpy over the modeled temperature interval. Subject to the gates above, this model allows condensation at any temperature where the gas is supersaturated rather than only below a fixed temperature. Gas IDs without a registry entry, invalid boiling points, and invalid or nonpositive vaporization enthalpies are skipped. -The direct mole-space calculation is algebraically equivalent to converting excess partial pressure back to moles, -but it avoids an overflow-prone pressure round trip for large inventories. The approximation and its assumptions -match the integrated ideal-vapor derivation summarized in [NISTIR 5321](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir5321.pdf). +The equilibrium solve uses the remaining vapor amount and saturation amount in logarithmic mole space. A bounded +Newton iteration with a bisection fallback keeps the solution inside `[0, n0]`, uses double-precision intermediates, +and avoids an overflow-prone pressure round trip for large inventories. The same temperature curve is used both to +select the condensed amount and to apply its energy change, so the solve includes the warming of the remaining +vapor as well as the resulting rise in saturation pressure. The approximation and its assumptions match the +integrated ideal-vapor derivation summarized in [NISTIR 5321](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir5321.pdf). ### 8.2 Phase-Change Internal-Energy Balance @@ -710,7 +717,10 @@ performed only when `C_after > 0`. The voxel's cached `TotalHeatCapacity` and `T As elsewhere in the energy model, a non-finite or nonpositive configured `MolarHeatCapacityAtConstantVolume` uses the normalized `DefaultMolarHeatCapacityAtConstantVolume`. -Phase-change energy generally warms the remaining gas, which raises saturation pressure and slows further condensation. Accounting for both the ideal-gas `pV` term and the condensed gas's departing sensible energy avoids assigning enthalpy directly to a constant-volume internal-energy state. +Phase-change energy generally warms the remaining gas, which raises both its partial pressure and its saturation +pressure. The coupled amount solve in §8.1 evaluates both effects before applying `CondensationRateFactor`. +Accounting for the ideal-gas `pV` term and the condensed gas's departing sensible energy avoids assigning enthalpy +directly to a constant-volume internal-energy state. ### Liquid-system integration diff --git a/src/Numos.CoreSim/AtmosConfig.cs b/src/Numos.CoreSim/AtmosConfig.cs index c5f05cc..d54c766 100644 --- a/src/Numos.CoreSim/AtmosConfig.cs +++ b/src/Numos.CoreSim/AtmosConfig.cs @@ -127,7 +127,8 @@ public class AtmosConfig public float ThermalConductance { get; set; } = AtmosConfigDefaults.ThermalConductance; /// - /// Dimensionless fraction of supersaturated vapor condensed per thermodynamics tick. + /// Dimensionless fraction of the heat-coupled equilibrium condensation amount applied per + /// thermodynamics tick. /// /// Values are clamped to [0, 1]; non-finite values disable condensation. public float CondensationRateFactor { get; set; } = AtmosConfigDefaults.CondensationRateFactor; @@ -151,4 +152,4 @@ public class AtmosConfig /// Maximum number of ticks that an accumulated activity value remains alive. /// public int AccumulatorMaxAliveTicks { get; set; } = AtmosConfigDefaults.AccumulatorMaxAliveTicks; -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AtmosConfigDefaults.cs b/src/Numos.CoreSim/AtmosConfigDefaults.cs index 259d345..59bc8d6 100644 --- a/src/Numos.CoreSim/AtmosConfigDefaults.cs +++ b/src/Numos.CoreSim/AtmosConfigDefaults.cs @@ -55,7 +55,7 @@ public static class AtmosConfigDefaults /// Default effective per-face thermal conductance, in joules per kelvin per thermodynamics tick. public const float ThermalConductance = 0.05f; - /// Default fraction of supersaturated vapor condensed per thermodynamics tick. + /// Default fraction of the heat-coupled equilibrium condensation amount applied per tick. public const float CondensationRateFactor = 0.5f; /// Default maximum source-pressure fraction transferred to one neighbor per tick. @@ -66,4 +66,4 @@ public static class AtmosConfigDefaults /// Default maximum lifetime of accumulated activity, in ticks. public const int AccumulatorMaxAliveTicks = 20; -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs index 23bf5cb..b59e2f9 100644 --- a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs +++ b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs @@ -5,6 +5,10 @@ namespace Numos.CoreSim.Solvers; /// internal sealed class PhaseChangeSolver { + private const int MaximumEquilibriumIterations = 24; + private const double MinimumMoleTolerance = 1e-7d; + private const double RelativeMoleTolerance = 1d / (1 << 23); + internal void Solve(AtmosChunk chunk, AtmosSolverConfigSnapshot config) { if (config.CondensationRateFactor <= 0f) @@ -23,7 +27,7 @@ private static void ProcessGas(AtmosChunk chunk, AtmosSolverConfigSnapshot confi !AtmosSolverMath.IsFinitePositive(properties.MolarEnthalpyOfVaporization)) return; - float inverseBoilingPoint = 1f / properties.BoilingPoint; + double inverseBoilingPoint = 1d / properties.BoilingPoint; for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; @@ -32,79 +36,194 @@ private static void ProcessGas(AtmosChunk chunk, AtmosSolverConfigSnapshot confi continue; float temperature = config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]); - float saturationPressure = CalculateSaturationPressure( + double saturationMoles = CalculateSaturationMoles( config, properties, temperature, inverseBoilingPoint); - float saturationMoles = AtmosSolverMath.PressureToMoles( - config, saturationPressure, temperature); if (gasMoles <= saturationMoles) continue; - float currentPartialPressure = saturationPressure * (gasMoles / saturationMoles); - float excessPressure = currentPartialPressure - saturationPressure; - float oldHeatCapacity = chunk.TotalHeatCapacity[voxelIndex]; - - // Latent heat released per mole condensed. float molarInternalEnergyOfVaporization = MathF.Max(0f, - properties.MolarEnthalpyOfVaporization - AtmosPhysicalConstants.MolarGasConstant * temperature); + properties.MolarEnthalpyOfVaporization - + AtmosPhysicalConstants.MolarGasConstant * temperature); + + double equilibriumRemainingMoles = CalculateEquilibriumRemainingMoles( + chunk, config, gasIndex, voxelIndex, gasMoles, temperature, + saturationMoles, molarInternalEnergyOfVaporization, properties, + inverseBoilingPoint); + double equilibriumCondensedMoles = gasMoles - equilibriumRemainingMoles; + double targetRemainingMoles = gasMoles - Math.Min(gasMoles, + equilibriumCondensedMoles * config.CondensationRateFactor); + float remainingMoles = (float)targetRemainingMoles; + if (remainingMoles < targetRemainingMoles) + remainingMoles = MathF.BitIncrement(remainingMoles); + remainingMoles = Math.Clamp(remainingMoles, 0f, gasMoles); + + float molesToCondense = gasMoles - remainingMoles; + if (molesToCondense <= 0f) + continue; - float pressureDropPerMole = currentPartialPressure / gasMoles; - float tempRisePerMole = molarInternalEnergyOfVaporization / oldHeatCapacity; + ApplyCondensation(chunk, config, gasIndex, voxelIndex, temperature, + remainingMoles, molesToCondense, molarInternalEnergyOfVaporization); + } + } - // Predict moles-to-condense using the closing rate at the starting temp. - float satSlopeStart = saturationPressure * properties.MolarEnthalpyOfVaporization / - (AtmosPhysicalConstants.MolarGasConstant * temperature * temperature); - float satRisePerMoleStart = satSlopeStart * tempRisePerMole; - float closingRateStart = pressureDropPerMole + satRisePerMoleStart; + /// + /// Finds the remaining vapor amount whose warmed partial pressure equals its saturation pressure. + /// A safeguarded Newton solve operates in log-mole space so large inventories and saturation pressures + /// do not require an overflow-prone pressure round trip. + /// + private static double CalculateEquilibriumRemainingMoles(AtmosChunk chunk, + AtmosSolverConfigSnapshot config, int gasIndex, ushort voxelIndex, double gasMoles, + double initialTemperature, double initialSaturationMoles, + double molarInternalEnergyOfVaporization, GasProperties properties, + double inverseBoilingPoint) + { + double molarHeatCapacity = + config.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[gasIndex].GasId); + double otherHeatCapacity = CalculateOtherHeatCapacityAtVoxel( + chunk, config, gasIndex, voxelIndex); + double initialTotalHeatCapacity = otherHeatCapacity + gasMoles * molarHeatCapacity; + + if (molarInternalEnergyOfVaporization == 0d) + return Math.Clamp(initialSaturationMoles, 0d, gasMoles); + + double lowerBound = 0d; + double upperBound = gasMoles; + double candidate = Math.Clamp(initialSaturationMoles, lowerBound, upperBound); + double moleTolerance = Math.Max(MinimumMoleTolerance, + gasMoles * RelativeMoleTolerance); + + for (var iteration = 0; iteration < MaximumEquilibriumIterations; iteration++) + { + if (candidate <= lowerBound || candidate >= upperBound) + candidate = (lowerBound + upperBound) * 0.5d; + + double previousWidth = upperBound - lowerBound; + double residual = CalculateSaturationLogResidual(config, gasMoles, + initialTemperature, initialTotalHeatCapacity, otherHeatCapacity, + molarHeatCapacity, molarInternalEnergyOfVaporization, properties, + inverseBoilingPoint, candidate, out double residualDerivative); + + if (residual > 0d) + upperBound = candidate; + else + lowerBound = candidate; + + // A valid Newton step normally contracts the bracket faster than bisection. If it hugs one + // endpoint, also evaluate the retained interval's midpoint so every iteration still halves + // the previous width in the worst case. + if (upperBound - lowerBound > previousWidth * 0.5d) + { + candidate = (lowerBound + upperBound) * 0.5d; + residual = CalculateSaturationLogResidual(config, gasMoles, + initialTemperature, initialTotalHeatCapacity, otherHeatCapacity, + molarHeatCapacity, molarInternalEnergyOfVaporization, properties, + inverseBoilingPoint, candidate, out residualDerivative); + if (residual > 0d) + upperBound = candidate; + else + lowerBound = candidate; + } + + if (upperBound - lowerBound <= moleTolerance || + (float)lowerBound == (float)upperBound) + break; + + double nextCandidate = double.NaN; + if (double.IsFinite(residual) && double.IsFinite(residualDerivative) && + residualDerivative != 0d) + nextCandidate = candidate - residual / residualDerivative; + + candidate = nextCandidate > lowerBound && nextCandidate < upperBound + ? nextCandidate + : (lowerBound + upperBound) * 0.5d; + } - float predictedMoles = closingRateStart > 0f - ? MathF.Min(excessPressure / closingRateStart, gasMoles) - : 0f; + // Return the supersaturated side of the bracket. ProcessGas also rounds the target toward + // more remaining vapor when converting it back to the float-backed simulation state. + return upperBound; + } - // Correct using the closing rate at the predicted end temp, since - // P_sat rises faster than a straight line as T increases. - float predictedTemp = temperature + tempRisePerMole * predictedMoles; - float exponentEnd = -properties.MolarEnthalpyOfVaporization / AtmosPhysicalConstants.MolarGasConstant * - (1f / predictedTemp - 1f / temperature); - float satVaporPressureEnd = config.SaturationReferencePressure * MathF.Exp(exponentEnd); - float satSlopeEnd = satVaporPressureEnd * properties.MolarEnthalpyOfVaporization / - (AtmosPhysicalConstants.MolarGasConstant * predictedTemp * predictedTemp); - float satRisePerMoleEnd = satSlopeEnd * tempRisePerMole; + private static double CalculateSaturationLogResidual(AtmosSolverConfigSnapshot config, + double gasMoles, double initialTemperature, double initialTotalHeatCapacity, + double otherHeatCapacity, double molarHeatCapacity, + double molarInternalEnergyOfVaporization, GasProperties properties, + double inverseBoilingPoint, double remainingMoles, out double residualDerivative) + { + double remainingHeatCapacity = otherHeatCapacity + remainingMoles * molarHeatCapacity; + if (remainingHeatCapacity <= 0d || !double.IsFinite(remainingHeatCapacity)) + { + residualDerivative = double.NaN; + return double.NegativeInfinity; + } - // Average of start/end rates gives a better estimate than either alone. - float closingRateAvg = pressureDropPerMole + 0.5f * (satRisePerMoleStart + satRisePerMoleEnd); + double condensedMoles = gasMoles - remainingMoles; + double temperature = initialTemperature + + condensedMoles / remainingHeatCapacity * + molarInternalEnergyOfVaporization; + if (temperature <= 0d || !double.IsFinite(temperature)) + { + residualDerivative = double.NaN; + return double.NegativeInfinity; + } - float molesToCondense = closingRateAvg > 0f - ? excessPressure / closingRateAvg - : 0f; + double logSaturationMoles = Math.Log(config.SaturationReferencePressure) - + Math.Log(config.PressurePerMoleKelvin) - + Math.Log(temperature) - + properties.MolarEnthalpyOfVaporization / + AtmosPhysicalConstants.MolarGasConstant * + (1d / temperature - inverseBoilingPoint); + double temperatureDerivative = -molarInternalEnergyOfVaporization * + initialTotalHeatCapacity / + (remainingHeatCapacity * remainingHeatCapacity); + double saturationLogTemperatureDerivative = + properties.MolarEnthalpyOfVaporization / + (AtmosPhysicalConstants.MolarGasConstant * temperature * temperature) - + 1d / temperature; + residualDerivative = 1d / remainingMoles - + saturationLogTemperatureDerivative * temperatureDerivative; + return Math.Log(remainingMoles) - logSaturationMoles; + } - molesToCondense = MathF.Min(molesToCondense, gasMoles) * config.CondensationRateFactor; + private static double CalculateOtherHeatCapacityAtVoxel(AtmosChunk chunk, + AtmosSolverConfigSnapshot config, int excludedGasIndex, ushort voxelIndex) + { + double totalHeatCapacity = 0d; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + if (gas == excludedGasIndex) + continue; - if (molesToCondense <= 0f) + float moles = chunk.ActiveGases[gas].Moles[voxelIndex]; + if (moles <= 0f) continue; - ApplyCondensation(chunk, config, gasIndex, voxelIndex, temperature, - molesToCondense, properties.MolarEnthalpyOfVaporization); + totalHeatCapacity += (double)moles * + config.GetMolarHeatCapacityAtConstantVolume( + chunk.ActiveGases[gas].GasId); } + + return totalHeatCapacity; } - private static float CalculateSaturationPressure(AtmosSolverConfigSnapshot config, - GasProperties properties, float temperature, float inverseBoilingPoint) + private static double CalculateSaturationMoles(AtmosSolverConfigSnapshot config, + GasProperties properties, double temperature, double inverseBoilingPoint) { - float exponent = -properties.MolarEnthalpyOfVaporization / - AtmosPhysicalConstants.MolarGasConstant * - (1f / temperature - inverseBoilingPoint); - return config.SaturationReferencePressure * MathF.Exp(exponent); + double logSaturationMoles = Math.Log(config.SaturationReferencePressure) - + Math.Log(config.PressurePerMoleKelvin) - + Math.Log(temperature) - + properties.MolarEnthalpyOfVaporization / + AtmosPhysicalConstants.MolarGasConstant * + (1d / temperature - inverseBoilingPoint); + return Math.Exp(logSaturationMoles); } private static void ApplyCondensation(AtmosChunk chunk, AtmosSolverConfigSnapshot config, - int gasIndex, ushort voxelIndex, float temperature, float condensedMoles, - float molarEnthalpyOfVaporization) + int gasIndex, ushort voxelIndex, float temperature, float remainingMoles, + float condensedMoles, float molarInternalEnergyOfVaporization) { - chunk.ActiveGases[gasIndex].Moles[voxelIndex] -= condensedMoles; + chunk.ActiveGases[gasIndex].Moles[voxelIndex] = remainingMoles; float newHeatCapacity = AtmosSolverMath.CalculateHeatCapacityAtVoxel(config, chunk, voxelIndex); - float molarInternalEnergyOfVaporization = MathF.Max(0f, - molarEnthalpyOfVaporization - AtmosPhysicalConstants.MolarGasConstant * temperature); chunk.TotalHeatCapacity[voxelIndex] = newHeatCapacity; if (newHeatCapacity > 0f) { @@ -116,4 +235,4 @@ private static void ApplyCondensation(AtmosChunk chunk, AtmosSolverConfigSnapsho chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs index d9a0b46..0708820 100644 --- a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs @@ -542,9 +542,9 @@ public void Condensation_RunsOnEvenTickAndReleasesInternalPhaseChangeEnergy() Is.EqualTo(10f)); Assert.That(afterOddTick.Temperature[0], Is.EqualTo(200f)); Assert.That(SimTestHelpers.Moles(afterEvenTick, SimTestHelpers.FirstGasId, 0), - Is.EqualTo(7.5f).Within(SimTestHelpers.Tolerance)); + Is.EqualTo(7.5000255f).Within(SimTestHelpers.Tolerance)); Assert.That(afterEvenTick.Temperature[0], - Is.EqualTo(200.6666667f).Within(SimTestHelpers.Tolerance)); + Is.EqualTo(200.6666576f).Within(SimTestHelpers.Tolerance)); }); } @@ -569,9 +569,9 @@ public void Condensation_NonPositiveMolarHeatCapacityAtConstantVolumeUsesConfigu Assert.Multiple(() => { Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), - Is.EqualTo(7.5f).Within(SimTestHelpers.Tolerance)); + Is.EqualTo(7.4996104f).Within(SimTestHelpers.Tolerance)); Assert.That(snapshot.Temperature[0], - Is.EqualTo(201.6666667f).Within(SimTestHelpers.Tolerance)); + Is.EqualTo(201.667013f).Within(SimTestHelpers.Tolerance)); }); } @@ -629,8 +629,167 @@ public void CondensationRateFactor_AboveOneIsClampedToOne() simulation.Tick(); var snapshot = simulation.GetChunkSnapshot(chunk); - Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), - Is.EqualTo(5f).Within(SimTestHelpers.Tolerance)); + Assert.Multiple(() => + { + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(5.000051f).Within(SimTestHelpers.Tolerance)); + Assert.That(snapshot.Temperature[0], + Is.EqualTo(201.9999592f).Within(SimTestHelpers.Tolerance)); + }); + } + + [Test] + public void Condensation_LatentHeatingConvergesWaterVaporToWarmedSaturation() + { + const float initialMoles = 9000f / 256f; + const float initialTemperature = 293f; + var config = SimTestHelpers.CreateDeterministicConfig(); + config.VoxelVolume = 1f; + config.SaturationReferencePressure = AtmosPhysicalConstants.StandardAtmosphericPressure; + config.CondensationRateFactor = 1f; + config.GasRegistry = + [ + new GasProperties + { + Name = "Water vapor", + MolarHeatCapacityAtConstantVolume = 33f, + BoilingPoint = 373f, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = 40_650f, + DiffusionCoefficient = 0f + } + ]; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, + initialMoles, initialTemperature); + + simulation.Tick(); + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + float remainingMoles = SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0); + float finalTemperature = snapshot.Temperature[0]; + double partialPressure = remainingMoles * AtmosPhysicalConstants.MolarGasConstant * + finalTemperature / config.VoxelVolume; + double exponent = -config.GasRegistry[0].MolarEnthalpyOfVaporization / + AtmosPhysicalConstants.MolarGasConstant * + (1d / finalTemperature - 1d / config.GasRegistry[0].BoilingPoint); + double saturationPressure = config.SaturationReferencePressure * Math.Exp(exponent); + double relativeSaturationError = Math.Abs(partialPressure - saturationPressure) / + saturationPressure; + + Assert.Multiple(() => + { + Assert.That(remainingMoles, Is.EqualTo(32.87925f).Within(0.002f)); + Assert.That(finalTemperature, Is.EqualTo(373.195f).Within(0.05f)); + Assert.That(finalTemperature, Is.LessThan(400f)); + Assert.That(float.IsFinite(remainingMoles) && float.IsFinite(finalTemperature), Is.True); + Assert.That(relativeSaturationError, Is.LessThan(1e-4d)); + }); + } + + [Test] + public void Condensation_MixedInertGasUsesOtherHeatCapacityBeforeApplyingRateFactor() + { + const float initialTemperature = 200f; + const float initialVaporMoles = 10f; + const float inertMoles = 10f; + const float molarHeatCapacity = 5f; + const float molarEnthalpyOfVaporization = 5000f; + const float equilibriumCondensedMoles = 4f; + const float condensationRateFactor = 0.5f; + float molarInternalEnergyOfVaporization = MathF.Max(0f, + molarEnthalpyOfVaporization - + AtmosPhysicalConstants.MolarGasConstant * initialTemperature); + float equilibriumRemainingHeatCapacity = + (initialVaporMoles - equilibriumCondensedMoles + inertMoles) * molarHeatCapacity; + float equilibriumTemperature = initialTemperature + + equilibriumCondensedMoles / equilibriumRemainingHeatCapacity * + molarInternalEnergyOfVaporization; + + var config = SimTestHelpers.CreateDeterministicConfig(); + config.CondensationRateFactor = condensationRateFactor; + config.SaturationReferencePressure = + (initialVaporMoles - equilibriumCondensedMoles) * + (AtmosPhysicalConstants.MolarGasConstant / config.VoxelVolume) * + equilibriumTemperature; + config.GasRegistry = + [ + new GasProperties + { + Name = "Condensable", + MolarHeatCapacityAtConstantVolume = molarHeatCapacity, + BoilingPoint = equilibriumTemperature, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = molarEnthalpyOfVaporization, + DiffusionCoefficient = 0f + }, + new GasProperties + { + Name = "Inert", + MolarHeatCapacityAtConstantVolume = molarHeatCapacity, + DiffusionCoefficient = 0f + } + ]; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, + initialVaporMoles, initialTemperature); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.SecondGasId, + inertMoles, initialTemperature); + + simulation.Tick(); + simulation.Tick(); + + float expectedCondensedMoles = equilibriumCondensedMoles * condensationRateFactor; + float expectedVaporMoles = initialVaporMoles - expectedCondensedMoles; + float expectedRemainingHeatCapacity = + (expectedVaporMoles + inertMoles) * molarHeatCapacity; + float expectedTemperature = initialTemperature + + expectedCondensedMoles / expectedRemainingHeatCapacity * + molarInternalEnergyOfVaporization; + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(expectedVaporMoles).Within(0.001f)); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.SecondGasId, 0), + Is.EqualTo(inertMoles)); + Assert.That(snapshot.Temperature[0], + Is.EqualTo(expectedTemperature).Within(0.001f)); + }); + } + + [Test] + public void Condensation_ZeroFloatInternalEnergyUsesTheIsothermalEquilibrium() + { + const float initialTemperature = 200f; + var config = CreateCondensationConfig(); + config.CondensationRateFactor = 1f; + var gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.MolarHeatCapacityAtConstantVolume = 1e-9f; + gas.MolarEnthalpyOfVaporization = + AtmosPhysicalConstants.MolarGasConstant * initialTemperature; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, + 10f, initialTemperature); + + simulation.Tick(); + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(gas.MolarEnthalpyOfVaporization - + AtmosPhysicalConstants.MolarGasConstant * initialTemperature, + Is.Zero); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(5f).Within(SimTestHelpers.Tolerance)); + Assert.That(snapshot.Temperature[0], Is.EqualTo(initialTemperature)); + }); } [Test] @@ -752,4 +911,4 @@ private static AtmosChunkHandle CreateIsolatedVoxel(AtmosSimulation simulation, simulation.SetVoxelTemperature(chunk, x, y, z, temperature); return chunk; } -} \ No newline at end of file +} From 648c45bc0d6e7b12e3d46718399e79f562a2b92e Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:37:30 -0500 Subject: [PATCH 11/14] Improve temperature and pressure handling with validation, deferred adjustments, and new utilities. Added voxel snapping support, enhanced safety via representability checks, optimized gas mixture operations, and expanded solver test coverage. --- Numos.slnx | 2 + README.md | 11 + docs/atmospherics_technical_documentation.md | 252 ++- docs/headless_runner.md | 302 +++ examples/headless/two-voxel-flow.jsonl | 10 + .../AtmosDangerousSolver.cs | 4 +- src/Numos.API/AtmosSimulation.GasMixtures.cs | 71 +- src/Numos.API/AtmosSimulation.cs | 92 +- src/Numos.API/AtmosSolverPipeline.cs | 16 +- src/Numos.API/IGasMixture.cs | 9 +- src/Numos.CoreSim/AggregateVoxels.cs | 733 +++++++ src/Numos.CoreSim/AtmosChunk.cs | 428 +++- src/Numos.CoreSim/AtmosConfig.cs | 56 +- src/Numos.CoreSim/AtmosConfigDefaults.cs | 19 +- src/Numos.CoreSim/AtmosKernel.API.cs | 758 ++++++- src/Numos.CoreSim/AtmosKernel.GasMixtures.cs | 147 +- src/Numos.CoreSim/AtmosKernel.cs | 97 +- .../AtmosSolverConfigSnapshot.cs | 67 +- src/Numos.CoreSim/AtmosSolverConstants.cs | 5 +- .../Datatypes/Snapshots/AtmosChunkSnapshot.cs | 2 +- .../Datatypes/Snapshots/AtmosVoxelSnapshot.cs | 2 +- src/Numos.CoreSim/GasProperties.cs | 7 +- src/Numos.CoreSim/Solvers/AdvectionSolver.cs | 162 +- src/Numos.CoreSim/Solvers/AtmosSolverMath.cs | 15 +- .../Solvers/AtmosSolverPipeline.cs | 42 +- .../Solvers/BoundaryFlowSolver.cs | 319 ++- .../Solvers/PhaseChangeSolver.cs | 69 +- .../Solvers/ThermalBoundarySolver.cs | 193 +- .../Solvers/ThermalDiffusionSolver.cs | 111 +- src/Numos.Headless/Diagnostics/Coordinate.cs | 29 + .../Diagnostics/SimulationStateAnalyzer.cs | 533 +++++ .../Diagnostics/SimulationStateReports.cs | 192 ++ src/Numos.Headless/HeadlessApplication.cs | 85 + src/Numos.Headless/HeadlessCommandHost.cs | 260 +++ src/Numos.Headless/Numos.Headless.csproj | 17 + src/Numos.Headless/Program.cs | 15 + src/Numos.Headless/Properties/AssemblyInfo.cs | 4 + .../Protocol/HeadlessJsonContext.cs | 16 + .../Protocol/HeadlessRequest.cs | 152 ++ .../Protocol/HeadlessResponse.cs | 47 + src/Numos.Headless/SimulationSession.cs | 353 +++ src/Numos.Viewer/SimulationViewer.RenderUi.cs | 44 +- .../AtmosDangerousApiTests.cs | 28 +- .../Numos.API.Tests/AtmosChunkVersionTests.cs | 5 +- .../AtmosSimulationContractTests.cs | 337 +++ .../AtmosSolverPipelineTests.cs | 995 ++++++++- tests/Numos.API.Tests/GasMixtureTests.cs | 139 +- .../IntraChunkFlowTests.cs | 9 +- ...rogressiveVoxelSnappingIntegrationTests.cs | 1914 +++++++++++++++++ .../SimTestHelpers.cs | 2 + .../SimulationLifecycleIntegrationTests.cs | 14 +- .../SimulationStabilityTests.cs | 3 +- .../ThermodynamicsIntegrationTests.cs | 42 + .../AtmosChunkInjectionTests.cs | 25 +- .../AtmosChunkTopologyTests.cs | 33 +- tests/Numos.CoreSim.Tests/AtmosConfigTests.cs | 36 +- .../HeadlessApplicationTests.cs | 592 +++++ .../Numos.Headless.Tests.csproj | 34 + 58 files changed, 9586 insertions(+), 370 deletions(-) create mode 100644 docs/headless_runner.md create mode 100644 examples/headless/two-voxel-flow.jsonl create mode 100644 src/Numos.CoreSim/AggregateVoxels.cs create mode 100644 src/Numos.Headless/Diagnostics/Coordinate.cs create mode 100644 src/Numos.Headless/Diagnostics/SimulationStateAnalyzer.cs create mode 100644 src/Numos.Headless/Diagnostics/SimulationStateReports.cs create mode 100644 src/Numos.Headless/HeadlessApplication.cs create mode 100644 src/Numos.Headless/HeadlessCommandHost.cs create mode 100644 src/Numos.Headless/Numos.Headless.csproj create mode 100644 src/Numos.Headless/Program.cs create mode 100644 src/Numos.Headless/Properties/AssemblyInfo.cs create mode 100644 src/Numos.Headless/Protocol/HeadlessJsonContext.cs create mode 100644 src/Numos.Headless/Protocol/HeadlessRequest.cs create mode 100644 src/Numos.Headless/Protocol/HeadlessResponse.cs create mode 100644 src/Numos.Headless/SimulationSession.cs create mode 100644 tests/Numos.CoreSim.IntegrationTests/ProgressiveVoxelSnappingIntegrationTests.cs create mode 100644 tests/Numos.Headless.Tests/HeadlessApplicationTests.cs create mode 100644 tests/Numos.Headless.Tests/Numos.Headless.Tests.csproj diff --git a/Numos.slnx b/Numos.slnx index 3206f46..485188e 100644 --- a/Numos.slnx +++ b/Numos.slnx @@ -4,6 +4,7 @@ + @@ -11,6 +12,7 @@ + diff --git a/README.md b/README.md index f23b64e..8d138e2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,17 @@ See `CONTRIBUTING.md` before contributing. ## Documentation Documentation for APIs and the project itself is available under `/docs`. +### Headless debugging + +`Numos.Headless` runs reproducible simulation experiments from newline-delimited JSON without opening the graphical +viewer. It can read commands interactively from standard input or replay a checked-in script, emitting one compact +JSON response per command for tools and automated comparisons. See the [headless runner guide](docs/headless_runner.md) +and the checked-in [two-voxel flow](examples/headless/two-voxel-flow.jsonl) and +[16×16 equilibrium](examples/headless/16x16-equilibrium.jsonl) experiments. The +[relative-pressure snap](examples/headless/16x16-relative-snap-equilibrium.jsonl) and +[mixed-gas relative-pressure snap](examples/headless/16x16-relative-snap-mixed-gas-equilibrium.jsonl) scenarios +exercise the production `0.1%` snap tolerance with the normal `0.1` Pa/tick minimum-transfer setting. + ## Copyright, Credits & License Numos is licensed under the MIT license. See `LICENSE.TXT` for more info. diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 4f98ad4..4695f3f 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -60,10 +60,13 @@ The system uses a two-layer Level of Detail (LOD) model: | Layer | Name | Granularity | Cost | When Active | |-------|------|-------------|------|-------------| -| **Macro** | Room Node | Whole-room aggregate | O(1) per room | Room is at equilibrium (sleeping) | +| **Macro (planned)** | Room Node | Whole-room aggregate | O(1) per room | Not wired into the simulation loop | | **Micro** | Atmos Chunk | Per-voxel cellular automata | O(n) per active voxel | Room has turbulent pressure gradients | -When a disturbance exceeds a configurable threshold (the "Threshold of Violence"), the macro-layer room transitions to the micro-layer voxel grid. When the voxel grid reaches equilibrium, it goes back to sleep and the macro layer resumes responsibility. +The current simulation always retains materialized per-voxel state. Automatic convergence can conservatively project +settled neighboring voxels into uniform temporary aggregates before a chunk sleeps, but those aggregates are not +persisted as `RoomNode`s. The planned macro layer would instead retain one whole-room aggregate and materialize the +voxel grid again after a sufficiently large disturbance; that transition is not implemented. ### Component Relationships @@ -143,7 +146,7 @@ Each chunk stores: | `TotalPressure` | `float[]` | Cached pressure per voxel in pascals (Pa), recalculated at advection start and refreshed as state changes | | `Temperature` | `float[]` | Temperature in kelvins (K) per voxel | | `TotalHeatCapacity` | `float[]` | Cached total heat capacity per voxel, in J/K | -| `ActiveAirIndices` | `ushort[]` | Dense list of voxel indices belonging to the currently active rooms | +| `ActiveAirIndices` | `ushort[]` | Sorted dense list of the passable components reached from active-room seeds | | `ActiveGases` | `GasChannel[]` | Sparse array of gas-specific mole data (see §3.2) | For thermodynamic calculations, each gas uses an effective molar heat capacity at constant volume: @@ -166,7 +169,15 @@ The gas-constant value and SI relationship follow the [NIST reference constants] Chunks are identified by an `Int3 GridPosition` in a spatial map (e.g. a `ConcurrentDictionary`). -**Active Air Optimization**: Steady-state physics loops iterate the dense `ActiveAirIndices` list rather than every voxel. `WakeRoom(roomId)` adds the room to `ActiveRoomIds` up to the configured `MaxActiveRooms`, then `RebuildActiveAirIndices` scans all `VoxelCount` entries and rebuilds the list with voxels from every active room. The rebuild is therefore O(`VoxelCount`) (4,096 entries for a default 16×16×16 chunk), while subsequent physics work is proportional to the active list. +**Active Air Optimization**: Steady-state physics loops iterate the dense `ActiveAirIndices` list rather than every +voxel. `WakeRoom(roomId)` adds a seed label to `ActiveRoomIds` up to `MaxActiveRooms`. +`RebuildActiveAirIndices` starts from every voxel with a seed label, then flood-fills through face-connected voxels +whose classification is neither solid nor void. Room IDs therefore control activation but are not physical flow +barriers. A different-label voxel connected to an active seed participates in gas, thermal, and snap processing; +an inactive component isolated by solid or void remains untouched only when it does not reuse an active seed label. +Every disconnected passable region carrying the same seed label is activated together. Rebuild is +O(`VoxelCount + passable edges`) and +produces ascending flat-index order, while subsequent physics work is proportional to this active closure. ### 3.2 Gas Channels (Structure of Arrays) @@ -201,7 +212,7 @@ Each voxel in `VoxelRoomMap` is assigned an integer value that determines its be ### 3.4 Room Nodes (Macro Layer) -When a room is at equilibrium (sleeping), it is represented by a `RoomNode`: +The planned macro layer would represent an equilibrium room with a `RoomNode`: ``` struct RoomNode { @@ -237,7 +248,7 @@ Each gas species is defined by a `GasProperties` struct: | `CondensationEnabled` | `bool` | Enables this species in the condensation model. | | `MolarEnthalpyOfVaporization` | `float` | Vaporization enthalpy in J/mol, used by Clausius–Clapeyron and converted to an approximate constant-volume internal-energy change for condensation. | | `LiquidId` | `int` | Reserved integration ID. The built-in solver does not currently create liquid state or emit a condensation event. | -| `DiffusionCoefficient` | `float` | Dimensionless fraction of the per-species mole imbalance mixed per simulation tick; finite values are clamped to [0, 1], and non-finite values disable species diffusion. | +| `DiffusionCoefficient` | `float` | Dimensionless fraction of the per-species mole imbalance mixed per simulation tick; finite values normalize to [0, 1], explicit face updates cap the effective fraction at 0.5, and non-finite values disable species diffusion. | The registry is stored as a `List` indexed by gas ID; zero is a valid gas ID. @@ -256,17 +267,21 @@ single definition in `VoxelClassification`. | `GlobalTemperature` | 293.15 | Reference ambient temperature (K). Not actively used in the simulation loop. | | `DefaultTemperatureFallback` | 293.15 | Starting effective temperature (K) used for pressure and sensible energy when a gas-bearing voxel stores a non-finite or nonpositive temperature. Invalid values normalize to 293.15 K. | | `DefaultMolarHeatCapacityAtConstantVolume` | `5R/2` | Ideal-diatomic molar `C_v` in J/(mol·K), used for missing registry entries and non-finite or nonpositive gas heat capacities. A non-finite or nonpositive fallback value is normalized to the same value. | -| `VoxelVolume` | 1 | Physical volume represented by each voxel (m³). Invalid values normalize to 1 m³. | +| `VoxelVolume` | 1 | Physical volume represented by each voxel (m³). Non-finite/nonpositive values, and positive values whose single-precision `R/V` coefficient is unrepresentable, normalize to 1 m³. | | `SaturationReferencePressure` | 101325 | Pressure (Pa) at which each gas's `BoilingPoint` applies. Invalid values normalize to one standard atmosphere. | -| `DefaultDiffusionCoefficient` | 0.02 | Dimensionless per-tick mixing fraction for unregistered gas IDs. Finite values are clamped to [0, 1]; non-finite values disable fallback diffusion. | +| `DefaultDiffusionCoefficient` | 0.02 | Dimensionless per-tick mixing fraction for unregistered gas IDs. Finite values normalize to [0, 1], explicit face updates cap the effective fraction at 0.5, and non-finite values disable fallback diffusion. | | `SpaceTemperature` | 2.7 | Temperature of space (K). Not actively used in the simulation loop. | | `BulkFlowCoefficient` | 0.25 | Dimensionless fraction of pressure delta requested as bulk flow per tick. Finite values are clamped to [0, 1]; non-finite values disable the large-delta branch. | | `BulkFlowDamping` | 0.5 | Multiplier applied to `BulkFlowCoefficient` during large-delta advection to reduce oscillation. Finite values are clamped to [0, 1]; non-finite values disable the large-delta branch. | | `LowPressureDeltaThreshold` | 5.0 | Below this pressure delta (Pa), flow uses `MaxPressureTransferFractionPerNeighbor` directly instead of `BulkFlowCoefficient * BulkFlowDamping`. Invalid or negative values normalize to zero. | | `MinimumPressureTransfer` | 0.1 | Candidate pressure transfers below this magnitude (Pa/tick) are discarded ("stiction"). Invalid or negative values normalize to zero. | | `VacuumThreshold` | 1.0 | Below this pressure (Pa), voxel contents are zeroed out. Invalid or negative values normalize to zero. | -| `SleepThreshold` | 100 | Consecutive ticks below `SleepEpsilon` before a chunk goes to sleep. Negative values normalize to zero. | -| `SleepEpsilon` | 3.5 | Maximum pressure delta considered "at rest" (Pa). Invalid or negative values normalize to zero. | +| `SleepThreshold` | 100 | Consecutive stable verification ticks required before a chunk automatically sleeps. Snap-assisted mode uses at least the built-in two-tick thermodynamics cadence; negative values normalize to zero. | +| `SleepEpsilon` | 0.5 | Absolute pressure tolerance (Pa). With voxel snapping enabled, this is the floor in the hybrid per-member pressure bound. With snapping disabled, it is the legacy maximum neighboring pressure delta considered at rest. Invalid or negative values normalize to zero. | +| `VoxelSnapPressureRelativeEpsilon` | 0.001 | Relative pressure tolerance used by voxel snapping. For each proposed member, this fraction is multiplied by the greatest of its current pressure, the aggregate equilibrium pressure, and `VacuumThreshold`; the allowed pressure correction is the greater of that result and `SleepEpsilon`. Finite values normalize to [0, 1], and non-finite values normalize to zero. | +| `VoxelSnappingEnabled` | `true` | Enables the progressive, conservative intra-chunk projection used before automatic sleep. Disabling it skips projection and retains pressure-only automatic sleep while advection is enabled. It does not disable `SleepChunk`. | +| `VoxelSnapTemperatureEpsilon` | 0.01 | Maximum temperature correction (K) permitted for every member of a candidate voxel-snap aggregate. Invalid or negative values normalize to zero. | +| `VoxelSnapMoleFractionEpsilon` | 0.001 | Maximum per-species mole-fraction correction permitted for every member of a candidate voxel-snap aggregate. This value is dimensionless; finite values are clamped to [0, 1], and non-finite values normalize to zero. | | `ThermalConductance` | 0.05 | Effective per-face conductance in J/K per thermodynamics tick. Multiplying it by a temperature difference produces a candidate energy transfer, which is bounded for explicit-solver stability. Invalid or nonpositive values disable thermal diffusion. | | `CondensationRateFactor` | 0.5 | Dimensionless fraction of the heat-coupled equilibrium condensation amount applied per thermodynamics tick. Finite values are clamped to [0, 1]; non-finite values disable condensation. | | `MaxPressureTransferFractionPerNeighbor` | 0.16 | Maximum fraction of a voxel's pressure requested as bulk flow to one neighbor per tick. Finite values are clamped to [0, 1]; non-finite values disable bulk flow. | @@ -297,9 +312,10 @@ low-level tooling parity. `AddGas` and transfers instead conserve sensible inter constant-volume molar heat capacity. Pressure is always derived from `P = nRT/V` rather than being independently mutable. -The `Temperature` setter stores its raw value for parity with direct voxel tooling. Non-finite and nonpositive stored -temperatures are interpreted through `DefaultTemperatureFallback` when pressure or sensible energy is calculated. -Creation and incoming-gas operations still require finite, nonnegative temperatures. +Subject to a representable derived pressure, the `Temperature` setter stores its raw value for parity with direct +voxel tooling. Non-finite and nonpositive stored temperatures are interpreted through `DefaultTemperatureFallback` +when pressure or sensible energy is calculated. Creation and incoming-gas operations still require finite, +nonnegative temperatures. At the start of each simulation tick, the solver captures the current `AtmosConfig` reference and a normalized configuration/gas-property snapshot. Built-in stages use the normalized snapshot for the whole tick. Standard and @@ -307,12 +323,13 @@ dangerous contexts expose the captured live reference, so mutating it is visible replacing the simulation configuration during a callback does not change the reference seen by later callbacks. Either kind of configuration change affects normalized built-in settings on the next tick. -Persistent voxel state and advection work buffers use single precision. Overflow-prone formulas use stable algebraic +Persistent voxel state uses single precision. Overflow-prone formulas use stable algebraic forms: thermal equilibrium conductance is evaluated without forming `C1 * C2`, heat-capacity-weighted mixing uses bounded interpolation, and condensation computes the temperature increment without subtracting large sensible-energy -terms. Thermal diffusion alone accumulates conductance and equal-and-opposite energy deltas in `double`; temperatures, -pressures, heat capacities, and gas inventories remain `float`. This prevents a representable temperature result from -being lost when an intermediate `C * ΔT` exceeds the `float` range. +terms. Intra-chunk advection and thermal diffusion accumulate mole and/or equal-and-opposite energy deltas in +`double`, then validate the projected single-precision state before committing it. Temperatures, pressures, heat +capacities, and gas inventories remain `float`. This prevents a representable result from being lost when fan-in or an +intermediate `C * ΔT` exceeds the `float` range. ```csharp var canister = simulation.CreateGasMixture(volume: 0.07f, temperature: 293.15f); @@ -393,6 +410,12 @@ Recursive `Tick`/`Update`, simulation disposal, and chunk registration/removal a they would invalidate or escape the current chunk snapshot. Perform those lifecycle operations outside the solver tick. +Adding an enabled custom stage, re-enabling a registered stage, or resetting a pipeline that has a missing or disabled +built-in invalidates solver-derived equilibrium at the next tick boundary. Automatically slept chunks resume their +retained active domains before that tick executes; chunks frozen explicitly with `SleepChunk` remain asleep. Pure +removal operations (`Unregister`, disabling a stage, or a reset that only removes custom stages), missing-stage and +same-state `SetEnabled` calls, and an already-default reset do not wake chunks. + Solver-specific settings should remain with the solver instead of expanding `AtmosConfig` with unrelated game configuration. Implement `IAtmosSolver` to make that ownership explicit: @@ -471,16 +494,20 @@ This is the core fluid dynamics step. It runs in parallel across chunks. 3. **Fickian Diffusion**: Independently of the total-pressure gradient and bulk-flow cutoff, a species diffusion term based on partial-pressure imbalance is applied: ``` deltaN = moles[src] - moles[neighbor] * (neighborTemp / srcTemp) - molesDiffused = deltaN * DiffusionCoefficient + effectiveDiffusionCoefficient = min(DiffusionCoefficient, 0.5) + molesDiffused = deltaN * effectiveDiffusionCoefficient ``` - This allows gases with different diffusion rates to mix after bulk pressure has equalized and permits one species to counter-diffuse against the net bulk-flow direction. Coefficients are clamped to [0, 1] for explicit-step stability. The Z-axis is checked conditionally, only when `Depth > 1`, allowing efficient 2D operation. + This allows gases with different diffusion rates to mix after bulk pressure has equalized and permits one species + to counter-diffuse against the net bulk-flow direction. Configured coefficients are normalized to [0, 1], then + capped at `0.5` for an explicit face update so a unit setting relaxes an isolated pair to equilibrium instead of + swapping its inventories every tick. The Z-axis is checked conditionally, only when `Depth > 1`, allowing efficient + 2D operation. -4. **Apply deltas**: After all voxels have been processed, the accumulated mole deltas are applied and per-species amounts below `AtmosSolverConstants.MinimumTrackedMoles` (currently 0.0001 mol) are snapped to 0. Each voxel's heat capacity is recalculated from its new composition, then its temperature is recovered from `newTemperature = (oldTotalHeatCapacity * oldEffectiveTemperature + energyDelta) / newTotalHeatCapacity`. A voxel with no heat capacity retains its stored temperature. The pressure cache is refreshed from the resulting moles and temperature before boundary processing. +4. **Apply deltas**: After all voxels have been processed, the accumulated mole deltas are applied. Negative roundoff is clamped to zero, while positive representable trace amounts are retained; a per-voxel trace cutoff would not conserve a species that is distributed across many voxels. Each voxel's heat capacity is recalculated from its new composition, then its temperature is recovered from `newTemperature = (oldTotalHeatCapacity * oldEffectiveTemperature + energyDelta) / newTotalHeatCapacity`. A voxel with no heat capacity retains its stored temperature. The pressure cache is refreshed from the resulting moles and temperature before boundary processing. -5. **Emit boundary events**: Every gas-bearing voxel that survives vacuum cleanup and lies on a chunk edge - (coordinate is 0 or `Size - 1`) emits one `BoundaryFlowEvent`. Eligibility is based on gas inventory rather than - a second positive-pressure check: an extremely small representable pressure can underflow to zero while the - species mole imbalance still supports diffusion. +5. **Emit boundary events**: Every active voxel on a chunk edge (coordinate is 0 or `Size - 1`) emits one + `BoundaryFlowEvent` before vacuum cleanup. Empty and vacuum endpoints must publish their edges so an awake + low-pressure side can discover and wake an actionable higher-pressure sleeping neighbor. ### 4.4 Stage 2 — Cross-Chunk Boundary Flow @@ -492,23 +519,31 @@ For each boundary event: 3. If outside: look up the neighboring chunk at `GridPosition + direction`. 4. Map the out-of-bounds coordinate into the neighbor's local space using modular arithmetic: `nX = (targetX + neighborWidth) % neighborWidth`. 5. If the neighbor voxel is solid, skip. -6. Calculate any outward bulk pressure transfer with the same limiter used by intra-chunk advection, including damping, the low-delta branch, minimum-transfer cutoff, and the per-neighbor cap. A sleeping target is woken only if a positive mole transfer will actually be injected. +6. Calculate any outward bulk pressure transfer with the same limiter used by intra-chunk advection, including damping, the low-delta branch, minimum-transfer cutoff, and the per-neighbor cap. Boundary events are emitted even by empty/vacuum active endpoints. If such an endpoint observes an actionable higher-pressure or composition-imbalanced sleeping neighbor, it wakes that neighbor so directed flow resumes on the next tick. 7. For each source species, combine bulk advection with the same positive partial-pressure diffusion term used inside a chunk. Diffusion is evaluated even when bulk flow is zero or points in the opposite direction: ``` molesAdvected = (flow * VoxelVolume / (R * sourceEffectiveTemperature)) * moleFraction deltaN = sourceMoles - neighborMoles * (neighborEffectiveTemperature / sourceEffectiveTemperature) - molesDiffused = DiffusionCoefficient > 0 ? max(0, deltaN * DiffusionCoefficient) : 0 + boundaryDiffusionCoefficient = min(DiffusionCoefficient, 0.5) + molesDiffused = boundaryDiffusionCoefficient > 0 ? max(0, deltaN * boundaryDiffusionCoefficient) : 0 molesMoved = min(sourceMoles, molesAdvected + molesDiffused) ``` - For a void target, neighbor moles and temperature are treated as zero. An unregistered gas uses `DefaultDiffusionCoefficient`. + For a void target, neighbor moles and temperature are treated as zero. An unregistered gas uses + `DefaultDiffusionCoefficient`. Both intra- and cross-chunk explicit face updates cap the effective coefficient at + `0.5`, so an isolated pair relaxes to equilibrium instead of swapping inventories. The cap is especially important + across chunks because both endpoint events are processed sequentially; it prevents the reverse event from consuming + a species just moved by the first event. 8. Transfer the capped moles directly (no delta buffer — this is sequential). Each species carries `molesMoved * c_effective * sourceEffectiveTemperature` of sensible energy during the direct transfer. The source and target heat-capacity caches, temperatures, and pressures are updated immediately by energy balance. Before injection, the target voxel's existing heat capacity is recalculated from its current moles and the normalized gas registry captured for the tick, including for a target chunk that was sleeping before the transfer. -If the adjacent chunk is not registered or the mapped target is solid, no transfer occurs. A non-void target room is -woken before it receives gas. Any source that moves gas is also kept awake with its sleep timer reset, because the -intra-chunk sleep scan cannot observe a cross-chunk gradient. A void target is an energy sink: transferred moles and -their carried energy are removed from the source without being added to a target voxel. +If the adjacent chunk is not registered or the mapped target is solid, no transfer occurs. A non-void target's +classification seed is activated before it receives gas (including disconnected regions sharing that label). If +active-room capacity cannot admit that seed, the individual edge is +deterministically deferred without changing either endpoint; capacity is not allowed to fail a partially processed +boundary batch. Any source that moves gas is also kept awake with its sleep timer reset, because the intra-chunk sleep +scan cannot observe a cross-chunk gradient. A void target is an energy sink: transferred moles and their carried +energy are removed from the source without being added to a target voxel. ### 4.5 Stages 3 and 4 — Thermodynamics and Thermal Boundaries @@ -536,7 +571,13 @@ temperature extrema. Voxels with zero heat capacity do not participate and retai **Phase Changes (Condensation)**: See §8. These run after intra-chunk thermal temperatures have been applied and before thermal-boundary events are drained. -**Cross-Chunk Thermal Diffusion**: Boundary faces are deduplicated, their post-phase-change temperatures and heat capacities are snapshotted, and the same `g`, `G`, `s`, and `Q` equations are applied across the entire boundary set. Equal-and-opposite energy deltas are buffered before any boundary temperature is written, eliminating concurrent-queue traversal bias. Solid and void voxels do not conduct, voxels below `VacuumThreshold` are excluded, and a missing adjacent chunk receives no heat. Depth-one chunks do not conduct through their Z faces. Thermal transfer can update a sleeping neighbor without waking it. +**Cross-Chunk Thermal Diffusion**: Boundary faces are deduplicated, their post-phase-change temperatures and heat +capacities are snapshotted, and the same `g`, `G`, `s`, and `Q` equations are applied across the entire boundary set. +Equal-and-opposite energy deltas are buffered before any boundary temperature is written, eliminating +concurrent-queue traversal bias. Solid and void voxels do not conduct, voxels below `VacuumThreshold` are excluded, +and a missing adjacent chunk receives no heat. Depth-one chunks do not conduct through their Z faces. Before any +temperature is written, every affected voxel component is capacity-validated and woken; a nonzero transfer resets +its sleep verification window. --- @@ -569,6 +610,11 @@ Two regimes are used depending on the magnitude of the pressure delta: Flows below `MinimumPressureTransfer` (0.1) are discarded entirely. This prevents infinitesimal flows from keeping a chunk awake indefinitely and accelerates convergence by eliminating micro-oscillations. +`MinimumPressureTransfer` governs ordinary advection only; it is not a prerequisite for voxel-snap eligibility. +Its production default remains `0.1` Pa/tick. The conservative snap projection intentionally finishes the +asymptotically diminishing flow tail once the aggregate satisfies its absolute/relative pressure, temperature, and +composition tolerances, even if an unsnapped face would still request an ordinary bulk transfer above this cutoff. + ### 5.4 Vacuum Cleanup Voxels with `TotalPressure < VacuumThreshold` (1.0) have all gas moles zeroed out. This prevents the accumulation of trace gas amounts that would otherwise never fully equalize and would keep chunks awake. @@ -576,15 +622,16 @@ Voxels with `TotalPressure < VacuumThreshold` (1.0) have all gas moles zeroed ou ### 5.5 Delta Buffers (Ordering Scope) Mole and sensible-energy transfers within a chunk are not applied directly during the neighbor scan. Gas-major mole -deltas are accumulated in a rented `float[]` at `gasIndex * VoxelCount + voxelIndex`. Equal-and-opposite sensible -energy deltas use a separate rented `double[]`, preventing a representable final temperature from being lost when an -intermediate `moles * C_v * temperature` exceeds the `float` range. After every active source voxel has been scanned, -the mole and energy deltas are applied together in a single pass and persistent state is stored as `float`. +deltas are accumulated in a rented `double[]` at `gasIndex * VoxelCount + voxelIndex`. Equal-and-opposite sensible +energy deltas use a separate rented `double[]`, preventing representable final moles or temperature from being lost +when several inflows or an intermediate `moles * C_v * temperature` exceed the `float` range. After every active source +voxel has been scanned, the complete projected state is checked for representable moles, heat capacity, temperature, +and pressure. Only then are the mole and energy deltas applied together and persistent state stored as `float`. This buffering prevents an earlier voxel's applied result from changing the snapshot read by a later voxel, so results do not depend on active-voxel iteration order when the neighbor order is held fixed. It does not make every permutation equivalent: the separate `scheduledOutflows` safety cap is consumed in fixed neighbor order, as described in §5.1, and can favor earlier directions when a source saturates. -The mole-delta and gas-major `scheduledOutflows` arrays are rented from `ArrayPool`; the energy-delta array is -rented from `ArrayPool`. All are returned after application. +The mole- and energy-delta arrays are rented from `ArrayPool`; the gas-major `scheduledOutflows` array is +rented from `ArrayPool`. All are returned after application. > [!NOTE] > Cross-chunk gas flow is deterministic but sequential and updates current state immediately, so a later boundary @@ -595,23 +642,107 @@ rented from `ArrayPool`. All are returned after application. ## 6. Sleep System -Each chunk maintains a `SleepTimer` counter. After each advection pass: +Each chunk maintains a `SleepTimer` counter. Automatic sleep has two modes selected by +`VoxelSnappingEnabled`. -1. The maximum pressure delta across all neighbor pairs in the chunk (`maxPressureDelta`) is tracked. -2. If `maxPressureDelta < SleepEpsilon` (3.5): increment `SleepTimer`. -3. If `SleepTimer > SleepThreshold` (100): set `IsAwake = false`. The chunk ceases all processing. -4. If `maxPressureDelta ≥ SleepEpsilon`: reset `SleepTimer` to 0. +### Snap-assisted automatic sleep (default) -A sleeping chunk is woken when: -- `InjectGasToVoxel` is called on it (the sleep timer is reset). -- A boundary flow event targets one of its voxels (the target room is woken via `WakeRoom`). +When voxel snapping is enabled, a terminal coordinator runs **after the complete configured solver pipeline**, even +after custom stages registered after the built-ins. It progressively aggregates settled, face-connected passable +neighbors **within one chunk**. Room IDs do not divide an active passable component, while solid and void voxels do. +A proposed aggregate is accepted only when projecting its members to their joint equilibrium keeps +every member within the configured correction limits: -A chunk that sends gas across a boundary is kept awake and has its sleep timer reset. The sleep criterion itself is -pressure-based; a temperature gradient alone does not wake or keep a chunk active. +- Pressure uses a hybrid absolute/relative bound. For member `i` and proposed equilibrium pressure `P_eq`: -The sleep system is the primary mechanism for achieving the "work-proportional cost" goal. In a station with 500 chunks, only the handful with active pressure gradients consume CPU. + ``` + pressureScale_i = max(P_i, P_eq, VacuumThreshold) + allowedPressureCorrection_i = max( + SleepEpsilon, + VoxelSnapPressureRelativeEpsilon * pressureScale_i) + abs(P_i - P_eq) <= allowedPressureCorrection_i + ``` + + The default relative epsilon is `0.001`, or `0.1%`; `SleepEpsilon = 0.5 Pa` remains the absolute floor near vacuum. +- `VoxelSnapTemperatureEpsilon`, in kelvins (K), bounds the temperature correction. +- `VoxelSnapMoleFractionEpsilon`, a dimensionless value in [0, 1], bounds the correction to every species' mole + fraction. + +These are bounds on the proposed aggregate state, not merely pairwise neighbor differences. Consequently, a long +chain of individually similar neighbors cannot transitively authorize an arbitrarily large correction at its +endpoints. Aggregation is progressive: a settled pair or group can join an adjacent group only when the combined +projection still satisfies every member's bounds. Snap eligibility does not wait for the ordinary advection transfer +request to fall below `MinimumPressureTransfer`; projection is the deliberate, conservative cutoff for that long +tail. A deterministic disjoint merge round lets each aggregate participate at most once per tick, so overlapping +neighborhood averages cannot double-count mass or depend on traversal mutation order. + +For an accepted aggregate of `k` equal-volume voxels, the projection reduces the current materialized state to total +moles for every gas and total sensible internal energy: + +``` +N_g = sum(n_i,g) +C_total = sum_g(N_g * Cv_g) +E_total = sum_i(T_effective_i * sum_g(n_i,g * Cv_g)) + +n'_i,g = N_g / k +T'_i = E_total / C_total +``` -Unit tests confirm convergence to sleep for L-shaped, donut-shaped, and zigzag room geometries, with pressure equilibrating to within 1.0 moles of the average across all voxels. +The solver reduces members and gas IDs in canonical order using double-precision totals, writes deterministic +remaining-total shares for each species and sensible energy, then recomputes heat capacity and pressure from the +materialized values. It refuses a projection whose final single-precision moles, temperature, heat capacity, or +pressure would be non-finite. Species and energy conservation are therefore deterministic and bounded by final +single-precision representability; exact bitwise conservation is not possible for every quotient such as one mole +across three float cells. This is still materialized voxel state, not a `RoomNode` or persistent macro state. + +Projection does not immediately hide the result. `SleepTimer` advances only on a later stable post-pipeline pass +where every active, passable intra-chunk edge is already internal to a settled aggregate, the exact materialized +state fingerprint is unchanged, and no merge or reprojection occurs. Any incomplete edge, rejected merge, public or +custom-stage mutation, gas/thermal boundary transfer, or new projection resets the window. Established aggregate +edges may be skipped by ordinary diffusion only while that exact fingerprint remains current; a disturbance is +processed normally before the terminal coordinator splits or revalidates the group. The effective verification +threshold is `max(SleepThreshold, ThermodynamicsTickInterval)` (currently two ticks), ensuring at least one complete +lower-frequency thermal/phase cadence is observed. Sleep occurs only when the timer grows beyond that threshold. + +Snap aggregates never span chunks. Registered neighboring chunks continue to exchange gas through the normal +boundary-flow stage, and a transfer keeps the source awake and wakes the target as applicable. Missing chunks remain +reflecting boundaries. This behavior should not be interpreted as an atomic cross-chunk equilibrium projection. + +### Snapping disabled + +Setting `VoxelSnappingEnabled` to `false` bypasses aggregation and conservative projection. While advection is +enabled, automatic sleep uses the legacy pressure-only rule: the timer advances while the maximum passable +intra-chunk neighbor pressure delta is below `SleepEpsilon`, resets when it is at or above that value, and sleeps the +chunk after the timer exceeds `SleepThreshold`. Disabling advection also disables this legacy sleep decision. The +relative-pressure, temperature, and mole-fraction epsilon settings have no effect in this mode. + +### Manual sleep and waking + +`SleepChunk` is deliberately a raw manual freeze. It immediately marks the chunk asleep in its current materialized +state; it does **not** run voxel snapping, verify pressure, temperature, composition, or boundaries, or wait for +`SleepThreshold`. It is therefore appropriate for debugging or caller-controlled lifecycle decisions, not as a +request to calculate equilibrium. Normal wake events can resume simulation from that frozen state. + +A sleeping chunk is woken when: +- `WakeRoom`, gas injection, or a gas-mixture mutation targets it. +- A gas or thermal boundary transfer targets one of its components, even when another component in the chunk was + already awake. +- registering a previously missing neighbor or opening an existing chunk boundary exposes a gas-bearing passable + component. +- a topology edit preserves a previously active component or opens a gas-bearing component to void. + +A chunk that sends gas across a boundary is kept awake and has its sleep timer reset. Successful wake planning is +capacity-validated before registration or boundary mutation is committed. Because automatic sleep is chunk-wide, +any successful wake of an automatic sleeper first restores its complete retained active seed/domain set; adding a +previously inactive component must fit alongside those retained seeds. Explicit manual sleepers retain the targeted +replacement-domain behavior. A direct temperature edit does not by +itself wake a manually sleeping chunk; callers can explicitly wake it when that is the desired lifecycle action. +The same edit does wake an automatically slept chunk because it invalidates solver-derived equilibrium. A normalized +physics-configuration change likewise invalidates aggregate state and wakes automatic sleepers while retaining their +active seed set; explicit manual sleepers remain frozen, although derived pressure/heat-capacity caches are refreshed +and versioned when the new configuration changes their visible values. + +The sleep system is the primary mechanism for achieving the "work-proportional cost" goal. In a station with 500 chunks, only the handful with active pressure gradients consume CPU. --- @@ -714,6 +845,10 @@ if C_after > 0: This temperature form is the simplified constant-volume energy equation after the departing vapor's sensible energy has canceled. It avoids computing and subtracting two potentially overflowing `T*C` terms. The temperature update is performed only when `C_after > 0`. The voxel's cached `TotalHeatCapacity` and `TotalPressure` are updated immediately. +Before changing vapor moles, the solver projects the remaining heat capacity, temperature, and pressure in wider +precision. If any persistent single-precision field would be non-finite, that condensation step is deferred without +changing the voxel, and the chunk remains awake for a later retry; phase change never commits a partially updated or +numerically poisoned state. As elsewhere in the energy model, a non-finite or nonpositive configured `MolarHeatCapacityAtConstantVolume` uses the normalized `DefaultMolarHeatCapacityAtConstantVolume`. @@ -782,9 +917,10 @@ All networking methods are stubs with comments indicating where real implementat 2. **Unidirectional flow in advection.** The advection loop only processes flow from high pressure to low (`pressureDelta > 0`). Due to the delta buffer, each voxel-pair transfer is computed from the higher-pressure side and applied after the neighbor scan. -3. **Sleep is pressure-driven.** The chunk sleep criterion observes intra-chunk pressure deltas, not temperature -gradients. Thermal diffusion can update an already participating sleeping neighbor across a boundary, but a thermal -gradient alone does not wake a chunk or keep its thermodynamics stage active. +3. **Activation remains label-seeded.** Default snap-assisted automatic sleep checks pressure, temperature, and +composition while a chunk is awake. Nonzero gas/thermal boundary transfers activate the receiving voxel's room label; +disconnected regions reusing that label activate together. A direct temperature edit wakes an automatic sleeper but +does not independently wake an explicitly slept chunk, and `SleepChunk` deliberately bypasses every convergence check. ### Performance @@ -832,8 +968,14 @@ If your target platform does not support threading (e.g., single-threaded WASM), ### Memory At 16×16×16 with one gas: -- Per chunk: about **88 KB** for `VoxelRoomMap`, `TotalPressure`, `Temperature`, `TotalHeatCapacity`, `ActiveAirIndices`, and one `GasChannel`, excluding smaller metadata arrays and pool overhead. +- Base materialized arrays: about **88 KB** for `VoxelRoomMap`, `TotalPressure`, `Temperature`, + `TotalHeatCapacity`, `ActiveAirIndices`, and one `GasChannel`. +- Persistent snap topology: about **40 KB** for inclusion, parent/next, and merge-participation arrays, for about + **128 KB per initialized one-gas chunk** before headers, small metadata, and pool slack. +- Snap finalization temporarily rents about **144 KB** of integer/double work arrays. Finalization is sequential + across chunks, so this workspace is returned to the shared pool after each chunk rather than retained per chunk. - Per additional gas: +16 KB. -- 512 chunks (8×8×8 grid): about **44 MB** before pool overhead and metadata. +- 512 initialized one-gas chunks (8×8×8 grid): about **64 MB** of persistent materialized/snap arrays before pool + overhead and metadata. `ArrayPool` rental means actual memory footprint depends on pool behavior. Arrays may be larger than requested and may persist in the pool after `Release()`. diff --git a/docs/headless_runner.md b/docs/headless_runner.md new file mode 100644 index 0000000..27054bc --- /dev/null +++ b/docs/headless_runner.md @@ -0,0 +1,302 @@ +# Headless simulation runner + +`Numos.Headless` is a machine-readable debugging host for Numos simulations. It exercises the supported +`Numos.API` mutation and snapshot paths without starting Raylib or ImGui, which makes experiments reproducible and +lets command-line tools inspect the same simulation state that feeds the viewer. + +The runner uses newline-delimited JSON (NDJSON/JSONL): each nonblank input line is one request and produces exactly one +compact JSON response on standard output. Blank lines are ignored. Human-readable diagnostics are written to standard +error so stdout can be parsed or diffed directly. + +## Running it + +Build the solution, then choose one of the three input modes: + +```powershell +# Interactive: read NDJSON requests from stdin until `exit` or end-of-file. +dotnet run --project src/Numos.Headless + +# Replay a script explicitly. +dotnet run --project src/Numos.Headless -- --script examples/headless/two-voxel-flow.jsonl + +# A script path may also be the sole positional argument. +dotnet run --project src/Numos.Headless -- examples/headless/two-voxel-flow.jsonl +``` + +Use `dotnet run --project src/Numos.Headless -- --help` for the command-line summary. When automating a replay, capture +stdout independently from stderr: + +```powershell +dotnet run --project src/Numos.Headless -- --script examples/headless/two-voxel-flow.jsonl ` + > two-voxel-flow.output.jsonl +``` + +JSONL files contain JSON objects only; they do not support comments or multi-line requests. `--help` is the one mode +that deliberately writes human-readable text to stdout instead of protocol responses. + +## Protocol envelope + +Every request must contain: + +```json +{"protocolVersion":1,"id":"create","op":"createSimulation","dimensions":{"x":2,"y":1,"z":1}} +``` + +- `protocolVersion` must be `1` on every request. +- `id` is a required, nonblank string supplied by the caller and copied to the corresponding response. Use unique + values when correlating a long interactive session. +- `op` selects an operation from the table below. + +Every response contains `protocolVersion`, the request's `id` and `op` when they could be decoded, and an `ok` boolean. +When a simulation is active, `state` contains its `name`, fixed chunk `dimensions`, `tick`, `chunkCount`, and `gasCount`. +Successful operations may add a `result` or `observation`. For example, a successful tick response has this shape: + +```json +{"protocolVersion":1,"id":"tick-1","op":"tick","ok":true,"state":{"name":"Two-voxel flow","dimensions":{"x":2,"y":1,"z":1},"tick":1,"chunkCount":1,"gasCount":1},"result":{"ticksExecuted":1}} +``` + +Failed operations set `ok` to `false` and include `error.code`, `error.message`, the JSONL `line`, and an +`exceptionType` when one is applicable. Failure output is still valid JSON, and the runner continues with the next +line. The `exit` operation writes its response before the process terminates. Unknown JSON properties and properties +that belong to a different operation are rejected, so a misspelled or misplaced experiment setting cannot be ignored +silently. + +Do not write log text to stdout when extending the runner. Protocol consumers rely on one response object per request. + +### Process exit codes + +- `0`: every processed request succeeded. +- `1`: at least one request produced an error response; later lines were still processed. +- `2`: command-line usage was invalid or the requested script could not be opened. + +An unavailable script also produces one JSON error response with code `scriptUnavailable`. + +### Error codes + +Protocol consumers can branch on `error.code` without parsing human-readable messages: + +| Code | Meaning | +| --- | --- | +| `invalidJson` | The input line is not syntactically valid JSON. | +| `invalidRequest` | The JSON is valid but does not match the v1 schema, including an unknown property or wrong value type. | +| `unsupportedProtocol` | `protocolVersion` is not `1`. | +| `missingProperty` | An operation is missing one of its required properties. | +| `unknownOperation` | `op` is not a supported operation name. | +| `simulationNotCreated` | The operation needs an active simulation. | +| `invalidGas` | A gas definition is invalid. | +| `gasNotFound` | `injectGas.gasId` is not present in the active gas registry. | +| `invalidTickCount` | `tick.count` is outside the allowed range. | +| `solverNotFound` | No registered solver has the requested name. | +| `operationRejected` | The supported Numos API rejected the requested mutation or address. | +| `internalError` | An unexpected failure occurred; its diagnostic is on stderr. | +| `scriptUnavailable` | The command-line script path could not be opened. | + +## Coordinates, classifications, and values + +Chunk and voxel coordinates use objects with `x`, `y`, and `z` members: + +```json +{"x":0,"y":0,"z":0} +``` + +A chunk position is measured in the chunk grid. A voxel position is local to its chunk. Voxel classifications use the +same integer values as the public API: + +- `-2`: solid +- `-1`: void +- `0`: unassigned +- positive values: room IDs + +Temperatures are in kelvins, pressure is in pascals, gas amounts are in moles, voxel volume is in cubic metres, and +heat capacities are in joules per mole-kelvin. + +## Operations + +Operations that access simulation state require an active simulation. A new `createSimulation` request atomically +replaces and disposes the current simulation after the replacement has been constructed successfully. + +| Operation | Request data | Effect | +| --- | --- | --- | +| `createSimulation` | Optional `name`; fixed chunk `dimensions`; optional `config` and `gases` | Creates a new paused, in-memory simulation at tick zero. | +| `closeSimulation` | None | Disposes the active simulation and clears its state. | +| `addChunk` | Chunk `position`; optional initial `classification` (default `0`) | Creates a chunk using the simulation's fixed dimensions and fills it with the classification. | +| `removeChunk` | Chunk `position` | Removes and disposes the chunk at that position. | +| `sealChunk` | Chunk `position` | Replaces the chunk's simulated outer faces with solid voxels. For depth-one chunks this seals the X/Y perimeter. | +| `setChunkClassification` | Chunk `position`; `classification` | Fills every voxel in a chunk with one classification. | +| `setVoxelClassification` | Chunk `position`; local `voxel`; `classification` | Changes one voxel's classification. | +| `setVoxelTemperature` | Chunk `position`; local `voxel`; `temperatureK` | Sets one voxel's stored temperature in kelvins. | +| `addGas` | `gas` definition | Appends a gas to the registry. `result.gasId` is its stable zero-based ID. | +| `injectGas` | Chunk `position`; local `voxel`; registered `gasId`, `moles`, and `temperatureK` | Adds gas to an air voxel and wakes its room. | +| `wakeRoom` | Chunk `position`; `roomId` | Wakes a room for subsequent simulation ticks. | +| `sleepChunk` | Chunk `position` | Immediately freezes the current materialized chunk state. It does not snap or verify equilibrium. | +| `updateConfig` | `config` patch | Updates only the supplied live configuration values for later mutations and ticks. | +| `setSolverEnabled` | `solver` name; `enabled` | Enables or disables a named solver stage without changing pipeline order. | +| `resetSolvers` | None | Restores the built-in solver pipeline and its enabled states. | +| `tick` | `count` from `1` through `1000000` | Runs exactly that many deterministic fixed simulation ticks. | +| `observe` | Optional `position`, `voxel`, `includeVoxels`, `onlyGasBearingVoxels`, and `maxIssueLocations` | Returns a coherent canonical report for the current tick. Dense per-voxel data is opt-in. | +| `exit` | None | Disposes the active simulation, responds, and stops reading input. | + +The built-in solver names accepted by `setSolverEnabled` are `advection`, `boundary-flow`, `thermodynamics`, and +`thermal-boundary`. A name that is not present in the current pipeline is an error rather than a silent no-op. +Re-enabling a stage, or `resetSolvers` restoring a missing or disabled built-in, invalidates solver-derived equilibrium +at the next tick boundary: automatically slept chunks resume their retained active domains, while chunks frozen with +`sleepChunk` remain asleep. Disabling a stage, requesting its current enabled state, and an already-default reset do +not wake chunks. + +### Configuration fields + +`createSimulation.config` and `updateConfig.config` accept these fields. Unit suffixes are part of the protocol names: + +- `globalTemperatureK` +- `defaultTemperatureFallbackK` +- `defaultMolarHeatCapacityAtConstantVolume` +- `voxelVolumeM3` +- `saturationReferencePressurePa` +- `defaultDiffusionCoefficient` +- `spaceTemperatureK` +- `bulkFlowCoefficient` +- `bulkFlowDamping` +- `lowPressureDeltaThresholdPa` +- `minimumPressureTransferPa` +- `vacuumThresholdPa` +- `sleepThreshold` +- `sleepEpsilonPa` +- `voxelSnapPressureRelativeEpsilon` +- `voxelSnappingEnabled` +- `voxelSnapTemperatureEpsilonK` +- `voxelSnapMoleFractionEpsilon` +- `thermalConductance` +- `condensationRateFactor` +- `maxPressureTransferFractionPerNeighbor` +- `accumulatorWakeThresholdPa` +- `accumulatorMaxAliveTicks` + +Omitted fields retain their current values. Keeping experiment configuration explicit is recommended when the output +will be compared across commits, because production defaults can evolve. + +Configuration reports preserve the requested raw values. Physics normalizes invalid values at use time; in +particular, `voxelVolumeM3` uses the 1 m³ fallback when it is non-finite, nonpositive, or so small that the +single-precision `R/V` coefficient is unrepresentable. + +Voxel snapping defaults to enabled. It progressively and conservatively projects settled, face-adjacent voxels +within each chunk before automatic sleep. For each proposed member, the allowed pressure correction is +`max(sleepEpsilonPa, voxelSnapPressureRelativeEpsilon * pressureScale)`, where `pressureScale` is the greatest of +that member's current pressure, the proposed aggregate equilibrium pressure, and `vacuumThresholdPa`. +`voxelSnapTemperatureEpsilonK` bounds each temperature correction in kelvins, and +`voxelSnapMoleFractionEpsilon` bounds each dimensionless per-species mole-fraction correction. Species totals and +sensible internal energy are preserved by the projection, subject to the simulation's single-precision storage. +The relative pressure epsilon is dimensionless and normalizes to `[0, 1]`; its default `0.001` means `0.1%`. +The other current defaults are `0.5` Pa, `0.01` K, and `0.001` mole fraction. + +Snap eligibility is intentionally independent of `minimumPressureTransferPa`, whose production default remains +`0.1` Pa/tick. A conservative snap may finish the diminishing flow tail even while ordinary advection would still +request a transfer. Sleep is committed only by the terminal +post-pipeline coordinator after an unchanged verification window; that window is at least the built-in two-tick +thermodynamics cadence even if `sleepThreshold` is lower. The coordinator is a terminal kernel lifecycle step, not a +named solver stage, so it is not listed in `solverPipeline` and still runs when the listed stages are disabled. Set +`voxelSnappingEnabled` to `false` when an isolation experiment must also disable projection and snap-assisted sleep. +Setting `voxelSnappingEnabled` to `false` skips projection and retains legacy pressure-only automatic sleep while the +advection solver is enabled; it does not disable the `sleepChunk` operation. `sleepChunk` remains a raw +caller-directed freeze even when snapping is enabled. Snap aggregates are intra-chunk only; registered cross-chunk +boundaries continue through ordinary boundary flow and wake behavior. + +Automatic sleep retains the chunk's active seed/domain set. Any later successful wake restores that complete set +before admitting a new target component, and capacity is checked atomically. Explicit `sleepChunk` is a manual freeze +and keeps its targeted replacement-domain wake semantics instead. + +The checked-in [`16x16-equilibrium.jsonl`](../examples/headless/16x16-equilibrium.jsonl) experiment injects 100 mol +of oxygen into one corner, samples nonuniform tick intervals through tick 1,000, and verifies the production-default +progression without opening the viewer: + +```sh +dotnet run --project src/Numos.Headless -- --script examples/headless/16x16-equilibrium.jsonl +``` + +The [`16x16-relative-snap-equilibrium.jsonl`](../examples/headless/16x16-relative-snap-equilibrium.jsonl) and +[`16x16-relative-snap-mixed-gas-equilibrium.jsonl`](../examples/headless/16x16-relative-snap-mixed-gas-equilibrium.jsonl) +experiments exercise the production `0.001` relative pressure epsilon at higher pressures. Both retain the normal +`0.5` Pa absolute floor and `0.1` Pa/tick minimum-transfer cutoff rather than converting the relative tolerance into +scenario-specific absolute or advection settings. + +### Gas definitions + +`addGas` appends a definition containing `name` and the physical fields used by `GasProperties`: + +```json +{"protocolVersion":1,"id":"gas","op":"addGas","gas":{"name":"First","molarHeatCapacityAtConstantVolume":1,"boilingPointK":0,"condensationEnabled":false,"molarEnthalpyOfVaporization":0,"liquidId":-1,"diffusionCoefficient":0}} +``` + +Diffusion coefficients normalize to `[0, 1]`; explicit voxel-face updates cap the effective mixing fraction at +`0.5`, so the maximum setting relaxes an isolated pair to equilibrium instead of swapping its inventories. + +Gas IDs are assigned in insertion order and remain stable for the life of a simulation. Use the returned ID in later +`injectGas` requests. `createSimulation.gases` accepts an array of the same definitions and assigns IDs in array order. + +## Observations + +`observe` captures the requested chunk scope under the simulation's state gate, so its tick number and chunk snapshots +describe one coherent state. With no `position` filter, that scope is every chunk. Its `observation` object contains: + +- `tick`, `simulationRate`, and `simulationChunkCount` (the total in the simulation, even when the report is filtered). +- `config`, including the gas registry with assigned IDs. +- `solverPipeline` in execution order, with each stage's name, kind, and enabled state. +- `global`, with topology and awake/sleep counts, total moles, estimated sensible energy, finite pressure/temperature + statistics, totals by gas, and anomaly counts. +- `chunks`, sorted by `(x, y, z)`, with generation/revision, dimensions, awake/sleep metadata, the same per-chunk + summary metrics, and optional `voxels`. +- `issueLocations` and `issueLocationsTruncated`, which provide a bounded, deterministic sample of non-finite or + negative pressure, temperature, and mole locations. + +Gas totals and definitions are ordered by gas ID. Statistics carry the full sample count, finite/non-finite counts, +and nullable finite minimum, maximum, and mean rather than hiding invalid samples. + +Dense voxel details are deliberately opt-in because they can dominate output for normal chunk sizes. Set +`includeVoxels` to `true` when investigating spatial behavior; `onlyGasBearingVoxels` can reduce that output to occupied +cells. An optional chunk `position` limits the report to one chunk. Supplying both `position` and a local `voxel` returns +that exact cell even if `includeVoxels` is false; `voxel` is invalid without `position`. `maxIssueLocations` caps the +coordinate samples attached to invalid-value diagnostics (default `32`, maximum `1024`). Each emitted voxel has a stable +local index and local coordinates, classification, gas-capable/gas-bearing flags, raw pressure and temperature, total +moles, estimated sensible energy, and per-gas moles. + +`pressurePa` is the simulation's cached pressure field. Supported mutations and live-configuration refreshes keep it +coherent, but an inactive voxel modified through unchecked dangerous solver storage can retain a stale cached value. +That raw visibility is intentional for debugging; derive `nRT/V` from the emitted primary state when auditing code +that bypasses the supported mutation paths. + +IEEE-754 non-finite values are encoded as the JSON strings `"NaN"`, `"Infinity"`, and `"-Infinity"`. The same strings +are accepted for floating-point request fields. Finite values remain JSON numbers. This keeps every response valid JSON +while preserving the invalid values most useful during debugging. Consumers should accept either a number or one of +those three strings for floating-point fields. + +## Determinism and comparison + +`tick` calls `AtmosSimulation.Tick()` directly. It does not use the viewer's wall-clock `Update(deltaTime)` accumulator, +so a script requests the same number of solver steps on every replay. Chunks, gases, and voxels are emitted in stable +order, and timing measurements are excluded from canonical observations. + +For useful diffs: + +1. Specify all configuration values that matter to the experiment. +2. Add gases in an explicit order and refer to their returned IDs. +3. Build topology before injecting gas. +4. Insert named `observe` requests before and after the ticks under investigation. +5. Compare parsed JSON rather than relying on whitespace. + +The runner reports state; it does not automatically assert universal mass or energy conservation. Void flow, vacuum +cleanup, and phase changes can intentionally remove mass or energy, so invariants depend on the experiment. Thermal +boundary flow itself is conservative and redistributes sensible energy between registered non-void endpoints. + +## Example + +[`examples/headless/two-voxel-flow.jsonl`](../examples/headless/two-voxel-flow.jsonl) creates one `2 x 1 x 1` chunk, +injects a single gas into its left voxel, observes the initial pressure imbalance, advances one tick, and observes the +result. It uses the reduced-pressure deterministic configuration used by the integration tests. + +Replay it with: + +```powershell +dotnet run --project src/Numos.Headless -- --script examples/headless/two-voxel-flow.jsonl +``` + +The file is intentionally ordinary NDJSON, so it is also a starting point for generated experiments and regression +fixtures. diff --git a/examples/headless/two-voxel-flow.jsonl b/examples/headless/two-voxel-flow.jsonl new file mode 100644 index 0000000..e49ff6c --- /dev/null +++ b/examples/headless/two-voxel-flow.jsonl @@ -0,0 +1,10 @@ +{"protocolVersion":1,"id":"create","op":"createSimulation","name":"Two-voxel flow","dimensions":{"x":2,"y":1,"z":1},"config":{"defaultTemperatureFallbackK":300,"defaultMolarHeatCapacityAtConstantVolume":1,"voxelVolumeM3":8.31446262,"saturationReferencePressurePa":1000,"defaultDiffusionCoefficient":0,"bulkFlowCoefficient":0.25,"bulkFlowDamping":0.5,"lowPressureDeltaThresholdPa":5,"minimumPressureTransferPa":0,"vacuumThresholdPa":0,"sleepThreshold":2147483647,"sleepEpsilonPa":0,"voxelSnapPressureRelativeEpsilon":0.001,"voxelSnappingEnabled":false,"voxelSnapTemperatureEpsilonK":0.01,"voxelSnapMoleFractionEpsilon":0.001,"thermalConductance":0.05,"condensationRateFactor":0.5,"maxPressureTransferFractionPerNeighbor":0.16}} +{"protocolVersion":1,"id":"gas","op":"addGas","gas":{"name":"First","molarHeatCapacityAtConstantVolume":1,"boilingPointK":0,"condensationEnabled":false,"molarEnthalpyOfVaporization":0,"liquidId":-1,"diffusionCoefficient":0}} +{"protocolVersion":1,"id":"chunk","op":"addChunk","position":{"x":0,"y":0,"z":0},"classification":1} +{"protocolVersion":1,"id":"temperature-left","op":"setVoxelTemperature","position":{"x":0,"y":0,"z":0},"voxel":{"x":0,"y":0,"z":0},"temperatureK":300} +{"protocolVersion":1,"id":"temperature-right","op":"setVoxelTemperature","position":{"x":0,"y":0,"z":0},"voxel":{"x":1,"y":0,"z":0},"temperatureK":300} +{"protocolVersion":1,"id":"inject","op":"injectGas","position":{"x":0,"y":0,"z":0},"voxel":{"x":0,"y":0,"z":0},"gasId":0,"moles":2,"temperatureK":300} +{"protocolVersion":1,"id":"before","op":"observe","includeVoxels":true} +{"protocolVersion":1,"id":"tick-1","op":"tick","count":1} +{"protocolVersion":1,"id":"after","op":"observe","includeVoxels":true} +{"protocolVersion":1,"id":"exit","op":"exit"} diff --git a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs index 747520e..80ed011 100644 --- a/src/Numos.API.Dangerous/AtmosDangerousSolver.cs +++ b/src/Numos.API.Dangerous/AtmosDangerousSolver.cs @@ -72,7 +72,7 @@ public void InjectGasToVoxel(int chunkIndex, ushort localVoxelIndex, int gasId, if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) return; - chunk.WakeRoom(roomId); + chunk.WakeVoxel(localVoxelIndex); GasInjectionSolver.InjectDuringTick( chunk, localVoxelIndex, gasId, moles, temperature, _context.TickConfig); } @@ -190,4 +190,4 @@ internal AtmosDangerousGasChannel(GasChannel channel, int voxelCount) /// Live per-voxel mole storage for this gas. public Span Moles => _channel.Moles.AsSpan(0, _voxelCount); -} \ No newline at end of file +} diff --git a/src/Numos.API/AtmosSimulation.GasMixtures.cs b/src/Numos.API/AtmosSimulation.GasMixtures.cs index f53dc8b..bf349cd 100644 --- a/src/Numos.API/AtmosSimulation.GasMixtures.cs +++ b/src/Numos.API/AtmosSimulation.GasMixtures.cs @@ -188,7 +188,18 @@ internal void SetMixtureTemperature(IInternalGasMixture mixture, float temperatu ThrowIfDisposed(); if (mixture is GasMixture owned) { - owned.State.Temperature = temperature; + GasMixtureState state = owned.State; + float previousTemperature = state.Temperature; + state.Temperature = temperature; + try + { + ValidateState(state); + } + catch + { + state.Temperature = previousTemperature; + throw; + } return; } @@ -568,12 +579,12 @@ private void AddGasToOwnedMixture(GasMixtureState state, int gasId, float moles, if (!float.IsFinite(combinedHeatCapacity)) throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); - float currentTemperature = GetEffectiveMixtureTemperature(state.Temperature); - float incomingTemperature = GetEffectiveMixtureTemperature(temperature); - float mixedTemperature = combinedHeatCapacity > 0f - ? currentTemperature + - (incomingTemperature - currentTemperature) * incomingHeatCapacity / combinedHeatCapacity - : temperature; + float mixedTemperature = MixTemperatures( + state.Temperature, + currentHeatCapacity, + temperature, + incomingHeatCapacity, + temperature); state.Moles[gasId] = combinedMoles; state.Temperature = mixedTemperature; @@ -603,12 +614,12 @@ private void MergeStates(GasMixtureState destination, GasMixtureState incoming) if (!float.IsFinite(combinedHeatCapacity)) throw new InvalidOperationException("The merged mixture heat capacity exceeds the supported range."); - float destinationTemperature = GetEffectiveMixtureTemperature(destination.Temperature); - float incomingTemperature = GetEffectiveMixtureTemperature(incoming.Temperature); - float mixedTemperature = combinedHeatCapacity > 0f - ? destinationTemperature + - (incomingTemperature - destinationTemperature) * incomingHeatCapacity / combinedHeatCapacity - : incoming.Temperature; + float mixedTemperature = MixTemperatures( + destination.Temperature, + destinationHeatCapacity, + incoming.Temperature, + incomingHeatCapacity, + incoming.Temperature); foreach (var (gasId, incomingMoles) in incoming.Moles) { @@ -660,13 +671,37 @@ private float GetEffectiveMixtureTemperature(float temperature) : AtmosConfigDefaults.DefaultTemperatureFallback; } + private float MixTemperatures( + float currentStoredTemperature, + float currentHeatCapacity, + float incomingStoredTemperature, + float incomingHeatCapacity, + float emptyTemperature) + { + if (currentHeatCapacity <= 0f) + return emptyTemperature; + + double combinedHeatCapacity = (double)currentHeatCapacity + incomingHeatCapacity; + double mixedTemperature = + ((double)GetEffectiveMixtureTemperature(currentStoredTemperature) * currentHeatCapacity + + (double)GetEffectiveMixtureTemperature(incomingStoredTemperature) * incomingHeatCapacity) / + combinedHeatCapacity; + return (float)mixedTemperature; + } + private float CalculateMixturePressure(GasMixtureState state) { float totalMoles = state.TotalMoles; if (totalMoles <= 0f) return 0f; - return totalMoles / state.Volume * AtmosPhysicalConstants.MolarGasConstant * - GetEffectiveMixtureTemperature(state.Temperature); + double pressure = (double)totalMoles / state.Volume * AtmosPhysicalConstants.MolarGasConstant * + GetEffectiveMixtureTemperature(state.Temperature); + float storedPressure = (float)pressure; + if (!float.IsFinite(storedPressure)) + throw new InvalidOperationException( + "The mixture pressure is not representable under the current simulation configuration."); + + return storedPressure; } private static GasMixtureState RemoveRatioFromState(GasMixtureState source, float ratio) @@ -709,9 +744,9 @@ private void ValidateState(GasMixtureState state) CalculateMixtureHeatCapacity(state); - float pressure = total / state.Volume * AtmosPhysicalConstants.MolarGasConstant * - GetEffectiveMixtureTemperature(state.Temperature); - if (!float.IsFinite(pressure)) + double pressure = (double)total / state.Volume * AtmosPhysicalConstants.MolarGasConstant * + GetEffectiveMixtureTemperature(state.Temperature); + if (!double.IsFinite(pressure) || pressure > float.MaxValue) throw new InvalidOperationException("The mixture's pressure exceeds the supported range."); } diff --git a/src/Numos.API/AtmosSimulation.cs b/src/Numos.API/AtmosSimulation.cs index 718c9b1..085364f 100644 --- a/src/Numos.API/AtmosSimulation.cs +++ b/src/Numos.API/AtmosSimulation.cs @@ -10,7 +10,7 @@ namespace Numos.API; /// Provides the supported, engine-agnostic facade for running a voxel-based atmospheric simulation. /// /// -/// The simulation owns every chunk created through . Call + /// The simulation owns every chunk created through . Call /// when the simulation is no longer needed to release those chunks and its /// worker-local buffers. Unless otherwise noted, members that access kernel state throw /// after disposal. A solver callback may use its context and edit the @@ -211,8 +211,11 @@ public void Dispose() [PublicAPI] public void Update(float elapsedSeconds) { - ThrowIfDisposed(); - _kernel.Update(elapsedSeconds); + lock (_mixtureGate) + { + ThrowIfDisposed(); + _kernel.Update(elapsedSeconds); + } } /// @@ -229,10 +232,13 @@ public void Update(float elapsedSeconds) public void Update(float elapsedSeconds, AtmosConfig config) { ArgumentNullException.ThrowIfNull(config); - ThrowIfDisposed(); - _kernel.EnsureCanExecuteTick(); - SetAtmosConfig(config); - Update(elapsedSeconds); + lock (_mixtureGate) + { + ThrowIfDisposed(); + _kernel.EnsureCanExecuteTick(); + SetAtmosConfig(config); + Update(elapsedSeconds); + } } /// @@ -284,6 +290,37 @@ public AtmosChunkHandle CreateAndRegisterChunk( return new AtmosChunkHandle(position); } + /// + /// Creates, classifies, and registers a chunk using this simulation's fixed chunk dimensions. + /// + /// The chunk's position in the chunk grid. + /// The maximum number of room IDs that may be active simultaneously. + /// + /// The classification assigned to every voxel before the chunk becomes visible to adjacent chunks. + /// + /// A lightweight handle that identifies the new chunk to this facade. + /// + /// Applying the classification before registration makes chunk creation atomic with respect to boundary + /// connectivity. In particular, a solid chunk is never transiently exposed as a passable neighbor. + /// + /// is zero or negative. + /// + /// A chunk is already registered at , or this is called from a solver callback. + /// + /// The simulation has been disposed. + [PublicAPI] + public AtmosChunkHandle CreateAndRegisterChunk( + Int3 position, + int maxActiveRooms, + VoxelClassification initialClassification) + { + ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxActiveRooms); + _kernel.CreateAndRegisterChunk(position, _chunkWidth, _chunkHeight, _chunkDepth, maxActiveRooms, + initialClassification.RoomId); + return new AtmosChunkHandle(position); + } + /// /// Removes a chunk from the simulation and releases the kernel resources it owns. /// @@ -366,7 +403,10 @@ private static AtmosChunkHandle[] CreateSortedHandles(Int3[] positions) /// /// A snapshot containing copied pressure, temperature, gas-channel, and voxel-classification arrays. /// - /// Mutating the returned arrays does not mutate the simulation. + /// + /// Mutating the returned arrays does not mutate the simulation. Pressure is the chunk's cached field from + /// its latest supported refresh; an in-place live-configuration edit is reflected after the next tick. + /// /// No chunk is registered at the handle's position. /// The simulation has been disposed. [PublicAPI] @@ -379,6 +419,10 @@ public AtmosChunkSnapshot GetChunkSnapshot(AtmosChunkHandle chunk) /// /// Returns detached values for one voxel without copying the chunk's full field arrays. /// + /// + /// Pressure is sampled from the chunk cache. Unlike , this method does not + /// rederive pressure from an uncommitted in-place configuration edit before the next tick. + /// /// The chunk containing the voxel. /// The voxel's flat local index. /// Scalar values plus one moles value per active gas channel. @@ -554,14 +598,17 @@ public void SetVoxelClassification(AtmosChunkHandle chunk, int x, int y, int z, /// The voxel's zero-based index in the chunk's flattened storage. /// The raw temperature value to store, in kelvins. /// - /// The supplied value is stored without validation or eager normalization. Snapshots expose that raw value - /// until a later operation overwrites it. The pressure cache is refreshed immediately; pressure and + /// Subject to a representable derived pressure, the supplied value is stored without eager normalization. + /// Snapshots expose that raw value until a later operation overwrites it. The pressure cache is refreshed immediately; pressure and /// sensible-energy calculations treat a gas-bearing voxel's non-finite or nonpositive stored value as /// for that calculation. /// /// is outside the chunk. /// No chunk is registered at the handle's position. /// The simulation has been disposed. + /// + /// The requested temperature would make the voxel's derived pressure unrepresentable. + /// [PublicAPI] public void SetVoxelTemperature(AtmosChunkHandle chunk, ushort localVoxelIndex, float temperature) { @@ -578,14 +625,17 @@ public void SetVoxelTemperature(AtmosChunkHandle chunk, ushort localVoxelIndex, /// The zero-based local z-coordinate. /// The raw temperature value to store, in kelvins. /// - /// The supplied value is stored without validation or eager normalization. Snapshots expose that raw value - /// until a later operation overwrites it. The pressure cache is refreshed immediately; pressure and + /// Subject to a representable derived pressure, the supplied value is stored without eager normalization. + /// Snapshots expose that raw value until a later operation overwrites it. The pressure cache is refreshed immediately; pressure and /// sensible-energy calculations treat a gas-bearing voxel's non-finite or nonpositive stored value as /// for that calculation. /// /// A local coordinate is outside the chunk. /// No chunk is registered at the handle's position. /// The simulation has been disposed. + /// + /// The requested temperature would make the voxel's derived pressure unrepresentable. + /// [PublicAPI] public void SetVoxelTemperature(AtmosChunkHandle chunk, int x, int y, int z, float temperature) { @@ -618,6 +668,9 @@ public void SetVoxelTemperature(AtmosChunkHandle chunk, int x, int y, int z, flo /// is negative, is not positive and finite, or /// is negative or non-finite. /// + /// + /// The resulting gas amount, total moles, heat capacity, temperature, or pressure is not representable. + /// /// No chunk is registered at the handle's position. /// The simulation has been disposed. [PublicAPI] @@ -672,8 +725,12 @@ public void AddGasToVoxel(AtmosChunkHandle chunk, int x, int y, int z, int gasId /// A handle identifying the target chunk. /// The classification ID of the room to activate. /// - /// Waking an already active room resets its sleep timer. Solid and void classification IDs are ignored. + /// Waking an already active room resets its sleep timer. A wake from automatic sleep first restores the + /// complete retained active domain, then admits a new room only when it fits the chunk's capacity. A chunk + /// frozen explicitly with retains targeted replacement-domain behavior. Solid and + /// void classification IDs are ignored. /// + /// The chunk's active-room capacity would be exceeded. /// No chunk is registered at the handle's position. /// The simulation has been disposed. [PublicAPI] @@ -709,12 +766,15 @@ public void SleepChunk(AtmosChunkHandle chunk) [PublicAPI] public void Tick() { - ThrowIfDisposed(); - _kernel.Tick(); + lock (_mixtureGate) + { + ThrowIfDisposed(); + _kernel.Tick(); + } } private void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(_disposed, this); } -} \ No newline at end of file +} diff --git a/src/Numos.API/AtmosSolverPipeline.cs b/src/Numos.API/AtmosSolverPipeline.cs index fbe9576..58d1d68 100644 --- a/src/Numos.API/AtmosSolverPipeline.cs +++ b/src/Numos.API/AtmosSolverPipeline.cs @@ -9,7 +9,10 @@ namespace Numos.API; /// /// The enabled stage list is snapshotted before each tick. Registration, removal, and enablement changes made /// by a running solver therefore take effect on the next tick. Registered custom solver instances remain -/// caller-owned; this pipeline does not dispose them. +/// caller-owned; this pipeline does not dispose them. Adding or re-enabling executable solver behavior +/// invalidates solver-derived equilibrium at the next tick boundary: automatically slept chunks resume their +/// retained active domains, while chunks frozen explicitly with remain +/// asleep. Removing or disabling behavior does not wake a chunk. /// public sealed class AtmosSolverPipeline { @@ -102,6 +105,11 @@ public bool Unregister(string name) } /// Enables or disables a stage without changing its position. + /// when the named stage exists; otherwise, . + /// + /// Re-enabling a stage schedules automatic-sleep invalidation for the next tick. Disabling a stage or + /// requesting its existing state does not wake chunks. + /// [PublicAPI] public bool SetEnabled(string name, bool enabled) { @@ -109,9 +117,13 @@ public bool SetEnabled(string name, bool enabled) } /// Restores the built-in pipeline and removes every custom solver. + /// + /// Restoring a missing or disabled built-in schedules automatic-sleep invalidation for the next tick. + /// A reset that only removes custom behavior does not wake chunks. + /// [PublicAPI] public void ResetToDefaults() { _simulation.Kernel.ResetSolverPipeline(); } -} \ No newline at end of file +} diff --git a/src/Numos.API/IGasMixture.cs b/src/Numos.API/IGasMixture.cs index 4f22289..99f6e15 100644 --- a/src/Numos.API/IGasMixture.cs +++ b/src/Numos.API/IGasMixture.cs @@ -22,12 +22,15 @@ public interface IGasMixture /// The stored temperature, in kelvins (K). /// - /// The setter stores the raw value. Pressure and energy calculations use the owner's configured fallback - /// when the stored value is non-finite or nonpositive. + /// Subject to a representable derived pressure, the setter stores the raw value. Pressure and energy + /// calculations use the owner's configured fallback when the stored value is non-finite or nonpositive. /// float Temperature { get; set; } /// The ideal-gas pressure, in pascals (Pa). + /// + /// The mixture's pressure is not representable under the owner's current live configuration. + /// float Pressure { get; } /// The total amount of gas, in moles (mol). @@ -112,4 +115,4 @@ public float GetMoles(int gasId) internal interface IInternalGasMixture : IGasMixture { -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AggregateVoxels.cs b/src/Numos.CoreSim/AggregateVoxels.cs new file mode 100644 index 0000000..46a1cf9 --- /dev/null +++ b/src/Numos.CoreSim/AggregateVoxels.cs @@ -0,0 +1,733 @@ +using System.Buffers; +using System.Diagnostics; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.CoreSim.Solvers; + +namespace Numos.CoreSim; + +/// +/// Tracks conservative, progressively merged voxel aggregates for one chunk. +/// +/// +/// Aggregates are topology only: atmospheric state remains materialized in the chunk's voxel arrays. +/// A merge is accepted from the post-pipeline state only when every member can be projected to the +/// combined equilibrium within the configured pressure, temperature, and composition limits. Each root +/// participates in at most one merge per tick, preventing overlapping neighbor projections and traversal- +/// order-dependent loss of mass or energy. +/// +internal sealed class AggregateVoxels +{ + private const ulong FingerprintOffset = 14695981039346656037UL; + private const ulong FingerprintPrime = 1099511628211UL; + + private bool[] _included = []; + private int[] _gasOrder = []; + private int[] _mergeBuffer = []; + private int[] _next = []; + private int[] _parent = []; + private bool[] _participated = []; + private double[] _speciesTotals = []; + private double[] _voxelEffectiveTemperature = []; + private double[] _voxelHeatCapacity = []; + private double[] _voxelPressure = []; + private double[] _voxelTotalMoles = []; + + private StateFingerprint _previousFingerprint; + private bool _hasPreviousFingerprint; + private int _includedCount; + private bool _isInitialized; + + /// + /// Invalidates every progressive aggregate and the stable-state verification window. + /// + internal void Reset() + { + _isInitialized = false; + _hasPreviousFingerprint = false; + } + + /// + /// Validates existing aggregates, performs one deterministic merge round, and advances automatic sleep. + /// + internal void FinalizeTick(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + HashSet processedRootPairs) + { + Debug.Assert(chunk.IsAwake); + RentWorkspace(chunk.VoxelCount); + try + { + EnsureInitialized(chunk); + PrepareGasOrder(chunk); + + StateFingerprint observedFingerprint = CalculateFingerprint(chunk); + bool materializedStateChanged = !_hasPreviousFingerprint || + observedFingerprint != _previousFingerprint; + bool aggregateChanged = ValidateExistingAggregates( + chunk, config, materializedStateChanged, out bool allStatesValid); + aggregateChanged |= MergeEligibleNeighbors(chunk, config, processedRootPairs); + bool fullyAggregated = allStatesValid && AreAllPassableEdgesInternal(chunk); + + _previousFingerprint = CalculateFingerprint(chunk); + _hasPreviousFingerprint = true; + + if (aggregateChanged || materializedStateChanged || !fullyAggregated) + { + chunk.SleepTimer = 0; + return; + } + + if (chunk.SleepTimer < int.MaxValue) + chunk.SleepTimer++; + // Thermodynamics runs every other tick. Even a caller-configured zero threshold must observe at least + // one complete lower-frequency pass before committing sleep, or an unchanged intervening tick could + // freeze an actionable thermal or phase-change gradient. + int verificationThreshold = Math.Max( + config.SleepThreshold, AtmosSolverConstants.ThermodynamicsTickInterval); + if (chunk.SleepTimer > verificationThreshold) + chunk.SleepAutomatically(); + } + finally + { + ReturnWorkspace(); + } + } + + private void RentWorkspace(int voxelCount) + { + _mergeBuffer = ArrayPool.Shared.Rent(voxelCount); + _voxelEffectiveTemperature = ArrayPool.Shared.Rent(voxelCount); + _voxelHeatCapacity = ArrayPool.Shared.Rent(voxelCount); + _voxelPressure = ArrayPool.Shared.Rent(voxelCount); + _voxelTotalMoles = ArrayPool.Shared.Rent(voxelCount); + } + + private void ReturnWorkspace() + { + ArrayPool.Shared.Return(_mergeBuffer); + ArrayPool.Shared.Return(_voxelEffectiveTemperature); + ArrayPool.Shared.Return(_voxelHeatCapacity); + ArrayPool.Shared.Return(_voxelPressure); + ArrayPool.Shared.Return(_voxelTotalMoles); + _mergeBuffer = []; + _voxelEffectiveTemperature = []; + _voxelHeatCapacity = []; + _voxelPressure = []; + _voxelTotalMoles = []; + } + + /// + /// Returns whether two face-neighbor voxels already share one established aggregate. + /// + internal bool AreAggregatedTogether(ushort firstVoxel, ushort secondVoxel) + { + if (!_isInitialized || + firstVoxel >= _parent.Length || secondVoxel >= _parent.Length) + return false; + + int firstRoot = _parent[firstVoxel]; + return firstRoot >= 0 && firstRoot == _parent[secondVoxel]; + } + + /// + /// Returns whether the live materialized state still matches the last finalized aggregate state. + /// + /// + /// Solvers may skip internal aggregate edges only while this exact fingerprint is current. A public + /// mutation, boundary transfer, or earlier custom stage otherwise has to be observed normally before + /// the terminal coordinator revalidates or splits the aggregate. + /// + internal bool IsMaterializedStateCurrent(AtmosChunk chunk) + { + if (!_isInitialized || !_hasPreviousFingerprint) + return false; + + PrepareGasOrder(chunk); + return CalculateFingerprint(chunk) == _previousFingerprint; + } + + private void EnsureInitialized(AtmosChunk chunk) + { + int voxelCount = chunk.VoxelCount; + if (_parent.Length != voxelCount) + { + _included = new bool[voxelCount]; + _next = new int[voxelCount]; + _parent = new int[voxelCount]; + _participated = new bool[voxelCount]; + _isInitialized = false; + } + + if (_isInitialized) + return; + + Array.Clear(_included); + Array.Fill(_next, -1); + Array.Fill(_parent, -1); + + _includedCount = chunk.ActiveAirCount; + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + int voxelIndex = chunk.ActiveAirIndices[activeIndex]; + _included[voxelIndex] = true; + _parent[voxelIndex] = voxelIndex; + } + + _hasPreviousFingerprint = false; + _isInitialized = true; + } + + private void PrepareGasOrder(AtmosChunk chunk) + { + int gasCount = chunk.ActiveGasCount; + if (_gasOrder.Length < gasCount) + Array.Resize(ref _gasOrder, gasCount); + if (_speciesTotals.Length < gasCount) + Array.Resize(ref _speciesTotals, gasCount); + + for (var gas = 0; gas < gasCount; gas++) + _gasOrder[gas] = gas; + + // Gas channels are created in mutation order. Sorting their indices by stable gas ID keeps aggregate + // reduction and writeback deterministic when callers create the same mixture in a different order. + for (var index = 1; index < gasCount; index++) + { + int channelIndex = _gasOrder[index]; + int gasId = chunk.ActiveGases[channelIndex].GasId; + int insertionIndex = index; + while (insertionIndex > 0 && + chunk.ActiveGases[_gasOrder[insertionIndex - 1]].GasId > gasId) + { + _gasOrder[insertionIndex] = _gasOrder[insertionIndex - 1]; + insertionIndex--; + } + + _gasOrder[insertionIndex] = channelIndex; + } + } + + private bool ValidateExistingAggregates(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + bool materializedStateChanged, out bool allStatesValid) + { + var aggregateChanged = false; + allStatesValid = true; + + for (var root = 0; root < chunk.VoxelCount; root++) + { + if (_parent[root] != root) + continue; + + if (!TryBuildEquilibrium(chunk, config, root, -1, out EquilibriumState equilibrium)) + { + allStatesValid = false; + if (_next[root] >= 0) + { + Split(root); + aggregateChanged = true; + } + + continue; + } + + if (_next[root] < 0) + continue; + + if (!IsWithinCorrectionLimits(chunk, config, root, -1, equilibrium)) + { + Split(root); + aggregateChanged = true; + continue; + } + + if (materializedStateChanged) + aggregateChanged |= Materialize(chunk, config, root, equilibrium); + } + + return aggregateChanged; + } + + private bool MergeEligibleNeighbors(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + HashSet processedRootPairs) + { + Array.Clear(_participated); + processedRootPairs.Clear(); + var aggregateChanged = false; + + for (var voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + { + if (!_included[voxelIndex]) + continue; + + GetCoordinates(chunk, voxelIndex, out int x, out int y, out int z); + if (x + 1 < chunk.Width) + TryMergeEdge(chunk, config, processedRootPairs, + voxelIndex, voxelIndex + 1, ref aggregateChanged); + if (y + 1 < chunk.Height) + TryMergeEdge(chunk, config, processedRootPairs, + voxelIndex, voxelIndex + chunk.Width, ref aggregateChanged); + if (z + 1 < chunk.Depth) + TryMergeEdge(chunk, config, processedRootPairs, voxelIndex, + voxelIndex + chunk.Width * chunk.Height, ref aggregateChanged); + } + + return aggregateChanged; + } + + private void TryMergeEdge(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + HashSet processedRootPairs, int firstVoxel, int secondVoxel, + ref bool aggregateChanged) + { + if (!_included[secondVoxel]) + return; + + int firstRoot = _parent[firstVoxel]; + int secondRoot = _parent[secondVoxel]; + Debug.Assert(firstRoot >= 0 && secondRoot >= 0); + if (firstRoot == secondRoot || _participated[firstRoot] || _participated[secondRoot]) + return; + if (_next[firstRoot] >= 0 || _next[secondRoot] >= 0) + { + int lowerRoot = Math.Min(firstRoot, secondRoot); + int upperRoot = Math.Max(firstRoot, secondRoot); + ulong pairKey = ((ulong)(uint)lowerRoot << 32) | (uint)upperRoot; + if (!processedRootPairs.Add(pairKey)) + return; + } + if (!TryBuildEquilibrium(chunk, config, firstRoot, secondRoot, + out EquilibriumState equilibrium) || + !IsWithinCorrectionLimits(chunk, config, firstRoot, secondRoot, equilibrium)) + return; + + int mergedRoot = Merge(firstRoot, secondRoot); + _participated[mergedRoot] = true; + Materialize(chunk, config, mergedRoot, equilibrium); + aggregateChanged = true; + } + + private bool TryBuildEquilibrium(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + int firstRoot, int secondRoot, out EquilibriumState equilibrium) + { + Array.Clear(_speciesTotals, 0, chunk.ActiveGasCount); + int memberCount = CopyMembersInCanonicalOrder(firstRoot, secondRoot); + var totalMoles = 0d; + var totalHeatCapacity = 0d; + var totalEnergy = 0d; + + for (var memberIndex = 0; memberIndex < memberCount; memberIndex++) + { + int voxelIndex = _mergeBuffer[memberIndex]; + float storedTemperature = chunk.Temperature[voxelIndex]; + float effectiveTemperature = config.GetEffectiveTemperature(storedTemperature); + if (!float.IsFinite(effectiveTemperature) || effectiveTemperature <= 0f) + { + equilibrium = default; + return false; + } + + double voxelMoles = 0d; + double voxelHeatCapacity = 0d; + for (var orderedGas = 0; orderedGas < chunk.ActiveGasCount; orderedGas++) + { + int channelIndex = _gasOrder[orderedGas]; + float moles = chunk.ActiveGases[channelIndex].Moles[voxelIndex]; + if (!float.IsFinite(moles) || moles < 0f) + { + equilibrium = default; + return false; + } + + _speciesTotals[channelIndex] += moles; + voxelMoles += moles; + voxelHeatCapacity += (double)moles * + config.GetMolarHeatCapacityAtConstantVolume( + chunk.ActiveGases[channelIndex].GasId); + } + + double voxelPressure = voxelMoles * effectiveTemperature * config.PressurePerMoleKelvin; + if (!double.IsFinite(voxelHeatCapacity) || !double.IsFinite(voxelPressure)) + { + equilibrium = default; + return false; + } + + _voxelEffectiveTemperature[voxelIndex] = effectiveTemperature; + _voxelHeatCapacity[voxelIndex] = voxelHeatCapacity; + _voxelPressure[voxelIndex] = voxelPressure; + _voxelTotalMoles[voxelIndex] = voxelMoles; + totalMoles += voxelMoles; + totalHeatCapacity += voxelHeatCapacity; + totalEnergy += voxelHeatCapacity * effectiveTemperature; + } + + if (memberCount <= 0 || !double.IsFinite(totalMoles) || !double.IsFinite(totalHeatCapacity) || + !double.IsFinite(totalEnergy)) + { + equilibrium = default; + return false; + } + + if (totalHeatCapacity <= 0d) + { + equilibrium = new EquilibriumState(memberCount, totalMoles, 0d, 0d, 0d, 0d); + return totalMoles == 0d; + } + + double temperature = totalEnergy / totalHeatCapacity; + double equilibriumPressure = totalMoles / memberCount * temperature * config.PressurePerMoleKelvin; + if (!double.IsFinite(temperature) || temperature <= 0d || + !double.IsFinite(equilibriumPressure) || equilibriumPressure < 0d || + equilibriumPressure > float.MaxValue) + { + equilibrium = default; + return false; + } + + equilibrium = new EquilibriumState(memberCount, totalMoles, totalHeatCapacity, + totalEnergy, temperature, equilibriumPressure); + return true; + } + + private bool IsWithinCorrectionLimits(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + int firstRoot, int secondRoot, EquilibriumState equilibrium) + { + for (var rootSlot = 0; rootSlot < 2; rootSlot++) + { + int root = rootSlot == 0 ? firstRoot : secondRoot; + if (root < 0) + continue; + + for (int voxelIndex = root; voxelIndex >= 0; voxelIndex = _next[voxelIndex]) + { + double pressureCorrection = Math.Abs( + _voxelPressure[voxelIndex] - equilibrium.Pressure); + double pressureScale = Math.Max( + Math.Max(_voxelPressure[voxelIndex], equilibrium.Pressure), + config.VacuumThreshold); + double pressureCorrectionLimit = Math.Max( + config.SleepEpsilon, + config.VoxelSnapPressureRelativeEpsilon * pressureScale); + if (!double.IsFinite(pressureCorrection) || + pressureCorrection > pressureCorrectionLimit) + return false; + + if (equilibrium.TotalHeatCapacity <= 0d) + continue; + + double voxelTotalMoles = _voxelTotalMoles[voxelIndex]; + // Vacuum has no physically defined temperature or composition. Pressure bounds how much gas + // may be projected into it; temperature and mole-fraction limits apply once gas is present. + if (voxelTotalMoles <= 0d) + continue; + if (Math.Abs(_voxelEffectiveTemperature[voxelIndex] - equilibrium.Temperature) > + config.VoxelSnapTemperatureEpsilon) + return false; + + for (var orderedGas = 0; orderedGas < chunk.ActiveGasCount; orderedGas++) + { + int channelIndex = _gasOrder[orderedGas]; + double targetFraction = _speciesTotals[channelIndex] / equilibrium.TotalMoles; + double currentFraction = voxelTotalMoles > 0d + ? chunk.ActiveGases[channelIndex].Moles[voxelIndex] / voxelTotalMoles + : 0d; + if (Math.Abs(currentFraction - targetFraction) > + config.VoxelSnapMoleFractionEpsilon) + return false; + } + } + } + + return CanMaterializeFinite(chunk, config, firstRoot, secondRoot, equilibrium); + } + + private bool CanMaterializeFinite(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + int firstRoot, int secondRoot, EquilibriumState equilibrium) + { + int memberCount = CopyMembersInCanonicalOrder(firstRoot, secondRoot); + Debug.Assert(memberCount == equilibrium.MemberCount); + for (var memberIndex = 0; memberIndex < memberCount; memberIndex++) + { + int voxelIndex = _mergeBuffer[memberIndex]; + _voxelHeatCapacity[voxelIndex] = 0d; + _voxelTotalMoles[voxelIndex] = 0d; + } + + for (var orderedGas = 0; orderedGas < chunk.ActiveGasCount; orderedGas++) + { + int channelIndex = _gasOrder[orderedGas]; + double molarHeatCapacity = config.GetMolarHeatCapacityAtConstantVolume( + chunk.ActiveGases[channelIndex].GasId); + double remainingMoles = _speciesTotals[channelIndex]; + int remainingMembers = memberCount; + for (var memberIndex = 0; memberIndex < memberCount; memberIndex++) + { + int voxelIndex = _mergeBuffer[memberIndex]; + float targetMoles = (float)Math.Max(0d, remainingMoles / remainingMembers); + if (!float.IsFinite(targetMoles)) + return false; + + double projectedHeatCapacity = _voxelHeatCapacity[voxelIndex] + + targetMoles * molarHeatCapacity; + if (!double.IsFinite(projectedHeatCapacity) || + projectedHeatCapacity > float.MaxValue) + return false; + + _voxelHeatCapacity[voxelIndex] = projectedHeatCapacity; + _voxelTotalMoles[voxelIndex] += targetMoles; + remainingMoles -= targetMoles; + remainingMembers--; + } + } + + double remainingEnergy = equilibrium.TotalEnergy; + double remainingHeatCapacity = 0d; + for (var memberIndex = 0; memberIndex < memberCount; memberIndex++) + remainingHeatCapacity += _voxelHeatCapacity[_mergeBuffer[memberIndex]]; + + for (var memberIndex = 0; memberIndex < memberCount; memberIndex++) + { + int voxelIndex = _mergeBuffer[memberIndex]; + double voxelHeatCapacity = _voxelHeatCapacity[voxelIndex]; + float projectedMoles = (float)_voxelTotalMoles[voxelIndex]; + if (!float.IsFinite(projectedMoles)) + return false; + if (voxelHeatCapacity <= 0d) + { + if (projectedMoles != 0f) + return false; + continue; + } + + double targetTemperature = remainingEnergy / remainingHeatCapacity; + if (!double.IsFinite(targetTemperature) || targetTemperature <= 0d || + targetTemperature > float.MaxValue) + return false; + + float storedTemperature = (float)targetTemperature; + if (!float.IsFinite(storedTemperature) || storedTemperature <= 0f) + return false; + float projectedPressure = AtmosSolverMath.CalculatePressure( + config, projectedMoles, storedTemperature); + if (!float.IsFinite(projectedPressure)) + return false; + remainingEnergy -= (double)storedTemperature * voxelHeatCapacity; + remainingHeatCapacity -= voxelHeatCapacity; + } + + return true; + } + + private int CopyMembersInCanonicalOrder(int firstRoot, int secondRoot) + { + int firstMember = firstRoot; + int secondMember = secondRoot; + var memberCount = 0; + while (firstMember >= 0 || secondMember >= 0) + { + if (secondMember < 0 || firstMember >= 0 && firstMember < secondMember) + { + _mergeBuffer[memberCount++] = firstMember; + firstMember = _next[firstMember]; + } + else + { + _mergeBuffer[memberCount++] = secondMember; + secondMember = _next[secondMember]; + } + } + + return memberCount; + } + + private bool Materialize(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + int root, EquilibriumState equilibrium) + { + var materializedStateChanged = false; + + for (var orderedGas = 0; orderedGas < chunk.ActiveGasCount; orderedGas++) + { + int channelIndex = _gasOrder[orderedGas]; + double remainingMoles = _speciesTotals[channelIndex]; + int remainingMembers = equilibrium.MemberCount; + for (int voxelIndex = root; voxelIndex >= 0; voxelIndex = _next[voxelIndex]) + { + float targetMoles = (float)Math.Max(0d, remainingMoles / remainingMembers); + ref float storedMoles = ref chunk.ActiveGases[channelIndex].Moles[voxelIndex]; + materializedStateChanged |= SetIfDifferent(ref storedMoles, targetMoles); + remainingMoles -= targetMoles; + remainingMembers--; + } + } + + double actualTotalHeatCapacity = 0d; + for (int voxelIndex = root; voxelIndex >= 0; voxelIndex = _next[voxelIndex]) + { + double voxelMoles = 0d; + double voxelHeatCapacity = 0d; + for (var orderedGas = 0; orderedGas < chunk.ActiveGasCount; orderedGas++) + { + int channelIndex = _gasOrder[orderedGas]; + float moles = chunk.ActiveGases[channelIndex].Moles[voxelIndex]; + voxelMoles += moles; + voxelHeatCapacity += (double)moles * + config.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[channelIndex].GasId); + } + + _voxelTotalMoles[voxelIndex] = voxelMoles; + _voxelHeatCapacity[voxelIndex] = voxelHeatCapacity; + actualTotalHeatCapacity += voxelHeatCapacity; + } + + double remainingEnergy = equilibrium.TotalEnergy; + double remainingHeatCapacity = actualTotalHeatCapacity; + for (int voxelIndex = root; voxelIndex >= 0; voxelIndex = _next[voxelIndex]) + { + double voxelHeatCapacity = _voxelHeatCapacity[voxelIndex]; + if (voxelHeatCapacity > 0d) + { + double target = remainingHeatCapacity > 0d + ? remainingEnergy / remainingHeatCapacity + : equilibrium.Temperature; + float targetTemperature = double.IsFinite(target) && target > 0d && target <= float.MaxValue + ? (float)target + : (float)equilibrium.Temperature; + float storedTemperature = chunk.Temperature[voxelIndex]; + if (BitConverter.SingleToInt32Bits(storedTemperature) != + BitConverter.SingleToInt32Bits(targetTemperature)) + { + chunk.Temperature[voxelIndex] = targetTemperature; + materializedStateChanged = true; + } + remainingEnergy -= (double)targetTemperature * voxelHeatCapacity; + remainingHeatCapacity -= voxelHeatCapacity; + } + + chunk.TotalHeatCapacity[voxelIndex] = (float)voxelHeatCapacity; + chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressure( + config, (float)_voxelTotalMoles[voxelIndex], chunk.Temperature[voxelIndex]); + } + + return materializedStateChanged; + } + + private int Merge(int firstRoot, int secondRoot) + { + int memberCount = CopyMembersInCanonicalOrder(firstRoot, secondRoot); + int mergedRoot = _mergeBuffer[0]; + for (var index = 0; index < memberCount; index++) + { + int member = _mergeBuffer[index]; + _parent[member] = mergedRoot; + _next[member] = index + 1 < memberCount ? _mergeBuffer[index + 1] : -1; + } + + return mergedRoot; + } + + private void Split(int root) + { + var memberCount = 0; + for (int member = root; member >= 0; member = _next[member]) + _mergeBuffer[memberCount++] = member; + + for (var index = 0; index < memberCount; index++) + { + int member = _mergeBuffer[index]; + _parent[member] = member; + _next[member] = -1; + } + } + + private bool AreAllPassableEdgesInternal(AtmosChunk chunk) + { + for (var voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + { + if (!_included[voxelIndex]) + continue; + + int root = _parent[voxelIndex]; + GetCoordinates(chunk, voxelIndex, out int x, out int y, out int z); + if (x + 1 < chunk.Width && !IsInternalEdge(root, voxelIndex + 1)) + return false; + if (y + 1 < chunk.Height && !IsInternalEdge(root, voxelIndex + chunk.Width)) + return false; + if (z + 1 < chunk.Depth && + !IsInternalEdge(root, voxelIndex + chunk.Width * chunk.Height)) + return false; + } + + return true; + } + + private bool IsInternalEdge(int root, int neighborIndex) + { + return !_included[neighborIndex] || _parent[neighborIndex] == root; + } + + private StateFingerprint CalculateFingerprint(AtmosChunk chunk) + { + ulong first = FingerprintOffset; + ulong second = 0x9E3779B97F4A7C15UL; + Mix(ref first, ref second, _includedCount); + Mix(ref first, ref second, chunk.ActiveGasCount); + + for (var orderedGas = 0; orderedGas < chunk.ActiveGasCount; orderedGas++) + { + int channelIndex = _gasOrder[orderedGas]; + Mix(ref first, ref second, chunk.ActiveGases[channelIndex].GasId); + } + + for (var voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + { + if (!_included[voxelIndex]) + continue; + + Mix(ref first, ref second, voxelIndex); + Mix(ref first, ref second, BitConverter.SingleToInt32Bits(chunk.Temperature[voxelIndex])); + for (var orderedGas = 0; orderedGas < chunk.ActiveGasCount; orderedGas++) + { + int channelIndex = _gasOrder[orderedGas]; + Mix(ref first, ref second, + BitConverter.SingleToInt32Bits(chunk.ActiveGases[channelIndex].Moles[voxelIndex])); + } + } + + return new StateFingerprint(first, second); + } + + private static void Mix(ref ulong first, ref ulong second, int value) + { + unchecked + { + ulong data = (uint)value; + first = (first ^ data) * FingerprintPrime; + second ^= data + 0x9E3779B97F4A7C15UL + (second << 6) + (second >> 2); + } + } + + private static bool SetIfDifferent(ref float storage, float value) + { + if (BitConverter.SingleToInt32Bits(storage) == BitConverter.SingleToInt32Bits(value)) + return false; + + storage = value; + return true; + } + + private static void GetCoordinates(AtmosChunk chunk, int voxelIndex, + out int x, out int y, out int z) + { + x = voxelIndex % chunk.Width; + int yz = voxelIndex / chunk.Width; + y = yz % chunk.Height; + z = yz / chunk.Height; + } + + private readonly record struct EquilibriumState( + int MemberCount, + double TotalMoles, + double TotalHeatCapacity, + double TotalEnergy, + double Temperature, + double Pressure); + + private readonly record struct StateFingerprint(ulong First, ulong Second); +} diff --git a/src/Numos.CoreSim/AtmosChunk.cs b/src/Numos.CoreSim/AtmosChunk.cs index b273f7e..83bacea 100644 --- a/src/Numos.CoreSim/AtmosChunk.cs +++ b/src/Numos.CoreSim/AtmosChunk.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; @@ -26,11 +27,12 @@ internal class AtmosChunk public int ActiveAirCount; /// - /// Flat voxel indices belonging to active rooms in this chunk. + /// Flat voxel indices in passable components reached from active-room seeds. /// /// - /// Only the first entries are valid. Rebuild this list with - /// after changing or the active rooms. + /// Only the first entries are valid. Room IDs seed activation but are + /// not flow barriers: every face-connected non-solid/non-void voxel is included. Rebuild this list + /// with after changing or active rooms. /// public ushort[] ActiveAirIndices; @@ -60,6 +62,7 @@ internal class AtmosChunk private long _generation; private long _revision; + private bool _wasAutomaticallySlept; /// /// Room IDs currently being processed in this chunk. @@ -112,6 +115,11 @@ internal class AtmosChunk /// public int SleepTimer; + /// + /// Progressive intra-chunk aggregate topology used by snap-assisted automatic sleep. + /// + internal AggregateVoxels VoxelAggregates { get; } = new(); + /// /// Temperature for each voxel, in kelvins (K), indexed by flat voxel index or local coordinate. /// @@ -120,7 +128,11 @@ internal class AtmosChunk /// /// Cached pressure for each voxel, in pascals (Pa), indexed by flat voxel index or local coordinate. /// - /// These values are recomputed by the simulation each tick. + /// + /// Active entries are refreshed by the pressure solver. Entries outside + /// retain their last refreshed value and are not authoritative after unchecked + /// dangerous-context writes. Supported public mutations and configuration changes refresh affected entries. + /// public FlatArray TotalPressure; /// @@ -231,6 +243,7 @@ public void Initialize( GridPosition = position; MaxActiveRooms = maxActiveRooms; IsAwake = false; + _wasAutomaticallySlept = false; Width = width; Height = height; Depth = depth; @@ -242,6 +255,7 @@ public void Initialize( ActiveRoomCount = 0; ActiveGasCount = 0; SleepTimer = 0; + VoxelAggregates.Reset(); VoxelRoomMap.Clear(); Array.Clear(ActiveAirIndices, 0, ActiveAirIndices.Length); @@ -271,6 +285,7 @@ public void MarkChanged() /// public void Release() { + VoxelAggregates.Reset(); if (ActiveGases != null) { for (var i = 0; i < ActiveGasCount; i++) @@ -286,36 +301,55 @@ public void Release() /// The room ID to activate. /// /// Solid and void classifications are ignored. Activating an already active room only resets - /// the sleep timer. When a new room is activated, is rebuilt. + /// the sleep timer. Any wake after automatic sleep first resumes the complete retained active domain. + /// When a new room is activated, is rebuilt. /// - /// Thrown when would exceed . + /// + /// would exceed . + /// public virtual void WakeRoom(int targetRoomId) { if (targetRoomId == VoxelClassification.RoomSolid || targetRoomId == VoxelClassification.RoomVoid) return; + if (WasAutomaticallySlept) + { + if (!CanResumeAutomaticallySleptDomainWithRoom(targetRoomId)) + throw new InvalidOperationException("Maximum active rooms reached for this chunk."); + + ResumeAutomaticallySleptDomain(); + } + if (IsAwake) { for (var r = 0; r < ActiveRoomCount; r++) { if (ActiveRoomIds[r] == targetRoomId) { + // A successful wake is a local disturbance. Rebuild aggregate membership progressively + // from the materialized voxel state instead of retaining a pre-disturbance grouping. + VoxelAggregates.Reset(); + _wasAutomaticallySlept = false; SleepTimer = 0; MarkChanged(); return; } } + } - if (!IsAwake) + int prospectiveRoomCount = IsAwake ? ActiveRoomCount : 0; + if (prospectiveRoomCount >= MaxActiveRooms) { - ActiveRoomCount = 0; - IsAwake = true; + throw new InvalidOperationException("Maximum active rooms reached for this chunk."); } - if (ActiveRoomCount >= MaxActiveRooms) + VoxelAggregates.Reset(); + _wasAutomaticallySlept = false; + if (!IsAwake) { - throw new Exception("Maximum active rooms reached for this chunk!"); + ActiveRoomCount = 0; + IsAwake = true; } ActiveRoomIds[ActiveRoomCount] = targetRoomId; @@ -326,39 +360,342 @@ public virtual void WakeRoom(int targetRoomId) } /// - /// Rebuilds the dense list of voxel indices belonging to active rooms. + /// Wakes the classification seed addressed by a specific voxel, or only resets the lifecycle when that + /// voxel is already in the active solver domain. Because room labels are activation seeds, disconnected + /// passable regions carrying the same label are activated together. + /// + internal void WakeVoxel(ushort localVoxelIndex) + { + int roomId = VoxelRoomMap[localVoxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return; + + // Automatic sleep is chunk-wide. Any successful local wake first restores the exact solver domain that + // qualified for sleep, then adds a new target component only if retained room capacity permits it. + if (WasAutomaticallySlept) + { + Span requestedVoxel = stackalloc ushort[1]; + requestedVoxel[0] = localVoxelIndex; + if (!CanWakeVoxels(requestedVoxel)) + throw new InvalidOperationException("Maximum active rooms reached for this chunk."); + + ResumeAutomaticallySleptDomain(); + } + + if (IsAwake && IsVoxelActive(localVoxelIndex)) + { + VoxelAggregates.Reset(); + _wasAutomaticallySlept = false; + SleepTimer = 0; + MarkChanged(); + return; + } + + WakeRoom(roomId); + } + + private bool CanResumeAutomaticallySleptDomainWithRoom(int targetRoomId) + { + var retainedRoomCount = 0; + for (var roomIndex = 0; roomIndex < ActiveRoomCount; roomIndex++) + { + int roomId = ActiveRoomIds[roomIndex]; + if (!HasPassableVoxelForRoom(roomId)) + continue; + + retainedRoomCount++; + if (roomId == targetRoomId) + return true; + } + + return retainedRoomCount < MaxActiveRooms; + } + + private void ResumeAutomaticallySleptDomain() + { + Debug.Assert(WasAutomaticallySlept); + + // Topology edits can wake a retained voxel after changing its classification. Remove seeds that no + // longer exist before rebuilding so stale labels cannot consume room capacity or seed empty domains. + var retainedRoomCount = 0; + for (var roomIndex = 0; roomIndex < ActiveRoomCount; roomIndex++) + { + int roomId = ActiveRoomIds[roomIndex]; + if (HasPassableVoxelForRoom(roomId)) + ActiveRoomIds[retainedRoomCount++] = roomId; + } + + ActiveRoomCount = retainedRoomCount; + _wasAutomaticallySlept = false; + IsAwake = true; + SleepTimer = 0; + RebuildActiveAirIndices(); + } + + private bool HasPassableVoxelForRoom(int roomId) + { + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return false; + + for (var voxelIndex = 0; voxelIndex < VoxelCount; voxelIndex++) + { + if (VoxelRoomMap[voxelIndex] == roomId) + return true; + } + + return false; + } + + /// Returns whether a voxel is present in the current sorted active-air domain. + internal bool IsVoxelActive(ushort localVoxelIndex) + { + return IsAwake && + Array.BinarySearch(ActiveAirIndices, 0, ActiveAirCount, localVoxelIndex) >= 0; + } + + /// + /// Determines whether all requested voxel components can be activated without exceeding room-seed + /// capacity. The simulation is prospective: each accepted seed expands through the same passable + /// closure as before the next request is evaluated. If any + /// request would resume an automatically slept domain, all retained seeds participate in the preflight. + /// + internal bool CanWakeVoxels(ReadOnlySpan localVoxelIndices) + { + bool[] included = ArrayPool.Shared.Rent(VoxelCount); + int[] queue = ArrayPool.Shared.Rent(VoxelCount); + Array.Clear(included, 0, VoxelCount); + try + { + var activeRooms = new HashSet(); + if (IsAwake) + { + for (var activeIndex = 0; activeIndex < ActiveAirCount; activeIndex++) + included[ActiveAirIndices[activeIndex]] = true; + for (var roomIndex = 0; roomIndex < ActiveRoomCount; roomIndex++) + activeRooms.Add(ActiveRoomIds[roomIndex]); + } + else if (WasAutomaticallySlept && + ContainsWakeableVoxel(localVoxelIndices)) + { + // An automatic sleeper resumes every still-valid retained seed before any state is applied. + // Model that union during preflight so a later request cannot exceed capacity after an earlier + // mutation has already committed. + for (var roomIndex = 0; roomIndex < ActiveRoomCount; roomIndex++) + { + int roomId = ActiveRoomIds[roomIndex]; + if (!HasPassableVoxelForRoom(roomId) || !activeRooms.Add(roomId)) + continue; + if (activeRooms.Count > MaxActiveRooms) + return false; + + IncludeProspectiveRoomClosure(roomId, included, queue); + } + } + + foreach (ushort localVoxelIndex in localVoxelIndices) + { + if (included[localVoxelIndex]) + continue; + + int roomId = VoxelRoomMap[localVoxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + continue; + if (activeRooms.Add(roomId) && activeRooms.Count > MaxActiveRooms) + return false; + + IncludeProspectiveRoomClosure(roomId, included, queue); + } + + return true; + } + finally + { + ArrayPool.Shared.Return(queue); + ArrayPool.Shared.Return(included); + } + } + + private bool ContainsWakeableVoxel(ReadOnlySpan localVoxelIndices) + { + foreach (ushort localVoxelIndex in localVoxelIndices) + { + int roomId = VoxelRoomMap[localVoxelIndex]; + if (roomId != VoxelClassification.RoomSolid && + roomId != VoxelClassification.RoomVoid) + return true; + } + + return false; + } + + private void IncludeProspectiveRoomClosure(int roomId, bool[] included, int[] queue) + { + var queuedCount = 0; + for (var voxelIndex = 0; voxelIndex < VoxelCount; voxelIndex++) + { + if (!included[voxelIndex] && VoxelRoomMap[voxelIndex] == roomId) + { + included[voxelIndex] = true; + queue[queuedCount++] = voxelIndex; + } + } + + for (var queuedIndex = 0; queuedIndex < queuedCount; queuedIndex++) + { + int componentVoxel = queue[queuedIndex]; + int x = componentVoxel % Width; + int yz = componentVoxel / Width; + int y = yz % Height; + int z = yz / Height; + if (x > 0) + TryEnqueuePassableVoxel(componentVoxel - 1, included, queue, ref queuedCount); + if (x + 1 < Width) + TryEnqueuePassableVoxel(componentVoxel + 1, included, queue, ref queuedCount); + if (y > 0) + TryEnqueuePassableVoxel(componentVoxel - Width, included, queue, ref queuedCount); + if (y + 1 < Height) + TryEnqueuePassableVoxel(componentVoxel + Width, included, queue, ref queuedCount); + int layerSize = Width * Height; + if (z > 0) + TryEnqueuePassableVoxel(componentVoxel - layerSize, + included, queue, ref queuedCount); + if (z + 1 < Depth) + TryEnqueuePassableVoxel(componentVoxel + layerSize, + included, queue, ref queuedCount); + } + } + + private void TryEnqueuePassableVoxel(int voxelIndex, bool[] included, + int[] queue, ref int queuedCount) + { + if (included[voxelIndex]) + return; + + int roomId = VoxelRoomMap[voxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return; + + included[voxelIndex] = true; + queue[queuedCount++] = voxelIndex; + } + + /// + /// Rebuilds the dense list of voxel indices in passable components seeded by active rooms. /// /// - /// The resulting list is stored in and its valid length is written - /// to . Call this after modifying room classifications or active room IDs. + /// Room IDs select starting voxels, not barriers. The traversal expands through every face-connected + /// voxel except solid and void classifications, matching the domain used by intra-chunk gas and heat + /// transfer. Disconnected components without an active-room seed remain inactive. The final list is + /// stored in ascending flat-index order for deterministic solver traversal. /// public void RebuildActiveAirIndices() { - ActiveAirCount = 0; - for (ushort i = 0; i < VoxelCount; i++) + VoxelAggregates.Reset(); + bool[] included = ArrayPool.Shared.Rent(VoxelCount); + Array.Clear(included, 0, VoxelCount); + var queuedCount = 0; + try { - int roomId = VoxelRoomMap[i]; - for (var r = 0; r < ActiveRoomCount; r++) + for (ushort voxelIndex = 0; voxelIndex < VoxelCount; voxelIndex++) { - if (ActiveRoomIds[r] == roomId) + int roomId = VoxelRoomMap[voxelIndex]; + for (var roomIndex = 0; roomIndex < ActiveRoomCount; roomIndex++) { - ActiveAirIndices[ActiveAirCount] = i; - ActiveAirCount++; + if (ActiveRoomIds[roomIndex] != roomId) + continue; + + included[voxelIndex] = true; + ActiveAirIndices[queuedCount++] = voxelIndex; break; } } + + for (var queuedIndex = 0; queuedIndex < queuedCount; queuedIndex++) + { + int voxelIndex = ActiveAirIndices[queuedIndex]; + int x = voxelIndex % Width; + int yz = voxelIndex / Width; + int y = yz % Height; + int z = yz / Height; + if (x > 0) + TryEnqueuePassableVoxel(voxelIndex - 1, included, ref queuedCount); + if (x + 1 < Width) + TryEnqueuePassableVoxel(voxelIndex + 1, included, ref queuedCount); + if (y > 0) + TryEnqueuePassableVoxel(voxelIndex - Width, included, ref queuedCount); + if (y + 1 < Height) + TryEnqueuePassableVoxel(voxelIndex + Width, included, ref queuedCount); + int layerSize = Width * Height; + if (z > 0) + TryEnqueuePassableVoxel(voxelIndex - layerSize, included, ref queuedCount); + if (z + 1 < Depth) + TryEnqueuePassableVoxel(voxelIndex + layerSize, included, ref queuedCount); + } + + ActiveAirCount = 0; + for (ushort voxelIndex = 0; voxelIndex < VoxelCount; voxelIndex++) + { + if (included[voxelIndex]) + ActiveAirIndices[ActiveAirCount++] = voxelIndex; + } + } + finally + { + ArrayPool.Shared.Return(included); } } + private void TryEnqueuePassableVoxel(int voxelIndex, bool[] included, ref int queuedCount) + { + if (included[voxelIndex]) + return; + + int roomId = VoxelRoomMap[voxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return; + + included[voxelIndex] = true; + ActiveAirIndices[queuedCount++] = (ushort)voxelIndex; + } + /// /// Marks the chunk as sleeping so that it is skipped by simulation ticks. /// public virtual void Sleep() { + _wasAutomaticallySlept = false; IsAwake = false; MarkChanged(); } + /// Enters solver-qualified sleep while retaining provenance for configuration invalidation. + internal void SleepAutomatically() + { + _wasAutomaticallySlept = true; + IsAwake = false; + MarkChanged(); + } + + /// Whether the current sleeping state was entered by automatic convergence logic. + internal bool WasAutomaticallySlept => !IsAwake && _wasAutomaticallySlept; + + /// + /// Invalidates solver-derived equilibrium state after a physics configuration or pipeline change. + /// Explicitly slept chunks remain frozen; automatic sleepers resume their retained active domain. + /// + internal void InvalidateSolverDerivedState() + { + if (!IsAwake && !_wasAutomaticallySlept) + return; + + VoxelAggregates.Reset(); + SleepTimer = 0; + if (_wasAutomaticallySlept) + ResumeAutomaticallySleptDomain(); + + MarkChanged(); + } + /// /// Adds gas to a voxel and updates pressure with the supplied ideal-gas pressure coefficient. /// @@ -388,34 +725,55 @@ public void InjectGasToVoxel(ushort localVoxelIndex, int gasId, float molesToAdd if (room == VoxelClassification.RoomVoid) return; - SleepTimer = 0; - float currentHeatCapacity = TotalHeatCapacity[localVoxelIndex]; + var currentTotalMoles = 0d; + var currentGasMoles = 0f; + for (var gas = 0; gas < ActiveGasCount; gas++) + { + float storedMoles = ActiveGases[gas].Moles[localVoxelIndex]; + if (!float.IsFinite(storedMoles) || storedMoles < 0f) + throw new InvalidOperationException("The existing gas amount is not representable."); + currentTotalMoles += storedMoles; + if (ActiveGases[gas].GasId == gasId) + currentGasMoles = storedMoles; + } - int targetChannelIndex = GetOrCreateGasChannel(gasId); - - ActiveGases[targetChannelIndex].Moles[localVoxelIndex] += molesToAdd; - - var currentTotalMoles = 0f; - for (var g = 0; g < ActiveGasCount; g++) + float combinedGasMoles = (float)((double)currentGasMoles + molesToAdd); + float combinedTotalMoles = (float)(currentTotalMoles + molesToAdd); + double incomingHeatCapacity = (double)molesToAdd * + effectiveMolarHeatCapacityAtConstantVolume; + float newHeatCapacity = (float)(currentHeatCapacity + incomingHeatCapacity); + if (!float.IsFinite(combinedGasMoles) || !float.IsFinite(combinedTotalMoles) || + !float.IsFinite(newHeatCapacity)) { - currentTotalMoles += ActiveGases[g].Moles[localVoxelIndex]; + throw new InvalidOperationException( + "The injected mixture exceeds the supported numeric range."); } - float incomingHeatCapacity = molesToAdd * effectiveMolarHeatCapacityAtConstantVolume; - float newHeatCapacity = currentHeatCapacity + incomingHeatCapacity; float currentTemp = Temperature[localVoxelIndex]; float newTemp = currentHeatCapacity > 0f && newHeatCapacity > 0f ? currentTemp == temperature ? currentTemp // Interpolation avoids the overflow-prone sum C1*T1 + C2*T2. - : currentTemp + (temperature - currentTemp) * incomingHeatCapacity / newHeatCapacity + : (float)(currentTemp + ((double)temperature - currentTemp) * + incomingHeatCapacity / newHeatCapacity) : temperature; + float newPressure = (float)((double)combinedTotalMoles * newTemp * pressurePerMoleKelvin); + if (!float.IsFinite(newTemp) || newTemp < 0f || !float.IsFinite(newPressure)) + { + throw new InvalidOperationException( + "The injected mixture exceeds the supported numeric range."); + } + + VoxelAggregates.Reset(); + SleepTimer = 0; + + int targetChannelIndex = GetOrCreateGasChannel(gasId); + ActiveGases[targetChannelIndex].Moles[localVoxelIndex] = combinedGasMoles; TotalHeatCapacity[localVoxelIndex] = newHeatCapacity; Temperature[localVoxelIndex] = newTemp; - - TotalPressure[localVoxelIndex] = currentTotalMoles * newTemp * pressurePerMoleKelvin; + TotalPressure[localVoxelIndex] = newPressure; MarkChanged(); } diff --git a/src/Numos.CoreSim/AtmosConfig.cs b/src/Numos.CoreSim/AtmosConfig.cs index d54c766..e0d0ed1 100644 --- a/src/Numos.CoreSim/AtmosConfig.cs +++ b/src/Numos.CoreSim/AtmosConfig.cs @@ -43,7 +43,8 @@ public class AtmosConfig /// Physical volume represented by one voxel, in cubic metres (m³). /// /// - /// Numos calculates pressure in pascals from P = nRT/V. Non-finite and nonpositive values are + /// Numos calculates pressure in pascals from P = nRT/V. Non-finite and nonpositive values, and + /// positive values for which the single-precision R/V coefficient is unrepresentable, are /// normalized to 1 m³ by the simulation. /// public float VoxelVolume { get; set; } = AtmosConfigDefaults.VoxelVolume; @@ -61,7 +62,10 @@ public class AtmosConfig /// /// Per-tick Fickian mixing fraction used for gas IDs missing from . /// - /// Values are clamped to [0, 1]; non-finite values disable fallback diffusion. + /// + /// Values are normalized to [0, 1] and the explicit face update caps the effective fraction at 0.5; + /// non-finite values disable fallback diffusion. + /// public float DefaultDiffusionCoefficient { get; set; } = AtmosConfigDefaults.DefaultDiffusionCoefficient; /// @@ -103,17 +107,59 @@ public class AtmosConfig public float VacuumThreshold { get; set; } = AtmosConfigDefaults.VacuumThreshold; /// - /// Consecutive ticks below before a chunk goes to sleep. + /// Consecutive stable verification ticks required before a chunk goes to sleep. /// - /// Negative values are normalized to zero. + /// + /// Negative values are normalized to zero. Snap-assisted sleep observes at least one complete built-in + /// thermodynamics cadence even when this value is smaller. Legacy pressure-only automatic sleep is + /// evaluated by the advection stage and therefore requires that stage to be enabled. + /// public int SleepThreshold { get; set; } = AtmosConfigDefaults.SleepThreshold; /// - /// Maximum pressure delta considered "at rest", in pascals (Pa). + /// Maximum pressure correction considered "at rest", in pascals (Pa). /// /// Non-finite and negative values are normalized to zero. public float SleepEpsilon { get; set; } = AtmosConfigDefaults.SleepEpsilon; + /// + /// Whether neighboring, nearly equilibrated voxels are conservatively combined before automatic sleep. + /// + /// + /// Snapping is confined to face-connected voxels in one chunk. Disabling it retains the legacy + /// pressure-only automatic-sleep behavior while advection is enabled. + /// + public bool VoxelSnappingEnabled { get; set; } = AtmosConfigDefaults.VoxelSnappingEnabled; + + /// + /// Maximum relative pressure correction allowed when snapping neighboring voxels. + /// + /// + /// Each member's pressure correction is compared with this fraction of the greatest of its current + /// pressure, the proposed aggregate equilibrium pressure, and . The allowed + /// correction is never smaller than . The setting has no effect while voxel + /// snapping is disabled. Finite values are clamped to [0, 1]; non-finite values normalize to zero. + /// + public float VoxelSnapPressureRelativeEpsilon { get; set; } = + AtmosConfigDefaults.VoxelSnapPressureRelativeEpsilon; + + /// + /// Maximum absolute temperature correction allowed when snapping neighboring voxels, in kelvins (K). + /// + /// + /// This bounds each member's correction to a proposed aggregate equilibrium. The setting has no effect + /// while voxel snapping is disabled. Non-finite and negative values normalize to zero. + /// + public float VoxelSnapTemperatureEpsilon { get; set; } = + AtmosConfigDefaults.VoxelSnapTemperatureEpsilon; + + /// + /// Maximum absolute per-species mole-fraction correction allowed when snapping neighboring voxels. + /// + /// Finite values are clamped to [0, 1]; non-finite values are normalized to zero. + public float VoxelSnapMoleFractionEpsilon { get; set; } = + AtmosConfigDefaults.VoxelSnapMoleFractionEpsilon; + /// /// Effective thermal conductance between adjacent voxels, in joules per kelvin (J/K) per /// thermodynamics tick (currently every second simulation tick). diff --git a/src/Numos.CoreSim/AtmosConfigDefaults.cs b/src/Numos.CoreSim/AtmosConfigDefaults.cs index 59bc8d6..8f6e711 100644 --- a/src/Numos.CoreSim/AtmosConfigDefaults.cs +++ b/src/Numos.CoreSim/AtmosConfigDefaults.cs @@ -49,8 +49,23 @@ public static class AtmosConfigDefaults /// Default consecutive quiet ticks required before a chunk sleeps. public const int SleepThreshold = 100; - /// Default maximum pressure delta considered at rest, in pascals (Pa). - public const float SleepEpsilon = 3.5f; + /// + /// Default absolute pressure-correction floor used by voxel snapping, and the legacy neighboring-pressure + /// tolerance when snapping is disabled, in pascals (Pa). + /// + public const float SleepEpsilon = 0.5f; + + /// Default enablement for conservative progressive voxel snapping. + public const bool VoxelSnappingEnabled = true; + + /// Default maximum relative pressure correction made by one voxel snap. + public const float VoxelSnapPressureRelativeEpsilon = 0.001f; + + /// Default maximum temperature correction made by one voxel snap, in kelvins (K). + public const float VoxelSnapTemperatureEpsilon = 0.01f; + + /// Default maximum per-species mole-fraction correction made by one voxel snap. + public const float VoxelSnapMoleFractionEpsilon = 0.001f; /// Default effective per-face thermal conductance, in joules per kelvin per thermodynamics tick. public const float ThermalConductance = 0.05f; diff --git a/src/Numos.CoreSim/AtmosKernel.API.cs b/src/Numos.CoreSim/AtmosKernel.API.cs index 20ca0b2..9b702b3 100644 --- a/src/Numos.CoreSim/AtmosKernel.API.cs +++ b/src/Numos.CoreSim/AtmosKernel.API.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Diagnostics; using Numos.CoreSim.Datatypes.Primitives; using Numos.CoreSim.Datatypes.Snapshots; using Numos.CoreSim.Solvers; @@ -39,6 +41,7 @@ internal void RegisterSolver(string name, SolverStepKind kind, lock (_stateGate) { _solverPipeline.Register(name, kind, solver, _solverPipeline.Count); + _solverPipelineInvalidationPending = true; } } @@ -51,6 +54,7 @@ internal void RegisterSolverBefore(string existingName, string name, SolverStepK if (index < 0) throw new KeyNotFoundException($"No solver named '{existingName}' is registered."); _solverPipeline.Register(name, kind, solver, index); + _solverPipelineInvalidationPending = true; } } @@ -63,6 +67,7 @@ internal void RegisterSolverAfter(string existingName, string name, SolverStepKi if (index < 0) throw new KeyNotFoundException($"No solver named '{existingName}' is registered."); _solverPipeline.Register(name, kind, solver, index + 1); + _solverPipelineInvalidationPending = true; } } @@ -78,7 +83,9 @@ internal bool SetSolverEnabled(string name, bool enabled) { lock (_stateGate) { - return _solverPipeline.SetEnabled(name, enabled); + bool found = _solverPipeline.SetEnabled(name, enabled, out bool becameEnabled); + _solverPipelineInvalidationPending |= becameEnabled; + return found; } } @@ -86,7 +93,7 @@ internal void ResetSolverPipeline() { lock (_stateGate) { - _solverPipeline.Reset(); + _solverPipelineInvalidationPending |= _solverPipeline.Reset(); } } @@ -195,9 +202,15 @@ internal void RegisterChunk(AtmosChunk chunk) { lock (_stateGate) { - if (!_chunkMap.TryAdd(chunk.GridPosition, chunk)) + if (_chunkMap.ContainsKey(chunk.GridPosition)) throw new InvalidOperationException($"A chunk is already registered at {chunk.GridPosition}."); + + Dictionary> wakePlan = CreateBoundaryWakePlan(chunk); + ValidateBoundaryWakePlan(wakePlan); + bool added = _chunkMap.TryAdd(chunk.GridPosition, chunk); + Debug.Assert(added); _chunkCollectionRevision++; + ApplyBoundaryWakePlan(wakePlan); } } @@ -229,13 +242,15 @@ internal bool UnregisterChunk(Int3 position) /// The number of voxels along the local z-axis. /// The maximum number of room IDs that may be active simultaneously. /// A chunk is already registered at . - internal void CreateAndRegisterChunk(Int3 position, int width, int height, int depth, int maxActiveRooms) + internal void CreateAndRegisterChunk(Int3 position, int width, int height, int depth, int maxActiveRooms, + int initialClassification = VoxelClassification.RoomUnassigned) { lock (_stateGate) { ThrowIfTickExecuting("register a chunk during the current tick"); var chunk = new AtmosChunk(width, height, depth, maxActiveRooms); chunk.Initialize(position, width, height, depth, maxActiveRooms); + chunk.VoxelRoomMap.Fill(initialClassification); RegisterChunk(chunk); } } @@ -406,8 +421,27 @@ internal void SetChunkClassification(Int3 position, VoxelClassification classifi lock (_stateGate) { var chunk = GetChunk(position); + int[] previousClassifications = chunk.VoxelRoomMap.ToArray(); + ushort[] previouslyActiveVoxels = CaptureActiveVoxels(chunk); chunk.VoxelRoomMap.Fill(classification.RoomId); + Dictionary> wakePlan; + try + { + wakePlan = CreateChangedBoundaryWakePlan(chunk, previousClassifications); + AddPreviouslyActiveComponents( + wakePlan, chunk, previouslyActiveVoxels, previousClassifications); + AddGasBearingChangedComponents(wakePlan, chunk, previousClassifications); + AddGasBearingNewVoidAdjacentComponents(wakePlan, chunk, previousClassifications); + ValidateBoundaryWakePlan(wakePlan); + } + catch + { + chunk.VoxelRoomMap.CopyFrom(previousClassifications); + throw; + } + RebuildActiveTopology(chunk); + ApplyBoundaryWakePlan(wakePlan); chunk.MarkChanged(); } } @@ -424,6 +458,8 @@ internal void SetChunkBoundaryClassification(Int3 position, VoxelClassification lock (_stateGate) { var chunk = GetChunk(position); + int[] previousClassifications = chunk.VoxelRoomMap.ToArray(); + ushort[] previouslyActiveVoxels = CaptureActiveVoxels(chunk); var dimensions = chunk.Dimensions; for (var z = 0; z < dimensions.Z; z++) @@ -439,7 +475,24 @@ internal void SetChunkBoundaryClassification(Int3 position, VoxelClassification chunk.VoxelRoomMap[chunk.GetIndex(new Int3(x, y, z))] = classification.RoomId; } + Dictionary> wakePlan; + try + { + wakePlan = CreateChangedBoundaryWakePlan(chunk, previousClassifications); + AddPreviouslyActiveComponents( + wakePlan, chunk, previouslyActiveVoxels, previousClassifications); + AddGasBearingChangedComponents(wakePlan, chunk, previousClassifications); + AddGasBearingNewVoidAdjacentComponents(wakePlan, chunk, previousClassifications); + ValidateBoundaryWakePlan(wakePlan); + } + catch + { + chunk.VoxelRoomMap.CopyFrom(previousClassifications); + throw; + } + RebuildActiveTopology(chunk); + ApplyBoundaryWakePlan(wakePlan); chunk.MarkChanged(); } } @@ -460,8 +513,27 @@ internal void SetVoxelClassification(Int3 position, ushort localVoxelIndex, { var chunk = GetChunk(position); ValidateVoxelIndex(chunk, localVoxelIndex); + int[] previousClassifications = chunk.VoxelRoomMap.ToArray(); + ushort[] previouslyActiveVoxels = CaptureActiveVoxels(chunk); chunk.VoxelRoomMap[localVoxelIndex] = classification.RoomId; + Dictionary> wakePlan; + try + { + wakePlan = CreateChangedBoundaryWakePlan(chunk, previousClassifications); + AddPreviouslyActiveComponents( + wakePlan, chunk, previouslyActiveVoxels, previousClassifications); + AddGasBearingChangedComponents(wakePlan, chunk, previousClassifications); + AddGasBearingNewVoidAdjacentComponents(wakePlan, chunk, previousClassifications); + ValidateBoundaryWakePlan(wakePlan); + } + catch + { + chunk.VoxelRoomMap.CopyFrom(previousClassifications); + throw; + } + RebuildActiveTopology(chunk); + ApplyBoundaryWakePlan(wakePlan); chunk.MarkChanged(); } } @@ -494,16 +566,25 @@ internal void SetVoxelClassification(Int3 position, int x, int y, int z, /// The absolute temperature to store, in kelvins. /// No chunk is registered at . /// is outside the chunk. + /// + /// The requested temperature would make the voxel's derived pressure unrepresentable. + /// internal void SetVoxelTemperature(Int3 position, ushort localVoxelIndex, float temperature) { lock (_stateGate) { var chunk = GetChunk(position); ValidateVoxelIndex(chunk, localVoxelIndex); + VoxelGasMixtureTotals totals = CalculateVoxelMixtureTotals( + chunk, + localVoxelIndex, + temperature); + if (chunk.IsAwake || chunk.WasAutomaticallySlept) + chunk.WakeVoxel(localVoxelIndex); + else + chunk.VoxelAggregates.Reset(); chunk.Temperature[localVoxelIndex] = temperature; - chunk.TotalPressure[localVoxelIndex] = - AtmosSolverMath.CalculatePressureAtVoxel(_config, chunk, localVoxelIndex); - chunk.MarkChanged(); + ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); } } @@ -550,8 +631,15 @@ internal void AddGasToVoxel(Int3 position, ushort localVoxelIndex, int gasId, fl if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) return; - chunk.WakeRoom(roomId); - GasInjectionSolver.Inject(chunk, localVoxelIndex, gasId, moles, temperature, _config); + // Validate the complete projected mixture before waking the target. Individually finite inputs can + // still overflow an existing species, the voxel total, heat capacity, or pressure; rejecting those + // states after WakeVoxel would make a failed injection observably mutate lifecycle state. + VoxelGasAddition addition = PrepareVoxelGasAddition( + chunk, localVoxelIndex, gasId, moles, temperature); + chunk.WakeVoxel(localVoxelIndex); + SetVoxelGasMoles(chunk, localVoxelIndex, gasId, addition.CombinedGasMoles); + chunk.Temperature[localVoxelIndex] = addition.MixedTemperature; + ApplyVoxelMixtureTotals(chunk, localVoxelIndex, addition.Totals); } } @@ -630,8 +718,654 @@ private AtmosChunk GetChunk(Int3 position) private static void RebuildActiveTopology(AtmosChunk chunk) { - if (chunk.IsAwake) - chunk.RebuildActiveAirIndices(); + if (!chunk.IsAwake) + return; + + var retainedRoomCount = 0; + for (var roomIndex = 0; roomIndex < chunk.ActiveRoomCount; roomIndex++) + { + int roomId = chunk.ActiveRoomIds[roomIndex]; + if (HasPassableRoomVoxel(chunk, roomId)) + chunk.ActiveRoomIds[retainedRoomCount++] = roomId; + } + + chunk.ActiveRoomCount = retainedRoomCount; + chunk.RebuildActiveAirIndices(); + } + + private static bool HasPassableRoomVoxel(AtmosChunk chunk, int roomId) + { + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return false; + + for (var voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + { + if (chunk.VoxelRoomMap[voxelIndex] == roomId) + return true; + } + + return false; + } + + private static ushort[] CaptureActiveVoxels(AtmosChunk chunk) + { + if ((!chunk.IsAwake && !chunk.WasAutomaticallySlept) || chunk.ActiveAirCount == 0) + return []; + + var activeVoxels = new ushort[chunk.ActiveAirCount]; + Array.Copy(chunk.ActiveAirIndices, activeVoxels, chunk.ActiveAirCount); + return activeVoxels; + } + + private static void AddPreviouslyActiveComponents( + Dictionary> wakePlan, AtmosChunk chunk, + ReadOnlySpan previouslyActiveVoxels, + ReadOnlySpan previousClassifications) + { + if (previouslyActiveVoxels.IsEmpty) + return; + + bool activeTopologyChanged = false; + foreach (ushort voxelIndex in previouslyActiveVoxels) + { + if (previousClassifications[voxelIndex] == chunk.VoxelRoomMap[voxelIndex]) + continue; + activeTopologyChanged = true; + break; + } + + if (!activeTopologyChanged) + return; + + bool[] visited = ArrayPool.Shared.Rent(chunk.VoxelCount); + int[] queue = ArrayPool.Shared.Rent(chunk.VoxelCount); + Array.Clear(visited, 0, chunk.VoxelCount); + try + { + foreach (ushort seedVoxel in previouslyActiveVoxels) + { + int roomId = chunk.VoxelRoomMap[seedVoxel]; + if (visited[seedVoxel] || + roomId == VoxelClassification.RoomSolid || + roomId == VoxelClassification.RoomVoid) + continue; + + if (!wakePlan.TryGetValue(chunk, out SortedSet? componentSeeds)) + { + componentSeeds = []; + wakePlan.Add(chunk, componentSeeds); + } + + componentSeeds.Add(seedVoxel); + var queuedCount = 0; + visited[seedVoxel] = true; + queue[queuedCount++] = seedVoxel; + for (var queuedIndex = 0; queuedIndex < queuedCount; queuedIndex++) + { + int componentVoxel = queue[queuedIndex]; + int x = componentVoxel % chunk.Width; + int yz = componentVoxel / chunk.Width; + int y = yz % chunk.Height; + int z = yz / chunk.Height; + if (x > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - 1, + visited, queue, ref queuedCount); + if (x + 1 < chunk.Width) + TryEnqueuePassableVoxel(chunk, componentVoxel + 1, + visited, queue, ref queuedCount); + if (y > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - chunk.Width, + visited, queue, ref queuedCount); + if (y + 1 < chunk.Height) + TryEnqueuePassableVoxel(chunk, componentVoxel + chunk.Width, + visited, queue, ref queuedCount); + int layerSize = chunk.Width * chunk.Height; + if (z > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - layerSize, + visited, queue, ref queuedCount); + if (z + 1 < chunk.Depth) + TryEnqueuePassableVoxel(chunk, componentVoxel + layerSize, + visited, queue, ref queuedCount); + } + } + } + finally + { + ArrayPool.Shared.Return(queue); + ArrayPool.Shared.Return(visited); + } + } + + private static void AddGasBearingChangedComponents( + Dictionary> wakePlan, + AtmosChunk chunk, + ReadOnlySpan previousClassifications) + { + bool[] visited = ArrayPool.Shared.Rent(chunk.VoxelCount); + int[] queue = ArrayPool.Shared.Rent(chunk.VoxelCount); + Array.Clear(visited, 0, chunk.VoxelCount); + try + { + for (ushort seedVoxel = 0; seedVoxel < chunk.VoxelCount; seedVoxel++) + { + int seedRoom = chunk.VoxelRoomMap[seedVoxel]; + if (visited[seedVoxel] || + seedRoom == VoxelClassification.RoomSolid || + seedRoom == VoxelClassification.RoomVoid) + continue; + + var queuedCount = 0; + visited[seedVoxel] = true; + queue[queuedCount++] = seedVoxel; + var hasGas = false; + var containsChangedVoxel = false; + for (var queuedIndex = 0; queuedIndex < queuedCount; queuedIndex++) + { + int componentVoxel = queue[queuedIndex]; + hasGas |= HasGasAtVoxel(chunk, (ushort)componentVoxel); + containsChangedVoxel |= previousClassifications[componentVoxel] != + chunk.VoxelRoomMap[componentVoxel]; + int x = componentVoxel % chunk.Width; + int yz = componentVoxel / chunk.Width; + int y = yz % chunk.Height; + int z = yz / chunk.Height; + if (x > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - 1, + visited, queue, ref queuedCount); + if (x + 1 < chunk.Width) + TryEnqueuePassableVoxel(chunk, componentVoxel + 1, + visited, queue, ref queuedCount); + if (y > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - chunk.Width, + visited, queue, ref queuedCount); + if (y + 1 < chunk.Height) + TryEnqueuePassableVoxel(chunk, componentVoxel + chunk.Width, + visited, queue, ref queuedCount); + int layerSize = chunk.Width * chunk.Height; + if (z > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - layerSize, + visited, queue, ref queuedCount); + if (z + 1 < chunk.Depth) + TryEnqueuePassableVoxel(chunk, componentVoxel + layerSize, + visited, queue, ref queuedCount); + } + + if (!hasGas || !containsChangedVoxel) + continue; + if (!wakePlan.TryGetValue(chunk, out SortedSet? componentSeeds)) + { + componentSeeds = []; + wakePlan.Add(chunk, componentSeeds); + } + + componentSeeds.Add(seedVoxel); + } + } + finally + { + ArrayPool.Shared.Return(queue); + ArrayPool.Shared.Return(visited); + } + } + + private static void AddGasBearingNewVoidAdjacentComponents( + Dictionary> wakePlan, + AtmosChunk chunk, + ReadOnlySpan previousClassifications) + { + bool[] visited = ArrayPool.Shared.Rent(chunk.VoxelCount); + int[] queue = ArrayPool.Shared.Rent(chunk.VoxelCount); + Array.Clear(visited, 0, chunk.VoxelCount); + try + { + for (ushort seedVoxel = 0; seedVoxel < chunk.VoxelCount; seedVoxel++) + { + int seedRoom = chunk.VoxelRoomMap[seedVoxel]; + if (visited[seedVoxel] || + seedRoom == VoxelClassification.RoomSolid || + seedRoom == VoxelClassification.RoomVoid) + continue; + + var queuedCount = 0; + visited[seedVoxel] = true; + queue[queuedCount++] = seedVoxel; + var hasGas = false; + var touchesVoid = false; + for (var queuedIndex = 0; queuedIndex < queuedCount; queuedIndex++) + { + int componentVoxel = queue[queuedIndex]; + hasGas |= HasGasAtVoxel(chunk, (ushort)componentVoxel); + int x = componentVoxel % chunk.Width; + int yz = componentVoxel / chunk.Width; + int y = yz % chunk.Height; + int z = yz / chunk.Height; + if (x > 0) + VisitTopologyNeighbor(chunk, componentVoxel - 1, + visited, queue, ref queuedCount, previousClassifications, ref touchesVoid); + if (x + 1 < chunk.Width) + VisitTopologyNeighbor(chunk, componentVoxel + 1, + visited, queue, ref queuedCount, previousClassifications, ref touchesVoid); + if (y > 0) + VisitTopologyNeighbor(chunk, componentVoxel - chunk.Width, + visited, queue, ref queuedCount, previousClassifications, ref touchesVoid); + if (y + 1 < chunk.Height) + VisitTopologyNeighbor(chunk, componentVoxel + chunk.Width, + visited, queue, ref queuedCount, previousClassifications, ref touchesVoid); + int layerSize = chunk.Width * chunk.Height; + if (z > 0) + VisitTopologyNeighbor(chunk, componentVoxel - layerSize, + visited, queue, ref queuedCount, previousClassifications, ref touchesVoid); + if (z + 1 < chunk.Depth) + VisitTopologyNeighbor(chunk, componentVoxel + layerSize, + visited, queue, ref queuedCount, previousClassifications, ref touchesVoid); + } + + if (!hasGas || !touchesVoid) + continue; + if (!wakePlan.TryGetValue(chunk, out SortedSet? componentSeeds)) + { + componentSeeds = []; + wakePlan.Add(chunk, componentSeeds); + } + + componentSeeds.Add(seedVoxel); + } + } + finally + { + ArrayPool.Shared.Return(queue); + ArrayPool.Shared.Return(visited); + } + } + + private static void VisitTopologyNeighbor(AtmosChunk chunk, int voxelIndex, + bool[] visited, int[] queue, ref int queuedCount, + ReadOnlySpan previousClassifications, ref bool touchesVoid) + { + int roomId = chunk.VoxelRoomMap[voxelIndex]; + if (roomId == VoxelClassification.RoomVoid) + { + touchesVoid |= previousClassifications[voxelIndex] != VoxelClassification.RoomVoid; + return; + } + + if (roomId == VoxelClassification.RoomSolid || visited[voxelIndex]) + return; + + visited[voxelIndex] = true; + queue[queuedCount++] = voxelIndex; + } + + private Dictionary> CreateBoundaryWakePlan(AtmosChunk chunk) + { + var wakePlan = new Dictionary>(); + AddBoundaryWakeConnection(wakePlan, chunk, Int3.NegX, Int3.PosX); + AddBoundaryWakeConnection(wakePlan, chunk, Int3.PosX, Int3.NegX); + AddBoundaryWakeConnection(wakePlan, chunk, Int3.NegY, Int3.PosY); + AddBoundaryWakeConnection(wakePlan, chunk, Int3.PosY, Int3.NegY); + if (chunk.Depth > 1) + { + AddBoundaryWakeConnection(wakePlan, chunk, Int3.NegZ, Int3.PosZ); + AddBoundaryWakeConnection(wakePlan, chunk, Int3.PosZ, Int3.NegZ); + } + + return wakePlan; + } + + private Dictionary> CreateChangedBoundaryWakePlan( + AtmosChunk chunk, + ReadOnlySpan previousClassifications) + { + var wakePlan = new Dictionary>(); + AddChangedBoundaryWakeConnection( + wakePlan, chunk, previousClassifications, Int3.NegX); + AddChangedBoundaryWakeConnection( + wakePlan, chunk, previousClassifications, Int3.PosX); + AddChangedBoundaryWakeConnection( + wakePlan, chunk, previousClassifications, Int3.NegY); + AddChangedBoundaryWakeConnection( + wakePlan, chunk, previousClassifications, Int3.PosY); + if (chunk.Depth > 1) + { + AddChangedBoundaryWakeConnection( + wakePlan, chunk, previousClassifications, Int3.NegZ); + AddChangedBoundaryWakeConnection( + wakePlan, chunk, previousClassifications, Int3.PosZ); + } + + return wakePlan; + } + + private void AddChangedBoundaryWakeConnection( + Dictionary> wakePlan, + AtmosChunk chunk, + ReadOnlySpan previousClassifications, + Int3 direction) + { + if (!_chunkMap.TryGetValue(chunk.GridPosition + direction, out var neighbor)) + return; + + for (ushort voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + { + Int3 position = chunk.GetXyzInt3(voxelIndex); + bool isFace = direction.X < 0 && position.X == 0 || + direction.X > 0 && position.X == chunk.Width - 1 || + direction.Y < 0 && position.Y == 0 || + direction.Y > 0 && position.Y == chunk.Height - 1 || + direction.Z < 0 && position.Z == 0 || + direction.Z > 0 && position.Z == chunk.Depth - 1; + if (!isFace) + continue; + + Int3 neighborPosition = (position + direction + neighbor.Dimensions) % + neighbor.Dimensions; + ushort neighborIndex = neighbor.GetIndex(neighborPosition); + int neighborRoom = neighbor.VoxelRoomMap[neighborIndex]; + int oldRoom = previousClassifications[voxelIndex]; + int newRoom = chunk.VoxelRoomMap[voxelIndex]; + if (!OpensOrChangesBoundaryBehavior(oldRoom, newRoom, neighborRoom)) + continue; + + AddGasBearingComponentAtVoxel(wakePlan, chunk, voxelIndex); + AddGasBearingComponentAtVoxel(wakePlan, neighbor, neighborIndex); + } + } + + private static bool OpensOrChangesBoundaryBehavior(int oldRoom, int newRoom, int neighborRoom) + { + if (newRoom == VoxelClassification.RoomSolid || + neighborRoom == VoxelClassification.RoomSolid) + return false; + + if (oldRoom == VoxelClassification.RoomSolid) + return true; + + bool oldIsVoid = oldRoom == VoxelClassification.RoomVoid; + bool newIsVoid = newRoom == VoxelClassification.RoomVoid; + return oldIsVoid != newIsVoid; + } + + private static void AddGasBearingComponentAtVoxel( + Dictionary> wakePlan, + AtmosChunk chunk, + ushort seedVoxel) + { + int seedRoom = chunk.VoxelRoomMap[seedVoxel]; + if (seedRoom == VoxelClassification.RoomSolid || + seedRoom == VoxelClassification.RoomVoid) + return; + + bool[] visited = ArrayPool.Shared.Rent(chunk.VoxelCount); + int[] queue = ArrayPool.Shared.Rent(chunk.VoxelCount); + Array.Clear(visited, 0, chunk.VoxelCount); + try + { + var queuedCount = 0; + visited[seedVoxel] = true; + queue[queuedCount++] = seedVoxel; + var hasGas = false; + for (var queuedIndex = 0; queuedIndex < queuedCount; queuedIndex++) + { + int componentVoxel = queue[queuedIndex]; + hasGas |= HasGasAtVoxel(chunk, (ushort)componentVoxel); + int x = componentVoxel % chunk.Width; + int yz = componentVoxel / chunk.Width; + int y = yz % chunk.Height; + int z = yz / chunk.Height; + if (x > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - 1, + visited, queue, ref queuedCount); + if (x + 1 < chunk.Width) + TryEnqueuePassableVoxel(chunk, componentVoxel + 1, + visited, queue, ref queuedCount); + if (y > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - chunk.Width, + visited, queue, ref queuedCount); + if (y + 1 < chunk.Height) + TryEnqueuePassableVoxel(chunk, componentVoxel + chunk.Width, + visited, queue, ref queuedCount); + int layerSize = chunk.Width * chunk.Height; + if (z > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - layerSize, + visited, queue, ref queuedCount); + if (z + 1 < chunk.Depth) + TryEnqueuePassableVoxel(chunk, componentVoxel + layerSize, + visited, queue, ref queuedCount); + } + + if (!hasGas) + return; + if (!wakePlan.TryGetValue(chunk, out SortedSet? componentSeeds)) + { + componentSeeds = []; + wakePlan.Add(chunk, componentSeeds); + } + + componentSeeds.Add(seedVoxel); + } + finally + { + ArrayPool.Shared.Return(queue); + ArrayPool.Shared.Return(visited); + } + } + + private void AddBoundaryWakeConnection(Dictionary> wakePlan, + AtmosChunk chunk, Int3 direction, Int3 oppositeDirection) + { + if (!_chunkMap.TryGetValue(chunk.GridPosition + direction, out var neighbor)) + return; + + AddGasBearingBoundaryRooms(wakePlan, chunk, neighbor, direction); + if (direction.Z == 0 || neighbor.Depth > 1) + AddGasBearingBoundaryRooms(wakePlan, neighbor, chunk, oppositeDirection); + } + + private static void AddGasBearingBoundaryRooms( + Dictionary> wakePlan, AtmosChunk chunk, + AtmosChunk neighbor, Int3 direction) + { + bool[] visited = ArrayPool.Shared.Rent(chunk.VoxelCount); + int[] queue = ArrayPool.Shared.Rent(chunk.VoxelCount); + Array.Clear(visited, 0, chunk.VoxelCount); + try + { + for (ushort voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + { + Int3 position = chunk.GetXyzInt3(voxelIndex); + bool isFace = direction.X < 0 && position.X == 0 || + direction.X > 0 && position.X == chunk.Width - 1 || + direction.Y < 0 && position.Y == 0 || + direction.Y > 0 && position.Y == chunk.Height - 1 || + direction.Z < 0 && position.Z == 0 || + direction.Z > 0 && position.Z == chunk.Depth - 1; + if (!isFace || visited[voxelIndex]) + continue; + + int roomId = chunk.VoxelRoomMap[voxelIndex]; + if (roomId == VoxelClassification.RoomSolid || + roomId == VoxelClassification.RoomVoid) + continue; + + Int3 neighborPosition = (position + direction + neighbor.Dimensions) % + neighbor.Dimensions; + int neighborRoom = neighbor.VoxelRoomMap[neighbor.GetIndex(neighborPosition)]; + if (neighborRoom == VoxelClassification.RoomSolid) + continue; + + var queuedCount = 0; + visited[voxelIndex] = true; + queue[queuedCount++] = voxelIndex; + var hasGas = false; + for (var queuedIndex = 0; queuedIndex < queuedCount; queuedIndex++) + { + int componentVoxel = queue[queuedIndex]; + hasGas |= HasGasAtVoxel(chunk, (ushort)componentVoxel); + int x = componentVoxel % chunk.Width; + int yz = componentVoxel / chunk.Width; + int y = yz % chunk.Height; + int z = yz / chunk.Height; + if (x > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - 1, visited, queue, ref queuedCount); + if (x + 1 < chunk.Width) + TryEnqueuePassableVoxel(chunk, componentVoxel + 1, visited, queue, ref queuedCount); + if (y > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - chunk.Width, + visited, queue, ref queuedCount); + if (y + 1 < chunk.Height) + TryEnqueuePassableVoxel(chunk, componentVoxel + chunk.Width, + visited, queue, ref queuedCount); + int layerSize = chunk.Width * chunk.Height; + if (z > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - layerSize, + visited, queue, ref queuedCount); + if (z + 1 < chunk.Depth) + TryEnqueuePassableVoxel(chunk, componentVoxel + layerSize, + visited, queue, ref queuedCount); + } + + if (!hasGas) + continue; + if (!wakePlan.TryGetValue(chunk, out SortedSet? componentSeeds)) + { + componentSeeds = []; + wakePlan.Add(chunk, componentSeeds); + } + + componentSeeds.Add(voxelIndex); + } + } + finally + { + ArrayPool.Shared.Return(queue); + ArrayPool.Shared.Return(visited); + } + } + + private static void TryEnqueuePassableVoxel(AtmosChunk chunk, int voxelIndex, + bool[] visited, int[] queue, ref int queuedCount) + { + if (visited[voxelIndex]) + return; + + int roomId = chunk.VoxelRoomMap[voxelIndex]; + if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) + return; + + visited[voxelIndex] = true; + queue[queuedCount++] = voxelIndex; + } + + private static void ValidateBoundaryWakePlan( + Dictionary> wakePlan) + { + foreach ((AtmosChunk chunk, SortedSet requestedVoxels) in wakePlan) + { + bool[] included = ArrayPool.Shared.Rent(chunk.VoxelCount); + int[] queue = ArrayPool.Shared.Rent(chunk.VoxelCount); + Array.Clear(included, 0, chunk.VoxelCount); + var activeRooms = new HashSet(); + try + { + if (chunk.IsAwake || chunk.WasAutomaticallySlept) + { + for (var roomIndex = 0; roomIndex < chunk.ActiveRoomCount; roomIndex++) + { + int activeRoom = chunk.ActiveRoomIds[roomIndex]; + if (!HasPassableRoomVoxel(chunk, activeRoom)) + continue; + activeRooms.Add(activeRoom); + IncludeRoomClosure(chunk, activeRoom, included, queue); + } + } + + foreach (ushort requestedVoxel in requestedVoxels) + { + if (included[requestedVoxel]) + continue; + + int requestedRoom = chunk.VoxelRoomMap[requestedVoxel]; + if (activeRooms.Add(requestedRoom) && activeRooms.Count > chunk.MaxActiveRooms) + { + throw new InvalidOperationException( + "Opening a chunk boundary would exceed an adjacent chunk's active-room capacity."); + } + + IncludeRoomClosure(chunk, requestedRoom, included, queue); + } + } + finally + { + ArrayPool.Shared.Return(queue); + ArrayPool.Shared.Return(included); + } + } + } + + private static void IncludeRoomClosure(AtmosChunk chunk, int roomId, + bool[] included, int[] queue) + { + var queuedCount = 0; + for (var voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + { + if (!included[voxelIndex] && chunk.VoxelRoomMap[voxelIndex] == roomId) + { + included[voxelIndex] = true; + queue[queuedCount++] = voxelIndex; + } + } + + for (var queuedIndex = 0; queuedIndex < queuedCount; queuedIndex++) + { + int componentVoxel = queue[queuedIndex]; + int x = componentVoxel % chunk.Width; + int yz = componentVoxel / chunk.Width; + int y = yz % chunk.Height; + int z = yz / chunk.Height; + if (x > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - 1, + included, queue, ref queuedCount); + if (x + 1 < chunk.Width) + TryEnqueuePassableVoxel(chunk, componentVoxel + 1, + included, queue, ref queuedCount); + if (y > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - chunk.Width, + included, queue, ref queuedCount); + if (y + 1 < chunk.Height) + TryEnqueuePassableVoxel(chunk, componentVoxel + chunk.Width, + included, queue, ref queuedCount); + int layerSize = chunk.Width * chunk.Height; + if (z > 0) + TryEnqueuePassableVoxel(chunk, componentVoxel - layerSize, + included, queue, ref queuedCount); + if (z + 1 < chunk.Depth) + TryEnqueuePassableVoxel(chunk, componentVoxel + layerSize, + included, queue, ref queuedCount); + } + } + + private static void ApplyBoundaryWakePlan( + Dictionary> wakePlan) + { + foreach ((AtmosChunk chunk, SortedSet requestedVoxels) in wakePlan + .OrderBy(static pair => pair.Key.GridPosition.X) + .ThenBy(static pair => pair.Key.GridPosition.Y) + .ThenBy(static pair => pair.Key.GridPosition.Z)) + { + foreach (ushort voxelIndex in requestedVoxels) + chunk.WakeVoxel(voxelIndex); + } + } + + private static bool HasGasAtVoxel(AtmosChunk chunk, ushort voxelIndex) + { + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + if (chunk.ActiveGases[gas].Moles[voxelIndex] > 0f) + return true; + } + + return false; } private static ushort GetValidatedVoxelIndex(AtmosChunk chunk, int x, int y, int z) @@ -673,4 +1407,4 @@ private static void ValidateGasInjection(int gasId, float moles, float temperatu "Temperature must be nonnegative and finite."); } } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs index eb75e5c..1e7d2b3 100644 --- a/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs +++ b/src/Numos.CoreSim/AtmosKernel.GasMixtures.cs @@ -85,8 +85,13 @@ internal float GetVoxelMixturePressure( { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); float totalMoles = GetVoxelTotalMoles(chunk, localVoxelIndex); - return AtmosSolverMath.CalculatePressure(_config, totalMoles, + float pressure = AtmosSolverMath.CalculatePressure(_config, totalMoles, chunk.Temperature[localVoxelIndex]); + if (!float.IsFinite(pressure)) + throw new InvalidOperationException( + "The voxel pressure is not representable under the current simulation configuration."); + + return pressure; } } @@ -178,10 +183,14 @@ internal void SetVoxelMixtureTemperature( lock (_stateGate) { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); - int roomId = GetGasRoomId(chunk, localVoxelIndex); - chunk.WakeRoom(roomId); + _ = GetGasRoomId(chunk, localVoxelIndex); + VoxelGasMixtureTotals totals = CalculateVoxelMixtureTotals( + chunk, + localVoxelIndex, + temperature); + chunk.WakeVoxel(localVoxelIndex); chunk.Temperature[localVoxelIndex] = temperature; - chunk.MarkChanged(); + ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); } } @@ -205,7 +214,7 @@ internal void SetVoxelMixtureMoles( gasId, moles); - chunk.WakeRoom(roomId); + chunk.WakeVoxel(localVoxelIndex); SetVoxelGasMoles(chunk, localVoxelIndex, gasId, moles); ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); } @@ -237,7 +246,7 @@ internal void AdjustVoxelMixtureMoles( gasId, moles); - chunk.WakeRoom(roomId); + chunk.WakeVoxel(localVoxelIndex); SetVoxelGasMoles(chunk, localVoxelIndex, gasId, moles); ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); } @@ -257,41 +266,14 @@ internal void AddVoxelMixtureGas( lock (_stateGate) { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); - float currentGasMoles = GetVoxelGasMoles(chunk, localVoxelIndex, gasId); - float combinedGasMoles = currentGasMoles + moles; - if (!float.IsFinite(combinedGasMoles)) - throw new InvalidOperationException("A merged gas amount exceeds the supported range."); - - VoxelGasMixtureTotals currentTotals = CalculateVoxelMixtureTotals( - chunk, - localVoxelIndex, - chunk.Temperature[localVoxelIndex]); - float currentHeatCapacity = currentTotals.HeatCapacity; - float incomingHeatCapacity = moles * AtmosSolverMath.GetMolarHeatCapacity(_config, gasId); - float combinedHeatCapacity = currentHeatCapacity + incomingHeatCapacity; - if (!float.IsFinite(combinedHeatCapacity)) - throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); - - float currentTemperature = AtmosSolverMath.GetEffectiveTemperature( - _config, chunk.Temperature[localVoxelIndex]); - float incomingTemperature = AtmosSolverMath.GetEffectiveTemperature(_config, temperature); - float mixedTemperature = combinedHeatCapacity > 0f - ? currentTemperature + - (incomingTemperature - currentTemperature) * incomingHeatCapacity / combinedHeatCapacity - : temperature; + VoxelGasAddition addition = PrepareVoxelGasAddition( + chunk, localVoxelIndex, gasId, moles, temperature); int roomId = GetGasRoomId(chunk, localVoxelIndex); - VoxelGasMixtureTotals totals = CalculateVoxelMixtureTotals( - chunk, - localVoxelIndex, - mixedTemperature, - gasId, - combinedGasMoles); - - chunk.WakeRoom(roomId); - SetVoxelGasMoles(chunk, localVoxelIndex, gasId, combinedGasMoles); - chunk.Temperature[localVoxelIndex] = mixedTemperature; - ApplyVoxelMixtureTotals(chunk, localVoxelIndex, totals); + chunk.WakeVoxel(localVoxelIndex); + SetVoxelGasMoles(chunk, localVoxelIndex, gasId, addition.CombinedGasMoles); + chunk.Temperature[localVoxelIndex] = addition.MixedTemperature; + ApplyVoxelMixtureTotals(chunk, localVoxelIndex, addition.Totals); } } @@ -304,7 +286,7 @@ internal void ClearVoxelMixture( { var chunk = GetMixtureChunk(position, generation, localVoxelIndex); int roomId = GetGasRoomId(chunk, localVoxelIndex); - chunk.WakeRoom(roomId); + chunk.WakeVoxel(localVoxelIndex); for (var gas = 0; gas < chunk.ActiveGasCount; gas++) chunk.ActiveGases[gas].Moles[localVoxelIndex] = 0f; @@ -317,7 +299,7 @@ internal void ValidateVoxelMixtureMutations(VoxelGasMixtureAddress[] addresses) ArgumentNullException.ThrowIfNull(addresses); lock (_stateGate) { - var requiredRooms = new Dictionary<(Int3 Position, long Generation), HashSet>(); + var requestedVoxels = new Dictionary>(); foreach (var address in addresses) { var chunk = GetMixtureChunk( @@ -328,20 +310,18 @@ internal void ValidateVoxelMixtureMutations(VoxelGasMixtureAddress[] addresses) if (roomId == VoxelClassification.RoomSolid || roomId == VoxelClassification.RoomVoid) throw new InvalidOperationException("Solid and void voxels cannot contain a gas mixture."); - var key = (address.ChunkPosition, address.ChunkGeneration); - if (!requiredRooms.TryGetValue(key, out var rooms)) + if (!requestedVoxels.TryGetValue(chunk, out List? chunkVoxels)) { - rooms = []; - if (chunk.IsAwake) - { - for (var room = 0; room < chunk.ActiveRoomCount; room++) - rooms.Add(chunk.ActiveRoomIds[room]); - } - requiredRooms.Add(key, rooms); + chunkVoxels = []; + requestedVoxels.Add(chunk, chunkVoxels); } - rooms.Add(roomId); - if (rooms.Count > chunk.MaxActiveRooms) + chunkVoxels.Add(address.LocalVoxelIndex); + } + + foreach ((AtmosChunk chunk, List chunkVoxels) in requestedVoxels) + { + if (!chunk.CanWakeVoxels(chunkVoxels.ToArray())) { throw new InvalidOperationException( "The gas-mixture transaction would exceed the chunk's active-room capacity."); @@ -376,7 +356,7 @@ internal void ReplaceVoxelMixture( Debug.Assert(float.IsFinite(totalMoles)); Debug.Assert(float.IsFinite(totalHeatCapacity)); - chunk.WakeRoom(roomId); + chunk.WakeVoxel(localVoxelIndex); for (var gas = 0; gas < chunk.ActiveGasCount; gas++) chunk.ActiveGases[gas].Moles[localVoxelIndex] = 0f; @@ -413,6 +393,57 @@ private static float GetVoxelGasMoles(AtmosChunk chunk, ushort localVoxelIndex, return 0f; } + private VoxelGasAddition PrepareVoxelGasAddition( + AtmosChunk chunk, + ushort localVoxelIndex, + int gasId, + float moles, + float temperature) + { + float currentGasMoles = GetVoxelGasMoles(chunk, localVoxelIndex, gasId); + float combinedGasMoles = currentGasMoles + moles; + if (!float.IsFinite(combinedGasMoles)) + throw new InvalidOperationException("A merged gas amount exceeds the supported range."); + + VoxelGasMixtureTotals currentTotals = CalculateVoxelMixtureTotals( + chunk, + localVoxelIndex, + chunk.Temperature[localVoxelIndex]); + float incomingHeatCapacity = moles * AtmosSolverMath.GetMolarHeatCapacity(_config, gasId); + float combinedHeatCapacity = currentTotals.HeatCapacity + incomingHeatCapacity; + if (!float.IsFinite(combinedHeatCapacity)) + throw new InvalidOperationException("The mixture's heat capacity exceeds the supported range."); + + float mixedTemperature; + if (currentTotals.HeatCapacity > 0f) + { + float currentTemperature = AtmosSolverMath.GetEffectiveTemperature( + _config, chunk.Temperature[localVoxelIndex]); + float incomingTemperature = AtmosSolverMath.GetEffectiveTemperature(_config, temperature); + double mixed = ((double)currentTemperature * currentTotals.HeatCapacity + + (double)incomingTemperature * incomingHeatCapacity) / + combinedHeatCapacity; + mixedTemperature = (float)mixed; + } + else + { + // Preserve the public injection contract: an empty voxel adopts the raw incoming value, including + // zero or a positive subnormal. Derived pressure uses the configured effective fallback as usual. + mixedTemperature = temperature; + } + + if (!float.IsFinite(mixedTemperature) || mixedTemperature < 0f) + throw new InvalidOperationException("The mixture's temperature exceeds the supported range."); + + VoxelGasMixtureTotals totals = CalculateVoxelMixtureTotals( + chunk, + localVoxelIndex, + mixedTemperature, + gasId, + combinedGasMoles); + return new VoxelGasAddition(combinedGasMoles, mixedTemperature, totals); + } + private static void SetVoxelGasMoles(AtmosChunk chunk, ushort localVoxelIndex, int gasId, float moles) { for (var gas = 0; gas < chunk.ActiveGasCount; gas++) @@ -438,6 +469,9 @@ private static int GetGasRoomId(AtmosChunk chunk, ushort localVoxelIndex) if (chunk.IsAwake) { + if (chunk.IsVoxelActive(localVoxelIndex)) + return roomId; + for (var room = 0; room < chunk.ActiveRoomCount; room++) { if (chunk.ActiveRoomIds[room] == roomId) @@ -524,4 +558,9 @@ private AtmosChunk GetMixtureChunk(Int3 position, long generation, ushort localV private readonly record struct VoxelGasMixtureTotals( float HeatCapacity, float Pressure); -} \ No newline at end of file + + private readonly record struct VoxelGasAddition( + float CombinedGasMoles, + float MixedTemperature, + VoxelGasMixtureTotals Totals); +} diff --git a/src/Numos.CoreSim/AtmosKernel.cs b/src/Numos.CoreSim/AtmosKernel.cs index 22f4fec..a20c3af 100644 --- a/src/Numos.CoreSim/AtmosKernel.cs +++ b/src/Numos.CoreSim/AtmosKernel.cs @@ -10,6 +10,7 @@ namespace Numos.CoreSim; internal sealed partial class AtmosKernel : IDisposable, IAtmosSolverWorld { private readonly ConcurrentDictionary _chunkMap = new(); + private readonly HashSet _processedAggregateRootPairs = []; private readonly object _stateGate = new(); private readonly AtmosSolverConfigSnapshot _tickConfig = new(); private readonly AtmosSolverPipeline _solverPipeline; @@ -17,7 +18,10 @@ internal sealed partial class AtmosKernel : IDisposable, IAtmosSolverWorld private float _accumulator; private long _chunkCollectionRevision; private AtmosConfig _config = new(); + private bool _hasConfigurationFingerprint; private bool _isTickExecuting; + private ulong _lastConfigurationFingerprint; + private bool _solverPipelineInvalidationPending; /// /// High-resolution timestamp ticks spent processing boundary flow since the latest elapsed-time update. @@ -56,6 +60,23 @@ private void TickSimulation(AtmosChunk[] chunks) try { _tickConfig.Capture(_config); + bool configurationChanged = _hasConfigurationFingerprint && + _lastConfigurationFingerprint != _tickConfig.ConfigurationFingerprint; + if (!_hasConfigurationFingerprint || configurationChanged) + { + ValidateAndRefreshConfiguredState(chunks); + _lastConfigurationFingerprint = _tickConfig.ConfigurationFingerprint; + _hasConfigurationFingerprint = true; + } + + if (configurationChanged || _solverPipelineInvalidationPending) + { + foreach (var chunk in chunks) + chunk.InvalidateSolverDerivedState(); + + _solverPipelineInvalidationPending = false; + } + TickCount++; foreach (var chunk in chunks) @@ -66,6 +87,23 @@ private void TickSimulation(AtmosChunk[] chunks) var context = new AtmosSolverExecutionContext(this, chunks, _tickConfig, _config, TickCount); _solverPipeline.Execute(context); + + // This coordinator deliberately runs after the complete configured pipeline, including custom + // stages registered after the built-ins. No earlier stage can therefore mutate a chunk after it + // has been projected and committed to automatic sleep for this tick. + foreach (var chunk in chunks) + { + if (_tickConfig.VoxelSnappingEnabled) + { + if (chunk.IsAwake) + chunk.VoxelAggregates.FinalizeTick( + chunk, _tickConfig, _processedAggregateRootPairs); + } + else + { + chunk.VoxelAggregates.Reset(); + } + } } finally { @@ -79,6 +117,63 @@ private void ThrowIfTickExecuting(string operation) throw new InvalidOperationException($"A solver callback cannot {operation}."); } + private void ValidateAndRefreshConfiguredState(AtmosChunk[] chunks) + { + // Validate the complete world first so a rejected live configuration cannot partially refresh caches. + foreach (var chunk in chunks) + { + for (var voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + _ = CalculateConfiguredVoxelState(chunk, (ushort)voxelIndex); + } + + foreach (var chunk in chunks) + { + bool changed = false; + for (var voxelIndex = 0; voxelIndex < chunk.VoxelCount; voxelIndex++) + { + (float heatCapacity, float pressure) = + CalculateConfiguredVoxelState(chunk, (ushort)voxelIndex); + changed |= chunk.TotalHeatCapacity[voxelIndex] != heatCapacity || + chunk.TotalPressure[voxelIndex] != pressure; + chunk.TotalHeatCapacity[voxelIndex] = heatCapacity; + chunk.TotalPressure[voxelIndex] = pressure; + } + + if (changed) + chunk.MarkChanged(); + } + } + + private (float HeatCapacity, float Pressure) CalculateConfiguredVoxelState( + AtmosChunk chunk, + ushort voxelIndex) + { + var totalMoles = 0f; + var heatCapacity = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + GasChannel channel = chunk.ActiveGases[gas]; + float moles = channel.Moles[voxelIndex]; + if (!float.IsFinite(moles) || moles < 0f) + throw new InvalidOperationException("The current gas state is outside the supported range."); + + totalMoles += moles; + heatCapacity += moles * _tickConfig.GetMolarHeatCapacityAtConstantVolume(channel.GasId); + } + + if (!float.IsFinite(totalMoles) || !float.IsFinite(heatCapacity)) + throw new InvalidOperationException("The current configuration makes a voxel total unrepresentable."); + + float pressure = AtmosSolverMath.CalculatePressure( + _tickConfig, + totalMoles, + chunk.Temperature[voxelIndex]); + if (!float.IsFinite(pressure)) + throw new InvalidOperationException("The current configuration makes a voxel pressure unrepresentable."); + + return (heatCapacity, pressure); + } + bool IAtmosSolverWorld.TryGetChunk(Int3 position, out AtmosChunk chunk) { return _chunkMap.TryGetValue(position, out chunk!); @@ -88,4 +183,4 @@ void IAtmosSolverWorld.AddBoundaryProcessingTicks(long elapsedTicks) { LastBoundaryTicks += elapsedTicks; } -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs b/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs index d237ac2..496d308 100644 --- a/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs +++ b/src/Numos.CoreSim/AtmosSolverConfigSnapshot.cs @@ -35,9 +35,7 @@ internal void Capture(AtmosConfig config) IsFinitePositive(config.DefaultMolarHeatCapacityAtConstantVolume) ? config.DefaultMolarHeatCapacityAtConstantVolume : AtmosConfigDefaults.DefaultMolarHeatCapacityAtConstantVolume; - VoxelVolume = IsFinitePositive(config.VoxelVolume) - ? config.VoxelVolume - : AtmosConfigDefaults.VoxelVolume; + VoxelVolume = Solvers.AtmosSolverMath.GetVoxelVolume(config); PressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / VoxelVolume; SaturationReferencePressure = IsFinitePositive(config.SaturationReferencePressure) ? config.SaturationReferencePressure @@ -69,12 +67,17 @@ internal void Capture(AtmosConfig config) VacuumThreshold = GetNonnegativeFinite(config.VacuumThreshold); SleepThreshold = Math.Max(0, config.SleepThreshold); SleepEpsilon = GetNonnegativeFinite(config.SleepEpsilon); + VoxelSnappingEnabled = config.VoxelSnappingEnabled; + VoxelSnapPressureRelativeEpsilon = ClampUnitInterval(config.VoxelSnapPressureRelativeEpsilon); + VoxelSnapTemperatureEpsilon = GetNonnegativeFinite(config.VoxelSnapTemperatureEpsilon); + VoxelSnapMoleFractionEpsilon = ClampUnitInterval(config.VoxelSnapMoleFractionEpsilon); ThermalConductance = IsFinitePositive(config.ThermalConductance) ? config.ThermalConductance : 0f; CondensationRateFactor = ClampUnitInterval(config.CondensationRateFactor); MaxPressureTransferFractionPerNeighbor = ClampUnitInterval(config.MaxPressureTransferFractionPerNeighbor); + ConfigurationFingerprint = CalculateConfigurationFingerprint(); } internal float DefaultTemperatureFallback { get; private set; } @@ -88,9 +91,14 @@ internal void Capture(AtmosConfig config) internal float VacuumThreshold { get; private set; } internal int SleepThreshold { get; private set; } internal float SleepEpsilon { get; private set; } + internal bool VoxelSnappingEnabled { get; private set; } + internal float VoxelSnapPressureRelativeEpsilon { get; private set; } + internal float VoxelSnapTemperatureEpsilon { get; private set; } + internal float VoxelSnapMoleFractionEpsilon { get; private set; } internal float ThermalConductance { get; private set; } internal float CondensationRateFactor { get; private set; } internal float MaxPressureTransferFractionPerNeighbor { get; private set; } + internal ulong ConfigurationFingerprint { get; private set; } internal float GetEffectiveTemperature(float storedTemperature) { @@ -137,4 +145,57 @@ private static float GetNonnegativeFinite(float value) { return float.IsFinite(value) ? MathF.Max(0f, value) : 0f; } + + private ulong CalculateConfigurationFingerprint() + { + const ulong offsetBasis = 14695981039346656037UL; + ulong hash = offsetBasis; + Add(ref hash, DefaultTemperatureFallback); + Add(ref hash, _defaultMolarHeatCapacityAtConstantVolume); + Add(ref hash, VoxelVolume); + Add(ref hash, SaturationReferencePressure); + Add(ref hash, _defaultDiffusionCoefficient); + Add(ref hash, BulkFlowCoefficient); + Add(ref hash, BulkFlowDamping); + Add(ref hash, LowPressureDeltaThreshold); + Add(ref hash, MinimumPressureTransfer); + Add(ref hash, VacuumThreshold); + Add(ref hash, SleepThreshold); + Add(ref hash, SleepEpsilon); + Add(ref hash, VoxelSnappingEnabled ? 1 : 0); + Add(ref hash, VoxelSnapPressureRelativeEpsilon); + Add(ref hash, VoxelSnapTemperatureEpsilon); + Add(ref hash, VoxelSnapMoleFractionEpsilon); + Add(ref hash, ThermalConductance); + Add(ref hash, CondensationRateFactor); + Add(ref hash, MaxPressureTransferFractionPerNeighbor); + Add(ref hash, _gasRegistryCount); + for (var gasId = 0; gasId < _gasRegistryCount; gasId++) + { + GasProperties properties = _gasRegistry[gasId]; + Add(ref hash, _molarHeatCapacitiesAtConstantVolume[gasId]); + Add(ref hash, _diffusionCoefficients[gasId]); + Add(ref hash, properties.BoilingPoint); + Add(ref hash, properties.CondensationEnabled ? 1 : 0); + Add(ref hash, properties.MolarEnthalpyOfVaporization); + } + + return hash; + } + + private static void Add(ref ulong hash, float value) + { + Add(ref hash, BitConverter.SingleToInt32Bits(value)); + } + + private static void Add(ref ulong hash, int value) + { + const ulong prime = 1099511628211UL; + uint bits = unchecked((uint)value); + for (var shift = 0; shift < 32; shift += 8) + { + hash ^= (byte)(bits >> shift); + hash *= prime; + } + } } diff --git a/src/Numos.CoreSim/AtmosSolverConstants.cs b/src/Numos.CoreSim/AtmosSolverConstants.cs index 171e4d3..5ea662f 100644 --- a/src/Numos.CoreSim/AtmosSolverConstants.cs +++ b/src/Numos.CoreSim/AtmosSolverConstants.cs @@ -22,9 +22,6 @@ internal static class AtmosSolverConstants /// Number of simulation ticks between thermodynamics passes. internal const int ThermodynamicsTickInterval = 2; - /// Per-species amount below which residual gas is discarded, in moles (mol). - internal const float MinimumTrackedMoles = 0.0001f; - /// Minimum vapor amount considered by the phase-change solver, in moles (mol). internal const float MinimumMolesForCondensation = 0.01f; -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs index fd25e6f..867bbf5 100644 --- a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs +++ b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs @@ -7,7 +7,7 @@ public struct AtmosChunkSnapshot public Int3 GridPosition; public Int3 Dimensions; - /// Detached per-voxel pressure values, in pascals (Pa). + /// Detached cached per-voxel pressure values, in pascals (Pa), at . public float[] TotalPressure; /// Detached per-voxel temperature values, in kelvins (K). diff --git a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosVoxelSnapshot.cs b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosVoxelSnapshot.cs index b9f46ca..34757ef 100644 --- a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosVoxelSnapshot.cs +++ b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosVoxelSnapshot.cs @@ -14,7 +14,7 @@ public readonly record struct VoxelGasSnapshot( /// /// Detached values for one voxel, intended for interaction details and tooltips. /// -/// Pressure in pascals (Pa). +/// Cached pressure in pascals (Pa) at the sampled chunk version. /// Temperature in kelvins (K). public readonly record struct AtmosVoxelSnapshot( AtmosChunkVersion ChunkVersion, diff --git a/src/Numos.CoreSim/GasProperties.cs b/src/Numos.CoreSim/GasProperties.cs index 70f44ec..e6789ce 100644 --- a/src/Numos.CoreSim/GasProperties.cs +++ b/src/Numos.CoreSim/GasProperties.cs @@ -53,6 +53,9 @@ public struct GasProperties /// /// Dimensionless fraction of the per-species mole imbalance mixed per simulation tick. /// - /// Values are clamped to [0, 1]; non-finite values disable diffusion for this species. + /// + /// Values are normalized to [0, 1] and the explicit face update caps the effective fraction at 0.5; + /// non-finite values disable diffusion for this species. + /// public float DiffusionCoefficient; -} \ No newline at end of file +} diff --git a/src/Numos.CoreSim/Solvers/AdvectionSolver.cs b/src/Numos.CoreSim/Solvers/AdvectionSolver.cs index 949e830..5e16916 100644 --- a/src/Numos.CoreSim/Solvers/AdvectionSolver.cs +++ b/src/Numos.CoreSim/Solvers/AdvectionSolver.cs @@ -49,20 +49,26 @@ private static void Advect(AtmosChunk chunk, AtmosSolverConfigSnapshot config, var maximumPressureDelta = 0f; if (chunk.ActiveGasCount > 0) { + bool skipStableAggregateEdges = config.VoxelSnappingEnabled && + chunk.VoxelAggregates.IsMaterializedStateCurrent(chunk); RefreshPressureAndHeatCapacity(chunk, config); ProcessActiveVoxels(chunk, config, boundaryBuffer, ref boundaryEventCount, - ref maximumPressureDelta); + ref maximumPressureDelta, skipStableAggregateEdges); } - UpdateSleepState(chunk, config, maximumPressureDelta); + // Snap-assisted sleep is finalized after every configured solver stage. Keeping the legacy decision + // here when projection is disabled preserves its established pressure-only behavior. + if (!config.VoxelSnappingEnabled) + UpdateSleepState(chunk, config, maximumPressureDelta); } private static void ProcessActiveVoxels(AtmosChunk chunk, AtmosSolverConfigSnapshot config, - BoundaryFlowEvent[] boundaryBuffer, ref int boundaryEventCount, ref float maximumPressureDelta) + BoundaryFlowEvent[] boundaryBuffer, ref int boundaryEventCount, ref float maximumPressureDelta, + bool skipStableAggregateEdges) { int activeGasCount = chunk.ActiveGasCount; int moleDeltaLength = activeGasCount * chunk.VoxelCount; - float[] moleDeltas = ArrayPool.Shared.Rent(moleDeltaLength); + double[] moleDeltas = ArrayPool.Shared.Rent(moleDeltaLength); double[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); float[] scheduledOutflows = ArrayPool.Shared.Rent(activeGasCount * chunk.VoxelCount); Array.Clear(moleDeltas, 0, moleDeltaLength); @@ -74,6 +80,11 @@ private static void ProcessActiveVoxels(AtmosChunk chunk, AtmosSolverConfigSnaps for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + Int3 position = chunk.GetXyzInt3(voxelIndex); + // Empty and vacuum boundary voxels still have to publish their edge. Otherwise an awake low- + // pressure endpoint cannot discover and wake a higher-pressure sleeping neighbor. + TryAppendBoundaryEvent(chunk, position, voxelIndex, boundaryBuffer, + ref boundaryEventCount); float currentPressure = chunk.TotalPressure[voxelIndex]; if (currentPressure < config.VacuumThreshold) { @@ -85,49 +96,59 @@ private static void ProcessActiveVoxels(AtmosChunk chunk, AtmosSolverConfigSnaps if (totalMoles <= 0f) continue; - Int3 position = chunk.GetXyzInt3(voxelIndex); ProcessNeighbors(chunk, config, position, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); - TryAppendBoundaryEvent(chunk, position, voxelIndex, boundaryBuffer, - ref boundaryEventCount); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows, + skipStableAggregateEdges); } - ApplyDeltas(chunk, config, moleDeltas, energyDeltas); + if (!ApplyDeltas(chunk, config, moleDeltas, energyDeltas)) + { + // A finite set of simultaneous inflows can have an unrepresentable float-backed result. + // Deterministically defer the whole delta batch rather than partially applying it or poisoning + // primary state, and keep legacy sleep from treating the unchanged tick as settled. + maximumPressureDelta = float.PositiveInfinity; + } } finally { ArrayPool.Shared.Return(scheduledOutflows); ArrayPool.Shared.Return(energyDeltas); - ArrayPool.Shared.Return(moleDeltas); + ArrayPool.Shared.Return(moleDeltas); } } private static void ProcessNeighbors(AtmosChunk chunk, AtmosSolverConfigSnapshot config, Int3 position, ushort voxelIndex, float currentPressure, float totalMoles, - ref float maximumPressureDelta, float[] moleDeltas, double[] energyDeltas, - float[] scheduledOutflows) + ref float maximumPressureDelta, double[] moleDeltas, double[] energyDeltas, + float[] scheduledOutflows, bool skipStableAggregateEdges) { CheckNeighbor(chunk, config, position + Int3.NegX, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows, + skipStableAggregateEdges); CheckNeighbor(chunk, config, position + Int3.PosX, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows, + skipStableAggregateEdges); CheckNeighbor(chunk, config, position + Int3.NegY, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows, + skipStableAggregateEdges); CheckNeighbor(chunk, config, position + Int3.PosY, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows, + skipStableAggregateEdges); if (chunk.Depth <= 1) return; CheckNeighbor(chunk, config, position + Int3.NegZ, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows, + skipStableAggregateEdges); CheckNeighbor(chunk, config, position + Int3.PosZ, voxelIndex, currentPressure, totalMoles, - ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows); + ref maximumPressureDelta, moleDeltas, energyDeltas, scheduledOutflows, + skipStableAggregateEdges); } private static void CheckNeighbor(AtmosChunk chunk, AtmosSolverConfigSnapshot config, Int3 neighborPosition, ushort voxelIndex, float currentPressure, float totalMoles, - ref float maximumPressureDelta, float[] moleDeltas, double[] energyDeltas, - float[] scheduledOutflows) + ref float maximumPressureDelta, double[] moleDeltas, double[] energyDeltas, + float[] scheduledOutflows, bool skipStableAggregateEdges) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; @@ -136,6 +157,9 @@ private static void CheckNeighbor(AtmosChunk chunk, AtmosSolverConfigSnapshot co int neighborRoom = chunk.VoxelRoomMap[neighborIndex]; if (neighborRoom == VoxelClassification.RoomSolid) return; + if (skipStableAggregateEdges && + chunk.VoxelAggregates.AreAggregatedTogether(voxelIndex, neighborIndex)) + return; bool isVoid = neighborRoom == VoxelClassification.RoomVoid; float neighborPressure = isVoid ? 0f : chunk.TotalPressure[neighborIndex]; @@ -158,8 +182,10 @@ private static void CheckNeighbor(AtmosChunk chunk, AtmosSolverConfigSnapshot co float neighborMoles = isVoid ? 0f : chunk.ActiveGases[gas].Moles[neighborIndex]; float moleImbalance = AtmosSolverMath.CalculateMoleImbalance( sourceMoles, sourceTemperature, neighborMoles, neighborTemperature); + float diffusionCoefficient = MathF.Min(0.5f, + config.GetDiffusionCoefficient(gasId)); float molesDiffused = moleImbalance > 0f - ? moleImbalance * config.GetDiffusionCoefficient(gasId) + ? moleImbalance * diffusionCoefficient : 0f; int outflowOffset = gas * chunk.VoxelCount + voxelIndex; @@ -185,12 +211,10 @@ private static void CheckNeighbor(AtmosChunk chunk, AtmosSolverConfigSnapshot co private static void RefreshPressureAndHeatCapacity(AtmosChunk chunk, AtmosSolverConfigSnapshot config) { - chunk.TotalPressure.Clear(); - chunk.TotalHeatCapacity.Clear(); - for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + chunk.TotalHeatCapacity[voxelIndex] = 0f; chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressure( config, GetTotalMoles(chunk, voxelIndex), chunk.Temperature[voxelIndex]); } @@ -209,42 +233,103 @@ private static void RefreshPressureAndHeatCapacity(AtmosChunk chunk, AtmosSolver } } - private static void ApplyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, - float[] moleDeltas, double[] energyDeltas) + private static bool ApplyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + double[] moleDeltas, double[] energyDeltas) { + if (!CanApplyDeltas(chunk, config, moleDeltas, energyDeltas)) + return false; + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; double oldEnergy = (double)config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]) * chunk.TotalHeatCapacity[voxelIndex]; bool stateChanged = energyDeltas[voxelIndex] != 0d; - chunk.TotalHeatCapacity[voxelIndex] = 0f; - var totalMoles = 0f; + var totalHeatCapacity = 0d; + var totalMoles = 0d; for (var gas = 0; gas < chunk.ActiveGasCount; gas++) { int offset = gas * chunk.VoxelCount; - float moleDelta = moleDeltas[offset + voxelIndex]; + double moleDelta = moleDeltas[offset + voxelIndex]; stateChanged |= moleDelta != 0f; - float moles = chunk.ActiveGases[gas].Moles[voxelIndex] + moleDelta; - if (moles < AtmosSolverConstants.MinimumTrackedMoles) - moles = 0f; + double projectedMoles = chunk.ActiveGases[gas].Moles[voxelIndex] + moleDelta; + // Per-voxel trace pruning is not conservative: a component can hold a meaningful species total + // whose uniformly materialized share is tiny in every voxel. Clamp only negative roundoff and + // retain every positive representable amount. + float moles = (float)Math.Max(0d, projectedMoles); chunk.ActiveGases[gas].Moles[voxelIndex] = moles; - chunk.TotalHeatCapacity[voxelIndex] += moles * + totalHeatCapacity += (double)moles * config.GetMolarHeatCapacityAtConstantVolume(chunk.ActiveGases[gas].GasId); totalMoles += moles; } - if (stateChanged && chunk.TotalHeatCapacity[voxelIndex] > 0f) + chunk.TotalHeatCapacity[voxelIndex] = (float)totalHeatCapacity; + if (stateChanged && totalHeatCapacity > 0d) { chunk.Temperature[voxelIndex] = MathF.Max(0f, (float)((oldEnergy + energyDeltas[voxelIndex]) / - chunk.TotalHeatCapacity[voxelIndex])); + totalHeatCapacity)); } chunk.TotalPressure[voxelIndex] = AtmosSolverMath.CalculatePressure( - config, totalMoles, chunk.Temperature[voxelIndex]); + config, (float)totalMoles, chunk.Temperature[voxelIndex]); } + + return true; + } + + private static bool CanApplyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + double[] moleDeltas, double[] energyDeltas) + { + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + double oldEnergy = (double)config.GetEffectiveTemperature(chunk.Temperature[voxelIndex]) * + chunk.TotalHeatCapacity[voxelIndex]; + bool stateChanged = energyDeltas[voxelIndex] != 0d; + var totalHeatCapacity = 0d; + var totalMoles = 0d; + + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + int offset = gas * chunk.VoxelCount; + double moleDelta = moleDeltas[offset + voxelIndex]; + stateChanged |= moleDelta != 0d; + double projectedMoles = Math.Max(0d, + chunk.ActiveGases[gas].Moles[voxelIndex] + moleDelta); + float storedMoles = (float)projectedMoles; + if (!float.IsFinite(storedMoles)) + return false; + + totalMoles += storedMoles; + totalHeatCapacity += (double)storedMoles * + config.GetMolarHeatCapacityAtConstantVolume( + chunk.ActiveGases[gas].GasId); + } + + float storedTotalMoles = (float)totalMoles; + float storedHeatCapacity = (float)totalHeatCapacity; + if (!float.IsFinite(storedTotalMoles) || !float.IsFinite(storedHeatCapacity)) + return false; + + float projectedTemperature = chunk.Temperature[voxelIndex]; + if (stateChanged && totalHeatCapacity > 0d) + { + double targetTemperature = Math.Max(0d, + (oldEnergy + energyDeltas[voxelIndex]) / totalHeatCapacity); + projectedTemperature = (float)targetTemperature; + if (!float.IsFinite(projectedTemperature)) + return false; + } + + float projectedPressure = AtmosSolverMath.CalculatePressure( + config, storedTotalMoles, projectedTemperature); + if (!float.IsFinite(projectedPressure)) + return false; + } + + return true; } private static void TryAppendBoundaryEvent(AtmosChunk chunk, Int3 position, ushort voxelIndex, @@ -285,9 +370,10 @@ private static void UpdateSleepState(AtmosChunk chunk, AtmosSolverConfigSnapshot return; } - chunk.SleepTimer++; + if (chunk.SleepTimer < int.MaxValue) + chunk.SleepTimer++; if (chunk.SleepTimer > config.SleepThreshold) - chunk.Sleep(); + chunk.SleepAutomatically(); } } diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs b/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs index 98d3400..bdc995b 100644 --- a/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs +++ b/src/Numos.CoreSim/Solvers/AtmosSolverMath.cs @@ -25,9 +25,13 @@ internal static float GetMolarHeatCapacity(AtmosConfig config, int gasId) internal static float GetVoxelVolume(AtmosConfig config) { - return IsFinitePositive(config.VoxelVolume) + float volume = IsFinitePositive(config.VoxelVolume) ? config.VoxelVolume : AtmosConfigDefaults.VoxelVolume; + float pressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / volume; + return IsFinitePositive(pressurePerMoleKelvin) + ? volume + : AtmosConfigDefaults.VoxelVolume; } internal static float GetEffectiveTemperature(AtmosConfig config, float storedTemperature) @@ -42,14 +46,17 @@ internal static float GetEffectiveTemperature(AtmosConfig config, float storedTe internal static float CalculatePressure(AtmosConfig config, float moles, float temperature) { - return MathF.Max(0f, moles) * GetEffectiveTemperature(config, temperature) * - (AtmosPhysicalConstants.MolarGasConstant / GetVoxelVolume(config)); + double pressure = (double)MathF.Max(0f, moles) * GetEffectiveTemperature(config, temperature) * + (AtmosPhysicalConstants.MolarGasConstant / GetVoxelVolume(config)); + return (float)pressure; } internal static float CalculatePressure(AtmosSolverConfigSnapshot config, float moles, float temperature) { Debug.Assert(float.IsFinite(moles) && moles >= 0f); - return moles * config.GetEffectiveTemperature(temperature) * config.PressurePerMoleKelvin; + double pressure = (double)moles * config.GetEffectiveTemperature(temperature) * + config.PressurePerMoleKelvin; + return (float)pressure; } internal static float PressureToMoles(AtmosSolverConfigSnapshot config, float pressure, float temperature) diff --git a/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs b/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs index 93dd77b..741a299 100644 --- a/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs +++ b/src/Numos.CoreSim/Solvers/AtmosSolverPipeline.cs @@ -13,7 +13,7 @@ internal AtmosSolverPipeline(Func createDefaults, IDisposable? def { _createDefaults = createDefaults; _defaultLifetime = defaultLifetime; - Reset(); + _ = Reset(); } internal int Count => _steps.Count; @@ -54,20 +54,52 @@ internal bool Unregister(string name) return true; } - internal bool SetEnabled(string name, bool enabled) + internal bool SetEnabled(string name, bool enabled, out bool becameEnabled) { + becameEnabled = false; int index = IndexOf(name); if (index < 0) return false; + if (_steps[index].Enabled == enabled) + return true; + _steps[index].Enabled = enabled; + becameEnabled = enabled; return true; } - internal void Reset() + internal bool Reset() { + SolverStep[] defaults = _createDefaults(); + bool restoresEnabledDefault = defaults.Any(expected => !_steps.Any(current => + string.Equals(current.Name, expected.Name, StringComparison.Ordinal) && + current.Kind == expected.Kind && + current.Enabled)); + + bool alreadyDefault = _steps.Count == defaults.Length; + if (alreadyDefault) + { + for (var index = 0; index < defaults.Length; index++) + { + SolverStep current = _steps[index]; + SolverStep expected = defaults[index]; + if (!string.Equals(current.Name, expected.Name, StringComparison.Ordinal) || + current.Kind != expected.Kind || + current.Enabled != expected.Enabled) + { + alreadyDefault = false; + break; + } + } + } + + if (alreadyDefault) + return false; + _steps.Clear(); - _steps.AddRange(_createDefaults()); + _steps.AddRange(defaults); + return restoresEnabledDefault; } internal void Execute(AtmosSolverExecutionContext context) @@ -108,4 +140,4 @@ internal enum SolverStepKind Dangerous } -internal readonly record struct SolverStepInfo(string Name, bool Enabled, SolverStepKind Kind); \ No newline at end of file +internal readonly record struct SolverStepInfo(string Name, bool Enabled, SolverStepKind Kind); diff --git a/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs b/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs index 4bbcf89..ddd3405 100644 --- a/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs +++ b/src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Diagnostics; using Numos.CoreSim.Datatypes.Events; using Numos.CoreSim.Datatypes.Primitives; @@ -65,12 +66,49 @@ private static void TryFlowToNeighbor(AtmosSolverExecutionContext context, Atmos float sourcePressure = sourceChunk.TotalPressure[sourceIndex]; bool isVoid = neighborRoom == VoxelClassification.RoomVoid; - float neighborPressure = isVoid ? 0f : neighborChunk.TotalPressure[neighborIndex]; + float neighborPressure = 0f; + if (!isVoid) + { + // Inactive connected components have deliberately non-authoritative caches. Boundary transfer + // can target one while the chunk itself is awake, so derive both caches from primary state before + // solving the edge or mixing incoming energy. + neighborPressure = AtmosSolverMath.CalculatePressureAtVoxel( + context.TickConfig, neighborChunk, neighborIndex); + } float pressureDelta = sourcePressure - neighborPressure; float bulkPressureTransfer = pressureDelta > 0f ? AtmosSolverMath.CalculateBulkPressureTransfer(context.TickConfig, pressureDelta, sourcePressure) : 0f; + bool directedTransfer = HasDirectedTransfer( + context.TickConfig, sourceChunk, sourceIndex, sourcePressure, + neighborChunk, neighborIndex, neighborPressure, isVoid); + if (directedTransfer && !isVoid && !neighborChunk.IsVoxelActive(neighborIndex) && + !CanWakeVoxel(neighborChunk, neighborIndex)) + { + // Room capacity is an execution limit, not a reason to commit a partial species transfer and throw. + // Deterministically defer this edge until the receiving component can be activated. Keep the event- + // producing endpoint awake so the edge is retried after the target's active-room set changes. + KeepAwakeForRetry(sourceChunk); + return; + } + + // Boundary events are emitted by awake endpoints. If that endpoint is the low-pressure side, wake an + // actionable sleeping/inactive source on the other side so it can emit the conservative directed flow + // on the next tick. Composition-only counter-diffusion uses the same rule. + if (!isVoid && !neighborChunk.IsVoxelActive(neighborIndex) && + HasActionableReverseTransfer(context.TickConfig, sourceChunk, sourceIndex, sourcePressure, + neighborChunk, neighborIndex, neighborPressure)) + { + if (CanWakeVoxel(neighborChunk, neighborIndex)) + neighborChunk.WakeVoxel(neighborIndex); + else + KeepAwakeForRetry(sourceChunk); + } + + if (!directedTransfer) + return; + float totalMoles = GetTotalMoles(sourceChunk, sourceIndex); if (totalMoles <= 0f) return; @@ -79,18 +117,47 @@ private static void TryFlowToNeighbor(AtmosSolverExecutionContext context, Atmos totalMoles, bulkPressureTransfer); } - private static void TransferSpecies(AtmosSolverExecutionContext context, AtmosChunk sourceChunk, - ushort sourceIndex, AtmosChunk neighborChunk, ushort neighborIndex, bool isVoid, - float totalMoles, float bulkPressureTransfer) + private static bool CanWakeVoxel(AtmosChunk chunk, ushort voxelIndex) { - AtmosSolverConfigSnapshot config = context.TickConfig; + Span requestedVoxel = stackalloc ushort[1]; + requestedVoxel[0] = voxelIndex; + return chunk.CanWakeVoxels(requestedVoxel); + } + + private static float GetBoundaryDiffusionCoefficient( + AtmosSolverConfigSnapshot config, + int gasId) + { + // Boundary edges are processed sequentially from events emitted by both endpoints. Limiting one + // directed pass to half the pair imbalance prevents the second event from consuming a freshly moved + // species back across the same edge when a configured coefficient approaches one. + return MathF.Min(0.5f, config.GetDiffusionCoefficient(gasId)); + } + + private static bool HasDirectedTransfer( + AtmosSolverConfigSnapshot config, + AtmosChunk sourceChunk, + ushort sourceIndex, + float sourcePressure, + AtmosChunk neighborChunk, + ushort neighborIndex, + float neighborPressure, + bool isVoid) + { + float totalMoles = GetTotalMoles(sourceChunk, sourceIndex); + if (totalMoles <= 0f) + return false; + + float pressureDelta = sourcePressure - neighborPressure; + float bulkPressureTransfer = pressureDelta > 0f + ? AtmosSolverMath.CalculateBulkPressureTransfer(config, pressureDelta, sourcePressure) + : 0f; float sourceTemperature = config.GetEffectiveTemperature(sourceChunk.Temperature[sourceIndex]); float neighborTemperature = isVoid ? 0f : config.GetEffectiveTemperature(neighborChunk.Temperature[neighborIndex]); float advectedMoles = AtmosSolverMath.PressureToMoles( config, bulkPressureTransfer, sourceTemperature); - var movedGas = false; for (var gas = 0; gas < sourceChunk.ActiveGasCount; gas++) { @@ -101,39 +168,227 @@ private static void TransferSpecies(AtmosSolverExecutionContext context, AtmosCh sourceMoles, sourceTemperature, GetGasMoles(neighborChunk, neighborIndex, gasId, isVoid), neighborTemperature); float molesDiffused = moleImbalance > 0f - ? moleImbalance * config.GetDiffusionCoefficient(gasId) + ? moleImbalance * GetBoundaryDiffusionCoefficient(config, gasId) : 0f; - float molesToMove = MathF.Min(sourceMoles, molesAdvected + molesDiffused); - if (molesToMove <= 0f) + if (MathF.Min(sourceMoles, molesAdvected + molesDiffused) > 0f) + return true; + } + + return false; + } + + private static bool HasActionableReverseTransfer( + AtmosSolverConfigSnapshot config, + AtmosChunk sourceChunk, + ushort sourceIndex, + float sourcePressure, + AtmosChunk neighborChunk, + ushort neighborIndex, + float neighborPressure) + { + float reversePressureDelta = neighborPressure - sourcePressure; + if (reversePressureDelta > 0f && + AtmosSolverMath.CalculateBulkPressureTransfer( + config, reversePressureDelta, neighborPressure) > 0f) + return true; + + float neighborTemperature = config.GetEffectiveTemperature(neighborChunk.Temperature[neighborIndex]); + float sourceTemperature = config.GetEffectiveTemperature(sourceChunk.Temperature[sourceIndex]); + for (var gas = 0; gas < neighborChunk.ActiveGasCount; gas++) + { + int gasId = neighborChunk.ActiveGases[gas].GasId; + if (GetBoundaryDiffusionCoefficient(config, gasId) <= 0f) continue; - float transferredHeatCapacity = molesToMove * - config.GetMolarHeatCapacityAtConstantVolume(gasId); - sourceChunk.ActiveGases[gas].Moles[sourceIndex] = sourceMoles - molesToMove; - sourceChunk.TotalHeatCapacity[sourceIndex] = MathF.Max(0f, - sourceChunk.TotalHeatCapacity[sourceIndex] - transferredHeatCapacity); - movedGas = true; + float neighborMoles = neighborChunk.ActiveGases[gas].Moles[neighborIndex]; + float sourceMoles = GetGasMoles(sourceChunk, sourceIndex, gasId, false); + if (AtmosSolverMath.CalculateMoleImbalance( + neighborMoles, neighborTemperature, sourceMoles, sourceTemperature) > 0f) + return true; + } + + return false; + } - if (isVoid) + private static void TransferSpecies(AtmosSolverExecutionContext context, AtmosChunk sourceChunk, + ushort sourceIndex, AtmosChunk neighborChunk, ushort neighborIndex, bool isVoid, + float totalMoles, float bulkPressureTransfer) + { + AtmosSolverConfigSnapshot config = context.TickConfig; + float sourceTemperature = config.GetEffectiveTemperature(sourceChunk.Temperature[sourceIndex]); + float neighborTemperature = isVoid + ? 0f + : config.GetEffectiveTemperature(neighborChunk.Temperature[neighborIndex]); + float advectedMoles = AtmosSolverMath.PressureToMoles( + config, bulkPressureTransfer, sourceTemperature); + float[] plannedMoves = ArrayPool.Shared.Rent(sourceChunk.ActiveGasCount); + Array.Clear(plannedMoves, 0, sourceChunk.ActiveGasCount); + try + { + var movedGas = false; + for (var gas = 0; gas < sourceChunk.ActiveGasCount; gas++) + { + int gasId = sourceChunk.ActiveGases[gas].GasId; + float sourceMoles = sourceChunk.ActiveGases[gas].Moles[sourceIndex]; + float molesAdvected = advectedMoles * (sourceMoles / totalMoles); + float moleImbalance = AtmosSolverMath.CalculateMoleImbalance( + sourceMoles, sourceTemperature, + GetGasMoles(neighborChunk, neighborIndex, gasId, isVoid), neighborTemperature); + float molesDiffused = moleImbalance > 0f + ? moleImbalance * GetBoundaryDiffusionCoefficient(config, gasId) + : 0f; + float molesToMove = MathF.Min(sourceMoles, molesAdvected + molesDiffused); + if (molesToMove <= 0f) + continue; + + plannedMoves[gas] = molesToMove; + movedGas = true; + } + + if (!movedGas) + return; + + TargetTransferState targetState = default; + if (!isVoid && !TryPrepareTargetTransfer( + config, sourceChunk, plannedMoves, sourceTemperature, + neighborChunk, neighborIndex, out targetState)) + { + // Float-backed primary state cannot represent this otherwise finite combined result. Defer the + // complete edge so no species is subtracted before a later species or cache overflows. + KeepAwakeForRetry(sourceChunk); + return; + } + + if (!isVoid) + neighborChunk.WakeVoxel(neighborIndex); + + for (var gas = 0; gas < sourceChunk.ActiveGasCount; gas++) + { + float molesToMove = plannedMoves[gas]; + if (molesToMove <= 0f) + continue; + + ref float sourceMoles = ref sourceChunk.ActiveGases[gas].Moles[sourceIndex]; + sourceMoles -= molesToMove; + if (isVoid) + continue; + + int targetChannel = neighborChunk.GetOrCreateGasChannel( + sourceChunk.ActiveGases[gas].GasId); + neighborChunk.ActiveGases[targetChannel].Moles[neighborIndex] += molesToMove; + } + + sourceChunk.TotalHeatCapacity[sourceIndex] = AtmosSolverMath.CalculateHeatCapacityAtVoxel( + config, sourceChunk, sourceIndex); + if (sourceChunk.TotalHeatCapacity[sourceIndex] > 0f) + sourceChunk.Temperature[sourceIndex] = sourceTemperature; + sourceChunk.TotalPressure[sourceIndex] = AtmosSolverMath.CalculatePressureAtVoxel( + config, sourceChunk, sourceIndex); + + if (!isVoid) + { + neighborChunk.Temperature[neighborIndex] = targetState.Temperature; + // Derive caches from the committed float channels in their real storage order. The double + // preflight proves representability, but its final rounded total can differ from an ordinary + // float channel reduction when tiny species are added to a very large existing mixture. + neighborChunk.TotalHeatCapacity[neighborIndex] = + AtmosSolverMath.CalculateHeatCapacityAtVoxel(config, neighborChunk, neighborIndex); + neighborChunk.TotalPressure[neighborIndex] = + AtmosSolverMath.CalculatePressureAtVoxel(config, neighborChunk, neighborIndex); + neighborChunk.MarkChanged(); + } + + // Intra-chunk sleep detection cannot see cross-chunk gradients. A boundary transfer therefore keeps + // its source eligible for the next tick, just as injection keeps the target awake. + KeepAwakeForRetry(sourceChunk); + sourceChunk.MarkChanged(); + } + finally + { + ArrayPool.Shared.Return(plannedMoves); + } + } + + private static bool TryPrepareTargetTransfer( + AtmosSolverConfigSnapshot config, + AtmosChunk sourceChunk, + float[] plannedMoves, + float sourceTemperature, + AtmosChunk targetChunk, + ushort targetIndex, + out TargetTransferState state) + { + var targetTotalMoles = 0d; + var targetHeatCapacity = 0d; + for (var gas = 0; gas < targetChunk.ActiveGasCount; gas++) + { + float moles = targetChunk.ActiveGases[gas].Moles[targetIndex]; + if (!float.IsFinite(moles) || moles < 0f) + { + state = default; + return false; + } + + targetTotalMoles += moles; + targetHeatCapacity += (double)moles * + config.GetMolarHeatCapacityAtConstantVolume( + targetChunk.ActiveGases[gas].GasId); + } + + var incomingMoles = 0d; + var incomingHeatCapacity = 0d; + for (var gas = 0; gas < sourceChunk.ActiveGasCount; gas++) + { + float molesToMove = plannedMoves[gas]; + if (molesToMove <= 0f) continue; - if (!neighborChunk.IsAwake) - neighborChunk.WakeRoom(neighborChunk.VoxelRoomMap[neighborIndex]); - GasInjectionSolver.InjectDuringTick(neighborChunk, neighborIndex, gasId, molesToMove, - sourceTemperature, config); + + int gasId = sourceChunk.ActiveGases[gas].GasId; + float combinedSpeciesMoles = GetGasMoles(targetChunk, targetIndex, gasId, false) + + molesToMove; + if (!float.IsFinite(combinedSpeciesMoles)) + { + state = default; + return false; + } + + incomingMoles += molesToMove; + incomingHeatCapacity += (double)molesToMove * + config.GetMolarHeatCapacityAtConstantVolume(gasId); } - if (!movedGas) - return; + float storedTotalMoles = (float)(targetTotalMoles + incomingMoles); + float storedHeatCapacity = (float)(targetHeatCapacity + incomingHeatCapacity); + if (!float.IsFinite(storedTotalMoles) || !float.IsFinite(storedHeatCapacity) || + storedHeatCapacity <= 0f) + { + state = default; + return false; + } + + double mixedTemperature = targetHeatCapacity > 0d + ? config.GetEffectiveTemperature(targetChunk.Temperature[targetIndex]) + + (sourceTemperature - config.GetEffectiveTemperature(targetChunk.Temperature[targetIndex])) * + incomingHeatCapacity / (targetHeatCapacity + incomingHeatCapacity) + : sourceTemperature; + float storedTemperature = (float)mixedTemperature; + float storedPressure = AtmosSolverMath.CalculatePressure( + config, storedTotalMoles, storedTemperature); + if (!float.IsFinite(storedTemperature) || storedTemperature <= 0f || + !float.IsFinite(storedPressure)) + { + state = default; + return false; + } - if (sourceChunk.TotalHeatCapacity[sourceIndex] > 0f) - sourceChunk.Temperature[sourceIndex] = sourceTemperature; - sourceChunk.TotalPressure[sourceIndex] = AtmosSolverMath.CalculatePressure( - config, GetTotalMoles(sourceChunk, sourceIndex), sourceTemperature); - // Intra-chunk sleep detection cannot see cross-chunk gradients. A boundary transfer therefore keeps - // its source eligible for the next tick, just as injection keeps the target awake. - sourceChunk.IsAwake = true; - sourceChunk.SleepTimer = 0; - sourceChunk.MarkChanged(); + state = new TargetTransferState(storedTemperature); + return true; + } + + private static void KeepAwakeForRetry(AtmosChunk chunk) + { + chunk.IsAwake = true; + chunk.SleepTimer = 0; } private static float GetGasMoles(AtmosChunk chunk, ushort voxelIndex, int gasId, bool isVoid) @@ -167,4 +422,6 @@ private static int CompareEvents( ? comparison : left.Event.LocalVoxelIndex.CompareTo(right.Event.LocalVoxelIndex); } + + private readonly record struct TargetTransferState(float Temperature); } diff --git a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs index b59e2f9..95f645d 100644 --- a/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs +++ b/src/Numos.CoreSim/Solvers/PhaseChangeSolver.cs @@ -221,18 +221,71 @@ private static void ApplyCondensation(AtmosChunk chunk, AtmosSolverConfigSnapsho int gasIndex, ushort voxelIndex, float temperature, float remainingMoles, float condensedMoles, float molarInternalEnergyOfVaporization) { - chunk.ActiveGases[gasIndex].Moles[voxelIndex] = remainingMoles; + if (!TryPrepareCondensation(chunk, config, gasIndex, voxelIndex, temperature, + remainingMoles, condensedMoles, molarInternalEnergyOfVaporization, + out float newHeatCapacity, out float newTemperature, out float newPressure)) + { + // A supersaturated voxel still has actionable phase work. Keep retrying instead of allowing the + // unchanged materialized state to satisfy the automatic-sleep verification window. + chunk.SleepTimer = 0; + chunk.VoxelAggregates.Reset(); + return; + } - float newHeatCapacity = AtmosSolverMath.CalculateHeatCapacityAtVoxel(config, chunk, voxelIndex); + chunk.ActiveGases[gasIndex].Moles[voxelIndex] = remainingMoles; chunk.TotalHeatCapacity[voxelIndex] = newHeatCapacity; + if (newHeatCapacity > 0f) + chunk.Temperature[voxelIndex] = newTemperature; + chunk.TotalPressure[voxelIndex] = newPressure; + } + + private static bool TryPrepareCondensation(AtmosChunk chunk, AtmosSolverConfigSnapshot config, + int gasIndex, ushort voxelIndex, float temperature, float remainingMoles, + float condensedMoles, float molarInternalEnergyOfVaporization, + out float newHeatCapacity, out float newTemperature, out float newPressure) + { + var newTotalMoles = 0f; + newHeatCapacity = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + { + float moles = gas == gasIndex + ? remainingMoles + : chunk.ActiveGases[gas].Moles[voxelIndex]; + if (moles <= 0f) + continue; + + newTotalMoles += moles; + float heatCapacityContribution = moles * + config.GetMolarHeatCapacityAtConstantVolume( + chunk.ActiveGases[gas].GasId); + newHeatCapacity += heatCapacityContribution; + if (!float.IsFinite(newTotalMoles) || !float.IsFinite(heatCapacityContribution) || + !float.IsFinite(newHeatCapacity)) + { + newTemperature = 0f; + newPressure = 0f; + return false; + } + } + + newTemperature = chunk.Temperature[voxelIndex]; if (newHeatCapacity > 0f) { - // Algebraically this is (T*C_remaining + n_condensed*U_vap) / C_remaining. Dividing - // before multiplying avoids both C*T overflow and the cancellation of two large energies. - chunk.Temperature[voxelIndex] = MathF.Max(0f, - temperature + condensedMoles / newHeatCapacity * molarInternalEnergyOfVaporization); + // Algebraically this is (T*C_remaining + n_condensed*U_vap) / C_remaining. Perform + // the quotient in double so a finite projected temperature is not rejected because of + // an overflowing single-precision intermediate. + double projectedTemperature = temperature + + (double)condensedMoles / newHeatCapacity * + molarInternalEnergyOfVaporization; + newTemperature = (float)projectedTemperature; + if (!float.IsFinite(newTemperature) || newTemperature <= 0f) + { + newPressure = 0f; + return false; + } } - chunk.TotalPressure[voxelIndex] = - AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); + + newPressure = AtmosSolverMath.CalculatePressure(config, newTotalMoles, newTemperature); + return float.IsFinite(newPressure); } } diff --git a/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs b/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs index 4eb1b89..e1c5dd5 100644 --- a/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs +++ b/src/Numos.CoreSim/Solvers/ThermalBoundarySolver.cs @@ -10,6 +10,10 @@ namespace Numos.CoreSim.Solvers; internal sealed class ThermalBoundarySolver : IAtmosSolverStage { private readonly List _activeEdges = []; + private readonly List _componentAddresses = []; + private readonly List _componentMembers = []; + private readonly Dictionary _componentParents = []; + private readonly List _componentRoots = []; private readonly Dictionary _energyDeltas = []; private readonly HashSet _edges = []; private readonly Dictionary _incidentConductances = []; @@ -44,6 +48,10 @@ private void ResetWorkspace() _incidentConductances.Clear(); _activeEdges.Clear(); _energyDeltas.Clear(); + _componentAddresses.Clear(); + _componentMembers.Clear(); + _componentParents.Clear(); + _componentRoots.Clear(); } private void CollectEdges(AtmosSolverExecutionContext context) @@ -136,17 +144,186 @@ private void AccumulateEnergyDeltas() private void ApplyEnergyDeltas(AtmosSolverExecutionContext context) { - foreach (var (address, energyDelta) in _energyDeltas) + BuildThermalComponents(); + foreach (ThermalVoxelAddress root in _componentRoots) + { + _componentMembers.Clear(); + foreach (ThermalVoxelAddress address in _componentParents.Keys) + { + if (FindComponentRoot(address) == root) + _componentMembers.Add(address); + } + + _componentMembers.Sort(CompareVoxels); + _componentAddresses.Clear(); + foreach ((ThermalVoxelAddress address, double energyDelta) in _energyDeltas) + { + if (energyDelta != 0d && FindComponentRoot(address) == root) + _componentAddresses.Add(address); + } + + if (_componentAddresses.Count == 0) + continue; + _componentAddresses.Sort(CompareVoxels); + + bool representable = true; + foreach (ThermalVoxelAddress address in _componentAddresses) + { + if (TryCalculateProjectedState( + context.TickConfig, address, _states[address], _energyDeltas[address], + out _, out _)) + continue; + representable = false; + break; + } + + if (!representable) + { + KeepActiveBoundaryProducersAwake(_componentMembers); + continue; + } + + var requestedVoxels = new Dictionary>(); + foreach (ThermalVoxelAddress address in _componentAddresses) + { + ThermalBoundaryState state = _states[address]; + if (!requestedVoxels.TryGetValue(state.Chunk, out List? chunkVoxels)) + { + chunkVoxels = []; + requestedVoxels.Add(state.Chunk, chunkVoxels); + } + + chunkVoxels.Add(address.LocalVoxelIndex); + } + + var canApplyComponent = true; + foreach ((AtmosChunk chunk, List chunkVoxels) in requestedVoxels) + { + if (chunk.CanWakeVoxels(chunkVoxels.ToArray())) + continue; + canApplyComponent = false; + break; + } + + if (!canApplyComponent) + { + // Each connected boundary graph is one simultaneous conservative batch. Defer only the blocked + // component, leaving unrelated thermal boundaries free to progress, and retain its currently + // active edge producers so it is retried after inactive target capacity becomes available. + // Component membership, rather than only nonzero net-delta addresses, matters here: an active + // mediator can balance equal-and-opposite edge transfers while still being the sole event source. + KeepActiveBoundaryProducersAwake(_componentMembers); + continue; + } + + foreach (ThermalVoxelAddress address in _componentAddresses) + { + double energyDelta = _energyDeltas[address]; + ThermalBoundaryState state = _states[address]; + // A chunk can be awake while this boundary voxel's classification seed is inactive. Activate + // that seed before applying energy so internal thermal diffusion and snap validation observe it. + state.Chunk.WakeVoxel(address.LocalVoxelIndex); + + bool valid = TryCalculateProjectedState( + context.TickConfig, address, state, energyDelta, + out float newTemperature, out float newPressure); + System.Diagnostics.Debug.Assert(valid); + + state.Chunk.Temperature[address.LocalVoxelIndex] = newTemperature; + state.Chunk.TotalHeatCapacity[address.LocalVoxelIndex] = state.HeatCapacity; + state.Chunk.TotalPressure[address.LocalVoxelIndex] = newPressure; + state.Chunk.MarkChanged(); + } + } + } + + private void BuildThermalComponents() + { + foreach ((ThermalBoundaryEdge edge, _) in _activeEdges) + UnionComponents(edge.First, edge.Second); + + var roots = new HashSet(); + foreach (ThermalVoxelAddress address in _componentParents.Keys) + { + ThermalVoxelAddress root = FindComponentRoot(address); + if (roots.Add(root)) + _componentRoots.Add(root); + } + + _componentRoots.Sort(CompareVoxels); + } + + private void UnionComponents(ThermalVoxelAddress first, ThermalVoxelAddress second) + { + if (!_componentParents.TryAdd(first, first)) + first = FindComponentRoot(first); + if (!_componentParents.TryAdd(second, second)) + second = FindComponentRoot(second); + + ThermalVoxelAddress firstRoot = FindComponentRoot(first); + ThermalVoxelAddress secondRoot = FindComponentRoot(second); + if (firstRoot == secondRoot) + return; + + if (CompareVoxels(firstRoot, secondRoot) <= 0) + _componentParents[secondRoot] = firstRoot; + else + _componentParents[firstRoot] = secondRoot; + } + + private ThermalVoxelAddress FindComponentRoot(ThermalVoxelAddress address) + { + ThermalVoxelAddress root = address; + while (_componentParents[root] != root) + root = _componentParents[root]; + + while (_componentParents[address] != address) + { + ThermalVoxelAddress parent = _componentParents[address]; + _componentParents[address] = root; + address = parent; + } + + return root; + } + + private void KeepActiveBoundaryProducersAwake(IEnumerable addresses) + { + foreach (ThermalVoxelAddress address in addresses) { ThermalBoundaryState state = _states[address]; - float newTemperature = MathF.Max(0f, - state.Temperature + (float)(energyDelta / state.HeatCapacity)); + if (state.Chunk.IsVoxelActive(address.LocalVoxelIndex)) + state.Chunk.SleepTimer = 0; + } + } + + private static bool TryCalculateProjectedState( + AtmosSolverConfigSnapshot config, + ThermalVoxelAddress address, + ThermalBoundaryState state, + double energyDelta, + out float newTemperature, + out float newPressure) + { + double projectedTemperature = Math.Max(0d, state.Temperature + energyDelta / state.HeatCapacity); + newTemperature = (float)projectedTemperature; + if (!double.IsFinite(projectedTemperature) || !float.IsFinite(newTemperature)) + { + newPressure = 0f; + return false; + } - state.Chunk.Temperature[address.LocalVoxelIndex] = newTemperature; - state.Chunk.TotalPressure[address.LocalVoxelIndex] = AtmosSolverMath.CalculatePressureAtVoxel( - context.TickConfig, state.Chunk, address.LocalVoxelIndex); - state.Chunk.MarkChanged(); + var totalMoles = 0f; + for (var gas = 0; gas < state.Chunk.ActiveGasCount; gas++) + totalMoles += state.Chunk.ActiveGases[gas].Moles[address.LocalVoxelIndex]; + if (!float.IsFinite(totalMoles)) + { + newPressure = 0f; + return false; } + + newPressure = AtmosSolverMath.CalculatePressure(config, totalMoles, newTemperature); + return float.IsFinite(newPressure); } private bool TryGetState(AtmosSolverExecutionContext context, ThermalVoxelAddress address, @@ -160,8 +337,6 @@ private bool TryGetState(AtmosSolverExecutionContext context, ThermalVoxelAddres ushort voxelIndex = address.LocalVoxelIndex; float pressure = AtmosSolverMath.CalculatePressureAtVoxel(context.TickConfig, chunk, voxelIndex); float heatCapacity = AtmosSolverMath.CalculateHeatCapacityAtVoxel(context.TickConfig, chunk, voxelIndex); - chunk.TotalPressure[voxelIndex] = pressure; - chunk.TotalHeatCapacity[voxelIndex] = heatCapacity; if (!AtmosSolverMath.IsFinitePositive(heatCapacity) || !float.IsFinite(pressure) || pressure < context.TickConfig.VacuumThreshold) return false; diff --git a/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs b/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs index 755ab15..2ba827c 100644 --- a/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs +++ b/src/Numos.CoreSim/Solvers/ThermalDiffusionSolver.cs @@ -18,6 +18,13 @@ internal int Solve(AtmosChunk chunk, AtmosSolverConfigSnapshot config, if (thermalConductance <= 0f) return 0; + // Advection normally refreshes these derived caches, but solver stages are independently disable-able + // and live configuration changes can revalue every species' heat capacity between ticks. Thermal + // diffusion must therefore establish its own coherent view from primary mole/temperature state. + RefreshDerivedState(chunk, config); + bool skipStableAggregateEdges = config.VoxelSnappingEnabled && + chunk.VoxelAggregates.IsMaterializedStateCurrent(chunk); + double[] incidentConductances = ArrayPool.Shared.Rent(chunk.VoxelCount); double[] energyDeltas = ArrayPool.Shared.Rent(chunk.VoxelCount); Array.Clear(incidentConductances, 0, chunk.VoxelCount); @@ -26,8 +33,9 @@ internal int Solve(AtmosChunk chunk, AtmosSolverConfigSnapshot config, try { int boundaryCount = AccumulateConductancesAndBoundaries( - chunk, config, incidentConductances, boundaryBuffer); - AccumulateEnergyDeltas(chunk, config, incidentConductances, energyDeltas); + chunk, config, incidentConductances, boundaryBuffer, skipStableAggregateEdges); + AccumulateEnergyDeltas( + chunk, config, incidentConductances, energyDeltas, skipStableAggregateEdges); ApplyEnergyDeltas(chunk, config, energyDeltas); return boundaryCount; } @@ -38,9 +46,21 @@ internal int Solve(AtmosChunk chunk, AtmosSolverConfigSnapshot config, } } + private static void RefreshDerivedState(AtmosChunk chunk, AtmosSolverConfigSnapshot config) + { + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + chunk.TotalHeatCapacity[voxelIndex] = + AtmosSolverMath.CalculateHeatCapacityAtVoxel(config, chunk, voxelIndex); + chunk.TotalPressure[voxelIndex] = + AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); + } + } + private static int AccumulateConductancesAndBoundaries(AtmosChunk chunk, AtmosSolverConfigSnapshot config, double[] incidentConductances, - ThermalBoundaryEvent[] boundaryBuffer) + ThermalBoundaryEvent[] boundaryBuffer, bool skipStableAggregateEdges) { var boundaryCount = 0; for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) @@ -51,13 +71,13 @@ private static int AccumulateConductancesAndBoundaries(AtmosChunk chunk, Int3 position = chunk.GetXyzInt3(voxelIndex); AccumulateConductance(chunk, config, position + Int3.PosX, voxelIndex, heatCapacity, - incidentConductances); + incidentConductances, skipStableAggregateEdges); AccumulateConductance(chunk, config, position + Int3.PosY, voxelIndex, heatCapacity, - incidentConductances); + incidentConductances, skipStableAggregateEdges); if (chunk.Depth > 1) { AccumulateConductance(chunk, config, position + Int3.PosZ, voxelIndex, heatCapacity, - incidentConductances); + incidentConductances, skipStableAggregateEdges); } if (IsBoundary(chunk, position)) @@ -68,7 +88,7 @@ private static int AccumulateConductancesAndBoundaries(AtmosChunk chunk, } private static void AccumulateEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, - double[] incidentConductances, double[] energyDeltas) + double[] incidentConductances, double[] energyDeltas, bool skipStableAggregateEdges) { for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { @@ -79,13 +99,13 @@ private static void AccumulateEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSn Int3 position = chunk.GetXyzInt3(voxelIndex); AccumulateFlux(chunk, config, position + Int3.PosX, voxelIndex, temperature, heatCapacity, - incidentConductances, energyDeltas); + incidentConductances, energyDeltas, skipStableAggregateEdges); AccumulateFlux(chunk, config, position + Int3.PosY, voxelIndex, temperature, heatCapacity, - incidentConductances, energyDeltas); + incidentConductances, energyDeltas, skipStableAggregateEdges); if (chunk.Depth > 1) { AccumulateFlux(chunk, config, position + Int3.PosZ, voxelIndex, temperature, heatCapacity, - incidentConductances, energyDeltas); + incidentConductances, energyDeltas, skipStableAggregateEdges); } } } @@ -93,24 +113,75 @@ private static void AccumulateEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSn private static void ApplyEnergyDeltas(AtmosChunk chunk, AtmosSolverConfigSnapshot config, double[] energyDeltas) { + // Validate the whole simultaneous batch before changing primary state. Individually finite heat + // transfers can still produce a temperature or ideal-gas pressure outside float storage range. for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) { ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; - if (energyDeltas[voxelIndex] == 0f || + if (energyDeltas[voxelIndex] == 0d || !TryGetThermalState(chunk, config, voxelIndex, out float oldTemperature, out float heatCapacity)) continue; + if (TryCalculateProjectedState( + chunk, config, voxelIndex, oldTemperature, heatCapacity, + energyDeltas[voxelIndex], out _, out _)) + continue; - chunk.Temperature[voxelIndex] = MathF.Max(0f, - oldTemperature + (float)(energyDeltas[voxelIndex] / heatCapacity)); - chunk.TotalPressure[voxelIndex] = - AtmosSolverMath.CalculatePressureAtVoxel(config, chunk, voxelIndex); + chunk.SleepTimer = 0; + return; } + + for (var activeIndex = 0; activeIndex < chunk.ActiveAirCount; activeIndex++) + { + ushort voxelIndex = chunk.ActiveAirIndices[activeIndex]; + if (energyDeltas[voxelIndex] == 0d || + !TryGetThermalState(chunk, config, voxelIndex, out float oldTemperature, + out float heatCapacity)) + continue; + + bool valid = TryCalculateProjectedState( + chunk, config, voxelIndex, oldTemperature, heatCapacity, + energyDeltas[voxelIndex], out float newTemperature, out float newPressure); + Debug.Assert(valid); + chunk.Temperature[voxelIndex] = newTemperature; + chunk.TotalPressure[voxelIndex] = newPressure; + } + } + + private static bool TryCalculateProjectedState( + AtmosChunk chunk, + AtmosSolverConfigSnapshot config, + ushort voxelIndex, + float oldTemperature, + float heatCapacity, + double energyDelta, + out float newTemperature, + out float newPressure) + { + double projectedTemperature = Math.Max(0d, oldTemperature + energyDelta / heatCapacity); + newTemperature = (float)projectedTemperature; + if (!double.IsFinite(projectedTemperature) || !float.IsFinite(newTemperature)) + { + newPressure = 0f; + return false; + } + + var totalMoles = 0f; + for (var gas = 0; gas < chunk.ActiveGasCount; gas++) + totalMoles += chunk.ActiveGases[gas].Moles[voxelIndex]; + if (!float.IsFinite(totalMoles)) + { + newPressure = 0f; + return false; + } + + newPressure = AtmosSolverMath.CalculatePressure(config, totalMoles, newTemperature); + return float.IsFinite(newPressure); } private static void AccumulateConductance(AtmosChunk chunk, AtmosSolverConfigSnapshot config, Int3 neighborPosition, ushort voxelIndex, float currentHeatCapacity, - double[] incidentConductances) + double[] incidentConductances, bool skipStableAggregateEdges) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; @@ -120,6 +191,9 @@ private static void AccumulateConductance(AtmosChunk chunk, AtmosSolverConfigSna if (neighborRoom == VoxelClassification.RoomSolid || neighborRoom == VoxelClassification.RoomVoid) return; + if (skipStableAggregateEdges && + chunk.VoxelAggregates.AreAggregatedTogether(voxelIndex, neighborIndex)) + return; if (!TryGetThermalState(chunk, config, neighborIndex, out _, out float neighborHeatCapacity)) return; @@ -131,7 +205,7 @@ private static void AccumulateConductance(AtmosChunk chunk, AtmosSolverConfigSna private static void AccumulateFlux(AtmosChunk chunk, AtmosSolverConfigSnapshot config, Int3 neighborPosition, ushort voxelIndex, float currentTemperature, float currentHeatCapacity, - double[] incidentConductances, double[] energyDeltas) + double[] incidentConductances, double[] energyDeltas, bool skipStableAggregateEdges) { if (!neighborPosition.IsWithin(default, chunk.Dimensions)) return; @@ -141,6 +215,9 @@ private static void AccumulateFlux(AtmosChunk chunk, AtmosSolverConfigSnapshot c if (neighborRoom == VoxelClassification.RoomSolid || neighborRoom == VoxelClassification.RoomVoid) return; + if (skipStableAggregateEdges && + chunk.VoxelAggregates.AreAggregatedTogether(voxelIndex, neighborIndex)) + return; if (!TryGetThermalState(chunk, config, neighborIndex, out float neighborTemperature, out float neighborHeatCapacity)) return; diff --git a/src/Numos.Headless/Diagnostics/Coordinate.cs b/src/Numos.Headless/Diagnostics/Coordinate.cs new file mode 100644 index 0000000..cbdd17a --- /dev/null +++ b/src/Numos.Headless/Diagnostics/Coordinate.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Numos.Maths; + +namespace Numos.Headless.Protocol; + +/// A JSON-friendly three-dimensional integer coordinate. +public readonly record struct Coordinate +{ + public Coordinate(int x, int y, int z) + { + X = x; + Y = y; + Z = z; + } + + [JsonRequired] + public int X { get; init; } + + [JsonRequired] + public int Y { get; init; } + + [JsonRequired] + public int Z { get; init; } + + internal readonly Int3 ToInt3() => new(X, Y, Z); + + internal static Coordinate From(Int3 value) => new(value.X, value.Y, value.Z); +} + diff --git a/src/Numos.Headless/Diagnostics/SimulationStateAnalyzer.cs b/src/Numos.Headless/Diagnostics/SimulationStateAnalyzer.cs new file mode 100644 index 0000000..6276ded --- /dev/null +++ b/src/Numos.Headless/Diagnostics/SimulationStateAnalyzer.cs @@ -0,0 +1,533 @@ +using Numos.API; +using Numos.CoreSim; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.CoreSim.Datatypes.Snapshots; +using Numos.Headless.Protocol; +using Numos.Maths; + +namespace Numos.Headless.Diagnostics; + +/// +/// Converts authoritative detached snapshots into deterministic, JSON-friendly diagnostics. +/// +public static class SimulationStateAnalyzer +{ + public static SimulationStateReport Analyze( + AtmosSimulation simulation, + AtmosConfig config, + SimulationObservationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(simulation); + ArgumentNullException.ThrowIfNull(config); + options ??= new SimulationObservationOptions(); + + int issueLimit = Math.Clamp( + options.MaxIssueLocations, + 0, + SimulationObservationOptions.MaximumMaxIssueLocations); + AtmosChunkHandle[] handles = SelectHandles(simulation.GetChunkHandles(), options.Chunk); + var requests = handles + .Select(static handle => new AtmosChunkSnapshotRequest( + handle.Position, + default, + AtmosChunkSnapshotFields.All)) + .ToArray(); + AtmosChunkSnapshotBatch batch = simulation.GetChangedChunkSnapshots(requests); + AtmosChunkSnapshot[] snapshots = batch.ChangedChunks + .OrderBy(static snapshot => snapshot.GridPosition.X) + .ThenBy(static snapshot => snapshot.GridPosition.Y) + .ThenBy(static snapshot => snapshot.GridPosition.Z) + .ToArray(); + + Dictionary> selectedVoxels = ResolveVoxelSelections(snapshots, options); + var issues = new IssueCollector(issueLimit); + var global = new SummaryAccumulator(config); + var chunkReports = new ChunkStateReport[snapshots.Length]; + + for (var chunkIndex = 0; chunkIndex < snapshots.Length; chunkIndex++) + { + AtmosChunkSnapshot snapshot = snapshots[chunkIndex]; + var chunkSummary = new SummaryAccumulator(config); + var voxelReports = new List(); + selectedVoxels.TryGetValue(snapshot.GridPosition, out HashSet? selectedIndices); + + int voxelCount = GetVoxelCount(snapshot.Dimensions); + for (var localIndex = 0; localIndex < voxelCount; localIndex++) + { + Coordinate localPosition = GetCoordinates(localIndex, snapshot.Dimensions); + VoxelAnalysis voxel = chunkSummary.AddVoxel( + snapshot, + localIndex, + localPosition, + issues); + global.AddVoxel(snapshot, localIndex, localPosition, null); + + bool explicitlySelected = selectedIndices?.Contains(localIndex) == true; + bool includeFromFullScan = options.IncludeVoxels && + (!options.OnlyGasBearingVoxels || voxel.IsGasBearing); + if (explicitlySelected || includeFromFullScan) + voxelReports.Add(CreateVoxelReport(snapshot, localIndex, localPosition, voxel, config)); + } + + chunkReports[chunkIndex] = new ChunkStateReport( + Coordinate.From(snapshot.GridPosition), + Coordinate.From(snapshot.Dimensions), + snapshot.Version.Generation, + snapshot.Version.Revision, + snapshot.IsAwake, + snapshot.SleepTimer, + snapshot.ActiveAirCount, + snapshot.ActiveGasCount, + chunkSummary.ToChunkReport(), + voxelReports.ToArray()); + global.AddChunk(snapshot); + } + + return new SimulationStateReport( + batch.TickCount, + AtmosSimulation.SimulationRate, + simulation.ChunkCount, + CreateConfigurationReport(config), + simulation.Solvers.Steps.Select(CreateSolverReport).ToArray(), + global.ToGlobalReport(), + chunkReports, + issues.Items.ToArray(), + issues.Truncated); + } + + private static AtmosChunkHandle[] SelectHandles( + AtmosChunkHandle[] handles, + Coordinate? selectedChunk) + { + if (!selectedChunk.HasValue) + return handles; + + Int3 position = selectedChunk.Value.ToInt3(); + foreach (AtmosChunkHandle handle in handles) + { + if (handle.Position == position) + return [handle]; + } + + throw new KeyNotFoundException($"No chunk is registered at {position}."); + } + + private static Dictionary> ResolveVoxelSelections( + IReadOnlyList snapshots, + SimulationObservationOptions options) + { + var result = new Dictionary>(); + if (options.Voxels == null || options.Voxels.Count == 0) + return result; + + var snapshotsByPosition = snapshots.ToDictionary(static snapshot => snapshot.GridPosition); + foreach (VoxelSelection selection in options.Voxels) + { + Int3 chunkPosition = selection.Chunk.ToInt3(); + if (options.Chunk.HasValue && options.Chunk.Value.ToInt3() != chunkPosition) + { + throw new ArgumentException( + $"Voxel selection chunk {chunkPosition} is outside the requested chunk scope.", + nameof(options)); + } + + if (!snapshotsByPosition.TryGetValue(chunkPosition, out AtmosChunkSnapshot snapshot)) + throw new KeyNotFoundException($"No chunk is registered at {chunkPosition}."); + + Coordinate voxel = selection.Voxel; + if (voxel.X < 0 || voxel.X >= snapshot.Dimensions.X || + voxel.Y < 0 || voxel.Y >= snapshot.Dimensions.Y || + voxel.Z < 0 || voxel.Z >= snapshot.Dimensions.Z) + { + throw new ArgumentOutOfRangeException( + nameof(options), + $"Voxel {voxel.X}, {voxel.Y}, {voxel.Z} is outside chunk {chunkPosition} with dimensions " + + $"{snapshot.Dimensions.X}, {snapshot.Dimensions.Y}, {snapshot.Dimensions.Z}."); + } + + int localIndex = voxel.X + snapshot.Dimensions.X * + (voxel.Y + snapshot.Dimensions.Y * voxel.Z); + if (!result.TryGetValue(chunkPosition, out HashSet? indices)) + { + indices = []; + result.Add(chunkPosition, indices); + } + + indices.Add(localIndex); + } + + return result; + } + + private static SimulationConfigurationReport CreateConfigurationReport(AtmosConfig config) + { + var gases = new GasConfigurationReport[config.GasRegistry.Count]; + for (var gasId = 0; gasId < gases.Length; gasId++) + { + GasProperties gas = config.GasRegistry[gasId]; + gases[gasId] = new GasConfigurationReport( + gasId, + gas.Name, + gas.MolarHeatCapacityAtConstantVolume, + gas.BoilingPoint, + gas.CondensationEnabled, + gas.MolarEnthalpyOfVaporization, + gas.LiquidId, + gas.DiffusionCoefficient); + } + + return new SimulationConfigurationReport( + config.GlobalTemperature, + config.DefaultTemperatureFallback, + config.DefaultMolarHeatCapacityAtConstantVolume, + config.VoxelVolume, + config.SaturationReferencePressure, + config.DefaultDiffusionCoefficient, + config.SpaceTemperature, + config.BulkFlowCoefficient, + config.BulkFlowDamping, + config.LowPressureDeltaThreshold, + config.MinimumPressureTransfer, + config.VacuumThreshold, + config.SleepThreshold, + config.SleepEpsilon, + config.VoxelSnapPressureRelativeEpsilon, + config.VoxelSnappingEnabled, + config.VoxelSnapTemperatureEpsilon, + config.VoxelSnapMoleFractionEpsilon, + config.ThermalConductance, + config.CondensationRateFactor, + config.MaxPressureTransferFractionPerNeighbor, + config.AccumulatorWakeThreshold, + config.AccumulatorMaxAliveTicks, + gases); + } + + private static SolverStepReport CreateSolverReport(AtmosSolverStep step) + { + string kind = step.Kind switch + { + AtmosSolverKind.BuiltIn => "builtIn", + AtmosSolverKind.Standard => "standard", + AtmosSolverKind.Dangerous => "dangerous", + _ => throw new ArgumentOutOfRangeException(nameof(step)) + }; + return new SolverStepReport(step.Name, step.IsEnabled, kind); + } + + private static VoxelStateReport CreateVoxelReport( + AtmosChunkSnapshot snapshot, + int localIndex, + Coordinate localPosition, + VoxelAnalysis voxel, + AtmosConfig config) + { + VoxelGasReport[] gases = snapshot.Gases + .OrderBy(static gas => gas.GasId) + .Select(gas => new VoxelGasReport( + gas.GasId, + GetGasName(config, gas.GasId), + gas.Moles[localIndex])) + .ToArray(); + return new VoxelStateReport( + localIndex, + localPosition, + snapshot.VoxelRoomMap[localIndex], + voxel.IsGasCapable, + voxel.IsGasBearing, + snapshot.TotalPressure[localIndex], + snapshot.Temperature[localIndex], + voxel.TotalMoles, + voxel.SensibleEnergy, + gases); + } + + private static string? GetGasName(AtmosConfig config, int gasId) + { + return gasId >= 0 && gasId < config.GasRegistry.Count + ? config.GasRegistry[gasId].Name + : null; + } + + private static int GetVoxelCount(Int3 dimensions) + { + return checked(dimensions.X * dimensions.Y * dimensions.Z); + } + + private static Coordinate GetCoordinates(int localIndex, Int3 dimensions) + { + int x = localIndex % dimensions.X; + int remainder = localIndex / dimensions.X; + int y = remainder % dimensions.Y; + int z = remainder / dimensions.Y; + return new Coordinate(x, y, z); + } + + private sealed class SummaryAccumulator(AtmosConfig config) + { + private readonly Dictionary _gasMoles = []; + private readonly FiniteStatisticsAccumulator _pressure = new(); + private readonly FiniteStatisticsAccumulator _temperature = new(); + private readonly AnomalyCountsAccumulator _anomalies = new(); + private int _chunkCount; + private int _voxelCount; + private int _gasCapableVoxelCount; + private int _solidVoxelCount; + private int _voidVoxelCount; + private int _gasBearingVoxelCount; + private int _activeAirCount; + private int _activeGasChannelCount; + private int _awakeChunkCount; + private double _totalMoles; + private double _sensibleEnergy; + + internal VoxelAnalysis AddVoxel( + AtmosChunkSnapshot snapshot, + int localIndex, + Coordinate localPosition, + IssueCollector? issues) + { + _voxelCount++; + int roomId = snapshot.VoxelRoomMap[localIndex]; + bool isSolid = roomId == VoxelClassification.RoomSolid; + bool isVoid = roomId == VoxelClassification.RoomVoid; + bool isGasCapable = !isSolid && !isVoid; + if (isSolid) + _solidVoxelCount++; + else if (isVoid) + _voidVoxelCount++; + else + _gasCapableVoxelCount++; + + float pressure = snapshot.TotalPressure[localIndex]; + float temperature = snapshot.Temperature[localIndex]; + _pressure.Add(pressure); + _temperature.Add(temperature); + InspectScalar(pressure, "Pressure", snapshot, localPosition, localIndex, null, issues); + InspectScalar(temperature, "Temperature", snapshot, localPosition, localIndex, null, issues); + + double voxelMoles = 0d; + double voxelEnergy = 0d; + bool isGasBearing = false; + float effectiveTemperature = GetEffectiveTemperature(config, temperature); + foreach (GasSnapshot gas in snapshot.Gases) + { + float moles = gas.Moles[localIndex]; + voxelMoles += moles; + // Invalid and negative amounts are still state worth retaining when a caller asks for + // gas-bearing voxels only; treating any non-zero value as occupied keeps them visible. + isGasBearing |= moles != 0f; + _gasMoles.TryGetValue(gas.GasId, out double currentMoles); + _gasMoles[gas.GasId] = currentMoles + moles; + voxelEnergy += (double)moles * GetMolarHeatCapacity(config, gas.GasId) * effectiveTemperature; + InspectScalar(moles, "Moles", snapshot, localPosition, localIndex, gas.GasId, issues); + } + + if (isGasBearing) + _gasBearingVoxelCount++; + _totalMoles += voxelMoles; + _sensibleEnergy += voxelEnergy; + return new VoxelAnalysis(isGasCapable, isGasBearing, voxelMoles, voxelEnergy); + } + + internal void AddChunk(AtmosChunkSnapshot snapshot) + { + _chunkCount++; + _activeAirCount += snapshot.ActiveAirCount; + _activeGasChannelCount += snapshot.ActiveGasCount; + if (snapshot.IsAwake) + _awakeChunkCount++; + } + + internal ChunkSummaryReport ToChunkReport() + { + return new ChunkSummaryReport( + _voxelCount, + _gasCapableVoxelCount, + _solidVoxelCount, + _voidVoxelCount, + _gasBearingVoxelCount, + _totalMoles, + _sensibleEnergy, + _pressure.ToReport(), + _temperature.ToReport(), + CreateGasTotals(), + _anomalies.ToReport()); + } + + internal SimulationSummaryReport ToGlobalReport() + { + return new SimulationSummaryReport( + _chunkCount, + _voxelCount, + _gasCapableVoxelCount, + _solidVoxelCount, + _voidVoxelCount, + _gasBearingVoxelCount, + _activeAirCount, + _activeGasChannelCount, + _awakeChunkCount, + _chunkCount - _awakeChunkCount, + _totalMoles, + _sensibleEnergy, + _pressure.ToReport(), + _temperature.ToReport(), + CreateGasTotals(), + _anomalies.ToReport()); + } + + private GasTotalReport[] CreateGasTotals() + { + return _gasMoles + .OrderBy(static pair => pair.Key) + .Select(pair => new GasTotalReport(pair.Key, GetGasName(config, pair.Key), pair.Value)) + .ToArray(); + } + + private void InspectScalar( + float value, + string field, + AtmosChunkSnapshot snapshot, + Coordinate localPosition, + int localIndex, + int? gasId, + IssueCollector? issues) + { + if (!float.IsFinite(value)) + { + _anomalies.AddNonFinite(field); + issues?.Add(new AnomalyIssueReport( + $"nonFinite{field}", + Coordinate.From(snapshot.GridPosition), + localPosition, + localIndex, + gasId, + value)); + } + else if (value < 0f) + { + _anomalies.AddNegative(field); + issues?.Add(new AnomalyIssueReport( + $"negative{field}", + Coordinate.From(snapshot.GridPosition), + localPosition, + localIndex, + gasId, + value)); + } + } + } + + private sealed class FiniteStatisticsAccumulator + { + private int _sampleCount; + private int _finiteCount; + private double _sum; + private double _minimum = double.PositiveInfinity; + private double _maximum = double.NegativeInfinity; + + internal void Add(float value) + { + _sampleCount++; + if (!float.IsFinite(value)) + return; + _finiteCount++; + _sum += value; + _minimum = Math.Min(_minimum, value); + _maximum = Math.Max(_maximum, value); + } + + internal FiniteStatisticsReport ToReport() + { + return new FiniteStatisticsReport( + _sampleCount, + _finiteCount, + _sampleCount - _finiteCount, + _finiteCount == 0 ? null : _minimum, + _finiteCount == 0 ? null : _maximum, + _finiteCount == 0 ? null : _sum / _finiteCount); + } + } + + private sealed class AnomalyCountsAccumulator + { + private int _nonFinitePressure; + private int _negativePressure; + private int _nonFiniteTemperature; + private int _negativeTemperature; + private int _nonFiniteMoles; + private int _negativeMoles; + + internal void AddNonFinite(string field) + { + switch (field) + { + case "Pressure": _nonFinitePressure++; break; + case "Temperature": _nonFiniteTemperature++; break; + case "Moles": _nonFiniteMoles++; break; + } + } + + internal void AddNegative(string field) + { + switch (field) + { + case "Pressure": _negativePressure++; break; + case "Temperature": _negativeTemperature++; break; + case "Moles": _negativeMoles++; break; + } + } + + internal AnomalyCountsReport ToReport() + { + return new AnomalyCountsReport( + _nonFinitePressure, + _negativePressure, + _nonFiniteTemperature, + _negativeTemperature, + _nonFiniteMoles, + _negativeMoles); + } + } + + private sealed class IssueCollector(int maximumCount) + { + internal List Items { get; } = []; + internal bool Truncated { get; private set; } + + internal void Add(AnomalyIssueReport issue) + { + if (Items.Count < maximumCount) + Items.Add(issue); + else + Truncated = true; + } + } + + private static float GetEffectiveTemperature(AtmosConfig config, float temperature) + { + if (float.IsFinite(temperature) && temperature > 0f) + return temperature; + return float.IsFinite(config.DefaultTemperatureFallback) && config.DefaultTemperatureFallback > 0f + ? config.DefaultTemperatureFallback + : AtmosPhysicalConstants.RoomTemperature; + } + + private static float GetMolarHeatCapacity(AtmosConfig config, int gasId) + { + float fallback = float.IsFinite(config.DefaultMolarHeatCapacityAtConstantVolume) && + config.DefaultMolarHeatCapacityAtConstantVolume > 0f + ? config.DefaultMolarHeatCapacityAtConstantVolume + : AtmosPhysicalConstants.IdealDiatomicMolarHeatCapacityAtConstantVolume; + if (gasId < 0 || gasId >= config.GasRegistry.Count) + return fallback; + float configured = config.GasRegistry[gasId].MolarHeatCapacityAtConstantVolume; + return float.IsFinite(configured) && configured > 0f ? configured : fallback; + } + + private readonly record struct VoxelAnalysis( + bool IsGasCapable, + bool IsGasBearing, + double TotalMoles, + double SensibleEnergy); +} diff --git a/src/Numos.Headless/Diagnostics/SimulationStateReports.cs b/src/Numos.Headless/Diagnostics/SimulationStateReports.cs new file mode 100644 index 0000000..0175517 --- /dev/null +++ b/src/Numos.Headless/Diagnostics/SimulationStateReports.cs @@ -0,0 +1,192 @@ +using Numos.Headless.Protocol; + +namespace Numos.Headless.Diagnostics; + +/// Controls the scope and detail of one detached simulation observation. +public sealed class SimulationObservationOptions +{ + public const int DefaultMaxIssueLocations = 32; + public const int MaximumMaxIssueLocations = 1_024; + + /// Limits the report to one chunk. A missing chunk is rejected. + public Coordinate? Chunk { get; init; } + + /// Specific voxels to include even when is false. + public IReadOnlyList? Voxels { get; init; } + + /// Includes detailed voxel reports for every voxel in the selected chunk scope. + public bool IncludeVoxels { get; init; } + + /// + /// When full voxel detail is enabled, omits voxels without a positive gas amount. Explicit + /// selections are still returned. + /// + public bool OnlyGasBearingVoxels { get; init; } + + /// Maximum number of deterministic anomaly locations returned with the aggregate counts. + public int MaxIssueLocations { get; init; } = DefaultMaxIssueLocations; +} + +/// Identifies one local voxel in a chunk. +public sealed class VoxelSelection +{ + public VoxelSelection(Coordinate? chunk, Coordinate? voxel) + { + Chunk = chunk ?? throw new ArgumentNullException(nameof(chunk)); + Voxel = voxel ?? throw new ArgumentNullException(nameof(voxel)); + } + + public Coordinate Chunk { get; } + public Coordinate Voxel { get; } +} + +/// A coherent detached observation of a simulation state. +public sealed record SimulationStateReport( + int Tick, + float SimulationRate, + int SimulationChunkCount, + SimulationConfigurationReport Config, + SolverStepReport[] SolverPipeline, + SimulationSummaryReport Global, + ChunkStateReport[] Chunks, + AnomalyIssueReport[] IssueLocations, + bool IssueLocationsTruncated); + +/// The live configuration values associated with an observation. +public sealed record SimulationConfigurationReport( + float GlobalTemperatureK, + float DefaultTemperatureFallbackK, + float DefaultMolarHeatCapacityAtConstantVolume, + float VoxelVolumeM3, + float SaturationReferencePressurePa, + float DefaultDiffusionCoefficient, + float SpaceTemperatureK, + float BulkFlowCoefficient, + float BulkFlowDamping, + float LowPressureDeltaThresholdPa, + float MinimumPressureTransferPa, + float VacuumThresholdPa, + int SleepThreshold, + float SleepEpsilonPa, + float VoxelSnapPressureRelativeEpsilon, + bool VoxelSnappingEnabled, + float VoxelSnapTemperatureEpsilonK, + float VoxelSnapMoleFractionEpsilon, + float ThermalConductance, + float CondensationRateFactor, + float MaxPressureTransferFractionPerNeighbor, + float AccumulatorWakeThresholdPa, + int AccumulatorMaxAliveTicks, + GasConfigurationReport[] Gases); + +/// One indexed gas definition captured from the configuration registry. +public sealed record GasConfigurationReport( + int GasId, + string? Name, + float MolarHeatCapacityAtConstantVolume, + float BoilingPointK, + bool CondensationEnabled, + float MolarEnthalpyOfVaporization, + int LiquidId, + float DiffusionCoefficient); + +/// Wire metadata for one solver stage, in execution order. +public sealed record SolverStepReport(string Name, bool IsEnabled, string Kind); + +/// Aggregate values across the chunks selected for an observation. +public sealed record SimulationSummaryReport( + int ChunkCount, + int VoxelCount, + int GasCapableVoxelCount, + int SolidVoxelCount, + int VoidVoxelCount, + int GasBearingVoxelCount, + int ActiveAirCount, + int ActiveGasChannelCount, + int AwakeChunkCount, + int SleepingChunkCount, + double TotalMoles, + double SensibleEnergyJ, + FiniteStatisticsReport PressurePa, + FiniteStatisticsReport TemperatureK, + GasTotalReport[] Gases, + AnomalyCountsReport Anomalies); + +/// Detached state and aggregate values for one chunk. +public sealed record ChunkStateReport( + Coordinate Position, + Coordinate Dimensions, + long Generation, + long Revision, + bool IsAwake, + int SleepTimer, + int ActiveAirCount, + int ActiveGasCount, + ChunkSummaryReport Summary, + VoxelStateReport[] Voxels); + +/// Aggregate values for one chunk. +public sealed record ChunkSummaryReport( + int VoxelCount, + int GasCapableVoxelCount, + int SolidVoxelCount, + int VoidVoxelCount, + int GasBearingVoxelCount, + double TotalMoles, + double SensibleEnergyJ, + FiniteStatisticsReport PressurePa, + FiniteStatisticsReport TemperatureK, + GasTotalReport[] Gases, + AnomalyCountsReport Anomalies); + +/// Finite-value statistics while retaining the size of the complete sampled field. +public sealed record FiniteStatisticsReport( + int SampleCount, + int FiniteCount, + int NonFiniteCount, + double? Minimum, + double? Maximum, + double? Mean); + +/// Total amount for one gas, ordered by gas ID. +public sealed record GasTotalReport(int GasId, string? Name, double Moles); + +/// Raw amount for one gas in one detailed voxel. +public sealed record VoxelGasReport(int GasId, string? Name, float Moles); + +/// Detailed raw and derived values for one voxel. +public sealed record VoxelStateReport( + int LocalIndex, + Coordinate Position, + int RoomId, + bool IsGasCapable, + bool IsGasBearing, + float PressurePa, + float TemperatureK, + double TotalMoles, + double SensibleEnergyJ, + VoxelGasReport[] Gases); + +/// Counts of physical-value anomalies found while scanning an observation. +public sealed record AnomalyCountsReport( + int NonFinitePressureCount, + int NegativePressureCount, + int NonFiniteTemperatureCount, + int NegativeTemperatureCount, + int NonFiniteMolesCount, + int NegativeMolesCount) +{ + public int TotalCount => + NonFinitePressureCount + NegativePressureCount + + NonFiniteTemperatureCount + NegativeTemperatureCount + + NonFiniteMolesCount + NegativeMolesCount; +} + +/// One bounded, deterministic location for an aggregate anomaly. +public sealed record AnomalyIssueReport( + string Kind, + Coordinate Chunk, + Coordinate Voxel, + int LocalIndex, + int? GasId, + float Value); diff --git a/src/Numos.Headless/HeadlessApplication.cs b/src/Numos.Headless/HeadlessApplication.cs new file mode 100644 index 0000000..8c18692 --- /dev/null +++ b/src/Numos.Headless/HeadlessApplication.cs @@ -0,0 +1,85 @@ +using Numos.Headless.Protocol; + +namespace Numos.Headless; + +/// Command-line entry point with injectable streams for contract tests. +internal static class HeadlessApplication +{ + private const string HelpText = """ + Numos.Headless - deterministic JSONL access to the Numos simulation + + Usage: + dotnet run --project src/Numos.Headless + dotnet run --project src/Numos.Headless -- --script + dotnet run --project src/Numos.Headless -- + + With no script, one JSON request is read from stdin per line. Exactly one compact + JSON response is written to stdout for each request. See docs/headless_runner.md. + """; + + internal static async Task RunAsync( + string[] args, + TextReader input, + TextWriter output, + TextWriter error, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (args is ["--help" or "-h"]) + { + await output.WriteLineAsync(HelpText.AsMemory(), cancellationToken); + return 0; + } + + string? scriptPath = args switch + { + [] => null, + [var path] when !path.StartsWith("-", StringComparison.Ordinal) => path, + ["--script", var path] => path, + _ => string.Empty + }; + + if (scriptPath == string.Empty) + { + await error.WriteLineAsync("Invalid arguments. Use --help for usage."); + return 2; + } + + if (scriptPath == null) + { + using var host = new HeadlessCommandHost(); + return await host.RunAsync(input, output, error, cancellationToken); + } + + StreamReader script; + try + { + script = File.OpenText(scriptPath); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + var response = new HeadlessResponse + { + Ok = false, + Error = new HeadlessError + { + Code = "scriptUnavailable", + Message = exception.Message, + ExceptionType = exception.GetType().Name + } + }; + await HeadlessCommandHost.WriteResponseAsync(output, response, cancellationToken); + return 2; + } + + using (script) + using (var host = new HeadlessCommandHost()) + return await host.RunAsync(script, output, error, cancellationToken); + } +} + diff --git a/src/Numos.Headless/HeadlessCommandHost.cs b/src/Numos.Headless/HeadlessCommandHost.cs new file mode 100644 index 0000000..b34ed1f --- /dev/null +++ b/src/Numos.Headless/HeadlessCommandHost.cs @@ -0,0 +1,260 @@ +using System.Text.Json; +using Numos.Headless.Protocol; + +namespace Numos.Headless; + +/// +/// Processes one versioned JSON request per input line and emits exactly one JSON response for it. +/// +internal sealed class HeadlessCommandHost : IDisposable +{ + internal const int ProtocolVersion = 1; + + private readonly SimulationSession _session = new(); + private bool _exitRequested; + + internal async Task RunAsync( + TextReader input, + TextWriter output, + TextWriter error, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + var lineNumber = 0; + var hadErrors = false; + while (!_exitRequested) + { + string? line = await input.ReadLineAsync(cancellationToken); + if (line == null) + break; + + lineNumber++; + if (string.IsNullOrWhiteSpace(line)) + continue; + + HeadlessResponse response = ProcessLine(line, lineNumber, error); + if (!response.Ok) + hadErrors = true; + + await WriteResponseAsync(output, response, cancellationToken); + } + + return hadErrors ? 1 : 0; + } + + public void Dispose() + { + _session.Dispose(); + } + + internal static async Task WriteResponseAsync( + TextWriter output, + HeadlessResponse response, + CancellationToken cancellationToken) + { + string json = JsonSerializer.Serialize(response, HeadlessJsonContext.Default.HeadlessResponse); + await output.WriteLineAsync(json.AsMemory(), cancellationToken); + await output.FlushAsync(cancellationToken); + } + + private HeadlessResponse ProcessLine(string line, int lineNumber, TextWriter errorOutput) + { + HeadlessRequest? request = null; + JsonDocument document; + try + { + document = JsonDocument.Parse(line); + } + catch (JsonException exception) + { + return Failure( + null, + "invalidJson", + exception.Message, + lineNumber, + nameof(JsonException)); + } + + using (document) + try + { + request = ReadEnvelope(document.RootElement); + request = document.RootElement.Deserialize(HeadlessJsonContext.Default.HeadlessRequest); + if (request == null) + throw new HeadlessRequestException("invalidRequest", "A request must be a JSON object."); + if (!request.ProtocolVersion.HasValue) + throw new HeadlessRequestException("missingProperty", "The 'protocolVersion' property is required."); + if (request.ProtocolVersion.Value != ProtocolVersion) + { + throw new HeadlessRequestException( + "unsupportedProtocol", + $"protocolVersion must be {ProtocolVersion}; received {request.ProtocolVersion.Value}."); + } + + if (string.IsNullOrWhiteSpace(request.Id)) + throw new HeadlessRequestException("missingProperty", "The 'id' property is required."); + if (string.IsNullOrWhiteSpace(request.Op)) + throw new HeadlessRequestException("missingProperty", "The 'op' property is required."); + + ValidateOperationProperties(document.RootElement, request.Op); + CommandExecution execution = _session.Execute(request); + _exitRequested = execution.ExitRequested; + return new HeadlessResponse + { + Id = request.Id, + Op = request.Op, + Ok = true, + State = _session.GetState(), + Result = execution.Result, + Observation = execution.Observation + }; + } + catch (JsonException exception) + { + return Failure( + request, + "invalidRequest", + exception.Message, + lineNumber, + nameof(JsonException)); + } + catch (HeadlessRequestException exception) + { + return Failure(request, exception.Code, exception.Message, lineNumber); + } + catch (Exception exception) when ( + exception is ArgumentException or InvalidOperationException or KeyNotFoundException or OverflowException) + { + return Failure( + request, + "operationRejected", + exception.Message, + lineNumber, + exception.GetType().Name); + } + catch (Exception exception) + { + errorOutput.WriteLine($"Unhandled exception while processing JSONL line {lineNumber}: {exception}"); + return Failure( + request, + "internalError", + "The operation failed unexpectedly. See stderr for the exception.", + lineNumber, + exception.GetType().Name); + } + } + + private static HeadlessRequest? ReadEnvelope(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + return null; + + int? protocolVersion = root.TryGetProperty("protocolVersion", out JsonElement versionElement) && + versionElement.ValueKind == JsonValueKind.Number && + versionElement.TryGetInt32(out int parsedVersion) + ? parsedVersion + : null; + string? id = root.TryGetProperty("id", out JsonElement idElement) && + idElement.ValueKind == JsonValueKind.String + ? idElement.GetString() + : null; + string? op = root.TryGetProperty("op", out JsonElement opElement) && + opElement.ValueKind == JsonValueKind.String + ? opElement.GetString() + : null; + return new HeadlessRequest + { + ProtocolVersion = protocolVersion, + Id = id, + Op = op + }; + } + + private static void ValidateOperationProperties(JsonElement root, string operation) + { + if (!IsSupportedOperation(operation)) + return; + + foreach (JsonProperty property in root.EnumerateObject()) + { + if (property.Name is "protocolVersion" or "id" or "op" || + IsOperationProperty(operation, property.Name)) + continue; + + throw new HeadlessRequestException( + "invalidRequest", + $"The '{property.Name}' property is not valid for the '{operation}' operation."); + } + } + + private static bool IsSupportedOperation(string operation) + { + return operation is + "createSimulation" or + "closeSimulation" or + "addChunk" or + "removeChunk" or + "sealChunk" or + "setChunkClassification" or + "setVoxelClassification" or + "setVoxelTemperature" or + "addGas" or + "injectGas" or + "wakeRoom" or + "sleepChunk" or + "updateConfig" or + "setSolverEnabled" or + "resetSolvers" or + "tick" or + "observe" or + "exit"; + } + + private static bool IsOperationProperty(string operation, string property) + { + return operation switch + { + "createSimulation" => property is "name" or "dimensions" or "config" or "gases", + "addChunk" => property is "position" or "classification", + "removeChunk" or "sealChunk" or "sleepChunk" => property == "position", + "setChunkClassification" => property is "position" or "classification", + "setVoxelClassification" => property is "position" or "voxel" or "classification", + "setVoxelTemperature" => property is "position" or "voxel" or "temperatureK", + "addGas" => property == "gas", + "injectGas" => property is "position" or "voxel" or "gasId" or "moles" or "temperatureK", + "wakeRoom" => property is "position" or "roomId", + "updateConfig" => property == "config", + "setSolverEnabled" => property is "solver" or "enabled", + "tick" => property == "count", + "observe" => property is "position" or "voxel" or "includeVoxels" or + "onlyGasBearingVoxels" or "maxIssueLocations", + _ => false + }; + } + + private HeadlessResponse Failure( + HeadlessRequest? request, + string code, + string message, + int lineNumber, + string? exceptionType = null) + { + return new HeadlessResponse + { + Id = request?.Id, + Op = request?.Op, + Ok = false, + State = _session.GetState(), + Error = new HeadlessError + { + Code = code, + Message = message, + ExceptionType = exceptionType, + Line = lineNumber + } + }; + } +} diff --git a/src/Numos.Headless/Numos.Headless.csproj b/src/Numos.Headless/Numos.Headless.csproj new file mode 100644 index 0000000..da67b3f --- /dev/null +++ b/src/Numos.Headless/Numos.Headless.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + Exe + Numos.Headless + Numos.Headless + + + + + + + + diff --git a/src/Numos.Headless/Program.cs b/src/Numos.Headless/Program.cs new file mode 100644 index 0000000..b99662d --- /dev/null +++ b/src/Numos.Headless/Program.cs @@ -0,0 +1,15 @@ +namespace Numos.Headless; + +internal static class Program +{ + private static Task Main(string[] args) + { + return HeadlessApplication.RunAsync( + args, + Console.In, + Console.Out, + Console.Error, + CancellationToken.None); + } +} + diff --git a/src/Numos.Headless/Properties/AssemblyInfo.cs b/src/Numos.Headless/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..085bbe1 --- /dev/null +++ b/src/Numos.Headless/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Numos.Headless.Tests")] + diff --git a/src/Numos.Headless/Protocol/HeadlessJsonContext.cs b/src/Numos.Headless/Protocol/HeadlessJsonContext.cs new file mode 100644 index 0000000..3fd33f9 --- /dev/null +++ b/src/Numos.Headless/Protocol/HeadlessJsonContext.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Numos.Headless.Diagnostics; + +namespace Numos.Headless.Protocol; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] +[JsonSerializable(typeof(HeadlessRequest))] +[JsonSerializable(typeof(HeadlessResponse))] +[JsonSerializable(typeof(SimulationStateReport))] +[JsonSerializable(typeof(ConfigurationPatch))] +[JsonSerializable(typeof(SimulationConfigurationReport))] +internal sealed partial class HeadlessJsonContext : JsonSerializerContext; diff --git a/src/Numos.Headless/Protocol/HeadlessRequest.cs b/src/Numos.Headless/Protocol/HeadlessRequest.cs new file mode 100644 index 0000000..a335037 --- /dev/null +++ b/src/Numos.Headless/Protocol/HeadlessRequest.cs @@ -0,0 +1,152 @@ +using Numos.CoreSim; + +namespace Numos.Headless.Protocol; + +/// +/// One versioned JSONL request accepted by the headless simulation host. +/// +internal sealed class HeadlessRequest +{ + public int? ProtocolVersion { get; init; } + public string? Id { get; init; } + public string? Op { get; init; } + + // Simulation construction and configuration. + public string? Name { get; init; } + public Coordinate? Dimensions { get; init; } + public ConfigurationPatch? Config { get; init; } + public GasDefinition[]? Gases { get; init; } + + // Chunk and voxel addressing. + public Coordinate? Position { get; init; } + public Coordinate? Voxel { get; init; } + public int? Classification { get; init; } + public int? RoomId { get; init; } + + // Gas operations. + public GasDefinition? Gas { get; init; } + public int? GasId { get; init; } + public float? Moles { get; init; } + public float? TemperatureK { get; init; } + + // Stepping and solver isolation. + public int? Count { get; init; } + public string? Solver { get; init; } + public bool? Enabled { get; init; } + + // Observation detail. Summary data is always returned by observe. + public bool? IncludeVoxels { get; init; } + public bool? OnlyGasBearingVoxels { get; init; } + public int? MaxIssueLocations { get; init; } +} + +/// JSON representation of one gas registry entry. +internal sealed class GasDefinition +{ + public string? Name { get; init; } + public float? MolarHeatCapacityAtConstantVolume { get; init; } + public float? BoilingPointK { get; init; } + public bool? CondensationEnabled { get; init; } + public float? MolarEnthalpyOfVaporization { get; init; } + public int? LiquidId { get; init; } + public float? DiffusionCoefficient { get; init; } + + internal GasProperties ToGasProperties() + { + if (string.IsNullOrWhiteSpace(Name)) + throw new HeadlessRequestException("invalidGas", "Every gas requires a non-empty name."); + + return new GasProperties + { + Name = Name.Trim(), + MolarHeatCapacityAtConstantVolume = MolarHeatCapacityAtConstantVolume ?? 0f, + BoilingPoint = BoilingPointK ?? 0f, + CondensationEnabled = CondensationEnabled ?? false, + MolarEnthalpyOfVaporization = MolarEnthalpyOfVaporization ?? 0f, + LiquidId = LiquidId ?? -1, + DiffusionCoefficient = DiffusionCoefficient ?? 0f + }; + } +} + +/// +/// Optional configuration values. Missing properties preserve the current Numos default or live value. +/// +internal sealed class ConfigurationPatch +{ + public float? GlobalTemperatureK { get; init; } + public float? DefaultTemperatureFallbackK { get; init; } + public float? DefaultMolarHeatCapacityAtConstantVolume { get; init; } + public float? VoxelVolumeM3 { get; init; } + public float? SaturationReferencePressurePa { get; init; } + public float? DefaultDiffusionCoefficient { get; init; } + public float? SpaceTemperatureK { get; init; } + public float? BulkFlowCoefficient { get; init; } + public float? BulkFlowDamping { get; init; } + public float? LowPressureDeltaThresholdPa { get; init; } + public float? MinimumPressureTransferPa { get; init; } + public float? VacuumThresholdPa { get; init; } + public int? SleepThreshold { get; init; } + public float? SleepEpsilonPa { get; init; } + public float? VoxelSnapPressureRelativeEpsilon { get; init; } + public bool? VoxelSnappingEnabled { get; init; } + public float? VoxelSnapTemperatureEpsilonK { get; init; } + public float? VoxelSnapMoleFractionEpsilon { get; init; } + public float? ThermalConductance { get; init; } + public float? CondensationRateFactor { get; init; } + public float? MaxPressureTransferFractionPerNeighbor { get; init; } + public float? AccumulatorWakeThresholdPa { get; init; } + public int? AccumulatorMaxAliveTicks { get; init; } + + internal void ApplyTo(AtmosConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + if (GlobalTemperatureK.HasValue) + config.GlobalTemperature = GlobalTemperatureK.Value; + if (DefaultTemperatureFallbackK.HasValue) + config.DefaultTemperatureFallback = DefaultTemperatureFallbackK.Value; + if (DefaultMolarHeatCapacityAtConstantVolume.HasValue) + config.DefaultMolarHeatCapacityAtConstantVolume = DefaultMolarHeatCapacityAtConstantVolume.Value; + if (VoxelVolumeM3.HasValue) + config.VoxelVolume = VoxelVolumeM3.Value; + if (SaturationReferencePressurePa.HasValue) + config.SaturationReferencePressure = SaturationReferencePressurePa.Value; + if (DefaultDiffusionCoefficient.HasValue) + config.DefaultDiffusionCoefficient = DefaultDiffusionCoefficient.Value; + if (SpaceTemperatureK.HasValue) + config.SpaceTemperature = SpaceTemperatureK.Value; + if (BulkFlowCoefficient.HasValue) + config.BulkFlowCoefficient = BulkFlowCoefficient.Value; + if (BulkFlowDamping.HasValue) + config.BulkFlowDamping = BulkFlowDamping.Value; + if (LowPressureDeltaThresholdPa.HasValue) + config.LowPressureDeltaThreshold = LowPressureDeltaThresholdPa.Value; + if (MinimumPressureTransferPa.HasValue) + config.MinimumPressureTransfer = MinimumPressureTransferPa.Value; + if (VacuumThresholdPa.HasValue) + config.VacuumThreshold = VacuumThresholdPa.Value; + if (SleepThreshold.HasValue) + config.SleepThreshold = SleepThreshold.Value; + if (SleepEpsilonPa.HasValue) + config.SleepEpsilon = SleepEpsilonPa.Value; + if (VoxelSnapPressureRelativeEpsilon.HasValue) + config.VoxelSnapPressureRelativeEpsilon = VoxelSnapPressureRelativeEpsilon.Value; + if (VoxelSnappingEnabled.HasValue) + config.VoxelSnappingEnabled = VoxelSnappingEnabled.Value; + if (VoxelSnapTemperatureEpsilonK.HasValue) + config.VoxelSnapTemperatureEpsilon = VoxelSnapTemperatureEpsilonK.Value; + if (VoxelSnapMoleFractionEpsilon.HasValue) + config.VoxelSnapMoleFractionEpsilon = VoxelSnapMoleFractionEpsilon.Value; + if (ThermalConductance.HasValue) + config.ThermalConductance = ThermalConductance.Value; + if (CondensationRateFactor.HasValue) + config.CondensationRateFactor = CondensationRateFactor.Value; + if (MaxPressureTransferFractionPerNeighbor.HasValue) + config.MaxPressureTransferFractionPerNeighbor = MaxPressureTransferFractionPerNeighbor.Value; + if (AccumulatorWakeThresholdPa.HasValue) + config.AccumulatorWakeThreshold = AccumulatorWakeThresholdPa.Value; + if (AccumulatorMaxAliveTicks.HasValue) + config.AccumulatorMaxAliveTicks = AccumulatorMaxAliveTicks.Value; + } +} diff --git a/src/Numos.Headless/Protocol/HeadlessResponse.cs b/src/Numos.Headless/Protocol/HeadlessResponse.cs new file mode 100644 index 0000000..7c82e20 --- /dev/null +++ b/src/Numos.Headless/Protocol/HeadlessResponse.cs @@ -0,0 +1,47 @@ +using Numos.Headless.Diagnostics; + +namespace Numos.Headless.Protocol; + +/// One compact JSON object emitted for an input request. +internal sealed class HeadlessResponse +{ + public int ProtocolVersion { get; init; } = HeadlessCommandHost.ProtocolVersion; + public string? Id { get; init; } + public string? Op { get; init; } + public bool Ok { get; init; } + public SessionState? State { get; init; } + public CommandResult? Result { get; init; } + public SimulationStateReport? Observation { get; init; } + public HeadlessError? Error { get; init; } +} + +internal sealed class SessionState +{ + public required string Name { get; init; } + public required Coordinate Dimensions { get; init; } + public required int Tick { get; init; } + public required int ChunkCount { get; init; } + public required int GasCount { get; init; } +} + +internal sealed class CommandResult +{ + public int? TicksExecuted { get; init; } + public int? GasId { get; init; } + public Coordinate? Position { get; init; } + public bool? Changed { get; init; } +} + +internal sealed class HeadlessError +{ + public required string Code { get; init; } + public required string Message { get; init; } + public string? ExceptionType { get; init; } + public int? Line { get; init; } +} + +internal sealed class HeadlessRequestException(string code, string message) : Exception(message) +{ + internal string Code { get; } = code; +} + diff --git a/src/Numos.Headless/SimulationSession.cs b/src/Numos.Headless/SimulationSession.cs new file mode 100644 index 0000000..79b18a3 --- /dev/null +++ b/src/Numos.Headless/SimulationSession.cs @@ -0,0 +1,353 @@ +using Numos.API; +using Numos.CoreSim; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.Headless.Diagnostics; +using Numos.Headless.Protocol; +using Numos.Maths; + +namespace Numos.Headless; + +/// +/// Owns the in-memory simulation manipulated by one JSONL host connection. +/// +internal sealed class SimulationSession : IDisposable +{ + private const int MaximumTicksPerRequest = 1_000_000; + + private AtmosSimulation? _simulation; + private AtmosConfig? _config; + private Coordinate? _dimensions; + private string? _name; + + internal bool HasSimulation => _simulation != null; + + internal CommandExecution Execute(HeadlessRequest request) + { + return request.Op switch + { + "createSimulation" => CreateSimulation(request), + "closeSimulation" => CloseSimulation(), + "addChunk" => AddChunk(request), + "removeChunk" => RemoveChunk(request), + "sealChunk" => SealChunk(request), + "setChunkClassification" => SetChunkClassification(request), + "setVoxelClassification" => SetVoxelClassification(request), + "setVoxelTemperature" => SetVoxelTemperature(request), + "addGas" => AddGas(request), + "injectGas" => InjectGas(request), + "wakeRoom" => WakeRoom(request), + "sleepChunk" => SleepChunk(request), + "updateConfig" => UpdateConfig(request), + "setSolverEnabled" => SetSolverEnabled(request), + "resetSolvers" => ResetSolvers(), + "tick" => Tick(request), + "observe" => Observe(request), + "exit" => Exit(), + _ => throw new HeadlessRequestException( + "unknownOperation", + $"Unknown operation '{request.Op}'. See docs/headless_runner.md for supported operations.") + }; + } + + internal SessionState? GetState() + { + if (_simulation == null || _config == null || _dimensions == null || _name == null) + return null; + + return new SessionState + { + Name = _name, + Dimensions = _dimensions.Value, + Tick = _simulation.TickCount, + ChunkCount = _simulation.ChunkCount, + GasCount = _config.GasRegistry.Count + }; + } + + public void Dispose() + { + _simulation?.Dispose(); + _simulation = null; + _config = null; + _dimensions = null; + _name = null; + } + + private CommandExecution CreateSimulation(HeadlessRequest request) + { + Coordinate dimensions = Require(request.Dimensions, "dimensions"); + var config = new AtmosConfig(); + request.Config?.ApplyTo(config); + if (request.Gases != null) + { + foreach (GasDefinition gas in request.Gases) + { + if (gas == null) + throw new HeadlessRequestException("invalidGas", "The gases array cannot contain null entries."); + config.GasRegistry.Add(gas.ToGasProperties()); + } + } + + AtmosSimulation? replacement = null; + try + { + replacement = new AtmosSimulation(config, dimensions.X, dimensions.Y, dimensions.Z); + Dispose(); + _simulation = replacement; + _config = config; + _dimensions = new Coordinate(dimensions.X, dimensions.Y, dimensions.Z); + _name = string.IsNullOrWhiteSpace(request.Name) + ? "Untitled Simulation" + : request.Name.Trim(); + replacement = null; + } + finally + { + replacement?.Dispose(); + } + + return new CommandExecution(); + } + + private CommandExecution CloseSimulation() + { + Dispose(); + return new CommandExecution(); + } + + private CommandExecution AddChunk(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + simulation.CreateAndRegisterChunk( + ToInt3(position), + AtmosChunkConstants.DefaultMaxActiveRooms, + new VoxelClassification(request.Classification ?? VoxelClassification.RoomUnassigned)); + return new CommandExecution(new CommandResult { Position = Copy(position) }); + } + + private CommandExecution RemoveChunk(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + bool changed = simulation.UnregisterChunk(Handle(position)); + return new CommandExecution(new CommandResult + { + Position = Copy(position), + Changed = changed + }); + } + + private CommandExecution SealChunk(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + simulation.SetChunkBoundaryClassification(Handle(position), VoxelClassification.RoomSolid); + return new CommandExecution(new CommandResult { Position = Copy(position) }); + } + + private CommandExecution SetChunkClassification(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + int classification = Require(request.Classification, "classification"); + simulation.SetChunkClassification(Handle(position), new VoxelClassification(classification)); + return new CommandExecution(new CommandResult { Position = Copy(position) }); + } + + private CommandExecution SetVoxelClassification(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + Coordinate voxel = Require(request.Voxel, "voxel"); + int classification = Require(request.Classification, "classification"); + simulation.SetVoxelClassification( + Handle(position), + voxel.X, + voxel.Y, + voxel.Z, + new VoxelClassification(classification)); + return new CommandExecution(); + } + + private CommandExecution SetVoxelTemperature(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + Coordinate voxel = Require(request.Voxel, "voxel"); + float temperature = Require(request.TemperatureK, "temperatureK"); + simulation.SetVoxelTemperature(Handle(position), voxel.X, voxel.Y, voxel.Z, temperature); + return new CommandExecution(); + } + + private CommandExecution AddGas(HeadlessRequest request) + { + _ = RequireSimulation(); + AtmosConfig config = _config!; + GasDefinition definition = Require(request.Gas, "gas"); + int gasId = config.GasRegistry.Count; + config.GasRegistry.Add(definition.ToGasProperties()); + return new CommandExecution(new CommandResult { GasId = gasId }); + } + + private CommandExecution InjectGas(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + Coordinate voxel = Require(request.Voxel, "voxel"); + int gasId = Require(request.GasId, "gasId"); + if (gasId < 0 || gasId >= _config!.GasRegistry.Count) + { + throw new HeadlessRequestException( + "gasNotFound", + $"No gas is registered with ID {gasId}."); + } + float moles = Require(request.Moles, "moles"); + float temperature = Require(request.TemperatureK, "temperatureK"); + simulation.AddGasToVoxel( + Handle(position), + voxel.X, + voxel.Y, + voxel.Z, + gasId, + moles, + temperature); + return new CommandExecution(); + } + + private CommandExecution WakeRoom(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + int roomId = Require(request.RoomId, "roomId"); + simulation.WakeRoom(Handle(position), roomId); + return new CommandExecution(); + } + + private CommandExecution SleepChunk(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + Coordinate position = Require(request.Position, "position"); + simulation.SleepChunk(Handle(position)); + return new CommandExecution(); + } + + private CommandExecution UpdateConfig(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + ConfigurationPatch patch = Require(request.Config, "config"); + patch.ApplyTo(_config!); + simulation.SetAtmosConfig(_config!); + return new CommandExecution(); + } + + private CommandExecution SetSolverEnabled(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + if (string.IsNullOrWhiteSpace(request.Solver)) + throw Missing("solver"); + bool enabled = Require(request.Enabled, "enabled"); + bool changed = simulation.Solvers.SetEnabled(request.Solver, enabled); + if (!changed) + { + throw new HeadlessRequestException( + "solverNotFound", + $"No solver stage named '{request.Solver}' is registered."); + } + + return new CommandExecution(new CommandResult { Changed = true }); + } + + private CommandExecution ResetSolvers() + { + AtmosSimulation simulation = RequireSimulation(); + simulation.Solvers.ResetToDefaults(); + return new CommandExecution(); + } + + private CommandExecution Tick(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + int count = Require(request.Count, "count"); + if (count <= 0 || count > MaximumTicksPerRequest) + { + throw new HeadlessRequestException( + "invalidTickCount", + $"count must be between 1 and {MaximumTicksPerRequest}."); + } + + for (var index = 0; index < count; index++) + simulation.Tick(); + + return new CommandExecution(new CommandResult { TicksExecuted = count }); + } + + private CommandExecution Observe(HeadlessRequest request) + { + AtmosSimulation simulation = RequireSimulation(); + if (request.Voxel != null && request.Position == null) + throw new HeadlessRequestException("missingProperty", "voxel requires a chunk position."); + + var options = new SimulationObservationOptions + { + Chunk = request.Position, + Voxels = request.Voxel == null + ? null + : [new VoxelSelection(request.Position!, request.Voxel)], + // A local voxel is an exact probe; it takes precedence over a simultaneous dense-output flag. + IncludeVoxels = request.Voxel == null && (request.IncludeVoxels ?? false), + OnlyGasBearingVoxels = request.OnlyGasBearingVoxels ?? false, + MaxIssueLocations = request.MaxIssueLocations ?? SimulationObservationOptions.DefaultMaxIssueLocations + }; + var observation = SimulationStateAnalyzer.Analyze(simulation, _config!, options); + return new CommandExecution(Observation: observation); + } + + private CommandExecution Exit() + { + Dispose(); + return new CommandExecution(ExitRequested: true); + } + + private AtmosSimulation RequireSimulation() + { + return _simulation ?? throw new HeadlessRequestException( + "simulationNotCreated", + "Create a simulation before running this operation."); + } + + private static T Require(T? value, string property) where T : class + { + return value ?? throw Missing(property); + } + + private static T Require(T? value, string property) where T : struct + { + return value ?? throw Missing(property); + } + + private static HeadlessRequestException Missing(string property) + { + return new HeadlessRequestException("missingProperty", $"The '{property}' property is required."); + } + + private static AtmosChunkHandle Handle(Coordinate position) + { + return new AtmosChunkHandle(ToInt3(position)); + } + + private static Int3 ToInt3(Coordinate value) + { + return new Int3(value.X, value.Y, value.Z); + } + + private static Coordinate Copy(Coordinate value) + { + return new Coordinate(value.X, value.Y, value.Z); + } +} + +internal sealed record CommandExecution( + CommandResult? Result = null, + SimulationStateReport? Observation = null, + bool ExitRequested = false); diff --git a/src/Numos.Viewer/SimulationViewer.RenderUi.cs b/src/Numos.Viewer/SimulationViewer.RenderUi.cs index c1e1954..9f8a54e 100644 --- a/src/Numos.Viewer/SimulationViewer.RenderUi.cs +++ b/src/Numos.Viewer/SimulationViewer.RenderUi.cs @@ -531,13 +531,38 @@ private void RenderConfigurationPanel() "Below this pressure, voxel contents are zeroed out.")) _config.VacuumThreshold = vacuumThreshold; int sleepThreshold = _config.SleepThreshold; - if (ConfigSlider("Sleep Threshold", "config-sleep-threshold", ref sleepThreshold, 1, 1000, - "Consecutive ticks below Sleep Epsilon before a chunk goes to sleep.")) + if (ConfigSlider("Sleep Threshold", "config-sleep-threshold", ref sleepThreshold, 0, 1000, + "Consecutive stable verification ticks required before automatic sleep. Snap-assisted sleep " + + "always observes at least one complete thermodynamics cadence.")) _config.SleepThreshold = sleepThreshold; float sleepEpsilon = _config.SleepEpsilon; - if (ConfigSlider("Sleep Epsilon", "config-sleep-epsilon", ref sleepEpsilon, 0f, 100f, - "Maximum pressure delta considered at rest.")) + if (ConfigSlider("Absolute Sleep Epsilon (Pa)", "config-sleep-epsilon", ref sleepEpsilon, 0f, 100f, + "Absolute floor for the maximum pressure correction allowed during voxel snapping. " + + "When snapping is disabled, this is the legacy neighboring pressure-delta threshold.")) _config.SleepEpsilon = sleepEpsilon; + float voxelSnapPressureRelativeEpsilon = _config.VoxelSnapPressureRelativeEpsilon; + if (ConfigSlider("Voxel Snap Relative Pressure Epsilon", + "config-voxel-snap-pressure-relative-epsilon", + ref voxelSnapPressureRelativeEpsilon, 0f, 0.01f, + "Maximum pressure correction as a fraction of the current/equilibrium pressure scale; " + + "0.001 means 0.1%. The larger of this limit and the absolute sleep epsilon is used.")) + _config.VoxelSnapPressureRelativeEpsilon = voxelSnapPressureRelativeEpsilon; + bool voxelSnappingEnabled = _config.VoxelSnappingEnabled; + if (ConfigCheckbox("Voxel Snapping Enabled", "config-voxel-snapping-enabled", + ref voxelSnappingEnabled, + "Conservatively projects settled neighboring voxels toward uniform species and energy state. " + + "Disabling this bypasses the projection but does not disable automatic or manual sleeping.")) + _config.VoxelSnappingEnabled = voxelSnappingEnabled; + float voxelSnapTemperatureEpsilon = _config.VoxelSnapTemperatureEpsilon; + if (ConfigSlider("Voxel Snap Temperature Epsilon (K)", "config-voxel-snap-temperature-epsilon", + ref voxelSnapTemperatureEpsilon, 0f, 10f, + "Maximum temperature correction, in kelvins, allowed for each member of a proposed aggregate.")) + _config.VoxelSnapTemperatureEpsilon = voxelSnapTemperatureEpsilon; + float voxelSnapMoleFractionEpsilon = _config.VoxelSnapMoleFractionEpsilon; + if (ConfigSlider("Voxel Snap Mole-Fraction Epsilon", "config-voxel-snap-mole-fraction-epsilon", + ref voxelSnapMoleFractionEpsilon, 0f, 1f, + "Maximum per-species mole-fraction correction allowed for each proposed member (0 to 1).")) + _config.VoxelSnapMoleFractionEpsilon = voxelSnapMoleFractionEpsilon; float thermalConductance = _config.ThermalConductance; if (ConfigSlider("Thermal Conductance", "config-thermal-conductance", ref thermalConductance, 0f, 1f, "Per-face energy conductance in J/K per thermodynamics tick.")) @@ -590,6 +615,10 @@ private void ResetConfigurationValues() _config.VacuumThreshold = defaults.VacuumThreshold; _config.SleepThreshold = defaults.SleepThreshold; _config.SleepEpsilon = defaults.SleepEpsilon; + _config.VoxelSnapPressureRelativeEpsilon = defaults.VoxelSnapPressureRelativeEpsilon; + _config.VoxelSnappingEnabled = defaults.VoxelSnappingEnabled; + _config.VoxelSnapTemperatureEpsilon = defaults.VoxelSnapTemperatureEpsilon; + _config.VoxelSnapMoleFractionEpsilon = defaults.VoxelSnapMoleFractionEpsilon; _config.ThermalConductance = defaults.ThermalConductance; _config.CondensationRateFactor = defaults.CondensationRateFactor; _config.MaxPressureTransferFractionPerNeighbor = defaults.MaxPressureTransferFractionPerNeighbor; @@ -613,6 +642,13 @@ private static bool ConfigSlider(string label, string id, ref int value, int min return changed; } + private static bool ConfigCheckbox(string label, string id, ref bool value, string tooltip) + { + bool changed = ImGui.Checkbox($"{label} (?)##{id}", ref value); + SetConfigTooltip(tooltip); + return changed; + } + private static void SetConfigTooltip(string tooltip) { if (ImGui.IsItemHovered()) diff --git a/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs b/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs index 73364a3..2d69fbe 100644 --- a/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs +++ b/tests/Numos.API.Dangerous.Tests/AtmosDangerousApiTests.cs @@ -88,6 +88,32 @@ public void DangerousInjection_UsesCurrentVoxelShcFromTickSnapshot() Assert.That(snapshot.Temperature[0], Is.EqualTo(525f).Within(0.0001f)); } + [Test] + public void DangerousInjection_IntoActivePassableClosureDoesNotConsumeAnotherRoomSlot() + { + var config = new Numos.CoreSim.AtmosConfig + { + BulkFlowCoefficient = 0f, + DefaultDiffusionCoefficient = 0f, + VoxelSnappingEnabled = false, + SleepThreshold = int.MaxValue + }; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default, maxActiveRooms: 1); + simulation.SetChunkClassification(chunk, + Numos.CoreSim.Datatypes.Primitives.VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, + new Numos.CoreSim.Datatypes.Primitives.VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 1, + new Numos.CoreSim.Datatypes.Primitives.VoxelClassification(2)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.Dangerous().Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "inject-closure", + context => context.InjectGasToVoxel(0, 1, 0, 1f, 300f)); + + Assert.That(simulation.Tick, Throws.Nothing); + Assert.That(simulation.GetVoxelSnapshot(chunk, 1).Gases.Single().Moles, Is.EqualTo(1f)); + } + [Test] public void ConfiguredDangerousSolver_RetainsEditableTypedConfiguration() { @@ -141,4 +167,4 @@ private sealed class DangerousInjectionSolverConfig { internal float Moles { get; set; } } -} \ No newline at end of file +} diff --git a/tests/Numos.API.Tests/AtmosChunkVersionTests.cs b/tests/Numos.API.Tests/AtmosChunkVersionTests.cs index cfd88df..648ad10 100644 --- a/tests/Numos.API.Tests/AtmosChunkVersionTests.cs +++ b/tests/Numos.API.Tests/AtmosChunkVersionTests.cs @@ -290,7 +290,10 @@ public void TryGetChunkSnapshot_ThermalFlowIntoSleepingNeighbor_AdvancesNeighbor Assert.Multiple(() => { Assert.That(created, Is.True); - Assert.That(after.IsAwake, Is.False); + Assert.That(before.IsAwake, Is.False); + Assert.That(after.IsAwake, Is.True, + "A nonzero thermal-boundary transfer must wake the receiving chunk."); + Assert.That(after.SleepTimer, Is.Zero); Assert.That(after.Temperature[0], Is.GreaterThan(before.Temperature[0])); }); } diff --git a/tests/Numos.API.Tests/AtmosSimulationContractTests.cs b/tests/Numos.API.Tests/AtmosSimulationContractTests.cs index ed09e90..0eb6e7e 100644 --- a/tests/Numos.API.Tests/AtmosSimulationContractTests.cs +++ b/tests/Numos.API.Tests/AtmosSimulationContractTests.cs @@ -340,6 +340,283 @@ public void ClassificationControlsWhetherGasCanBeAdded() }); } + [Test] + public void AutomaticSleep_LocalMutationResumesEntireRetainedMultiRoomDomain() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + BulkFlowCoefficient = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = float.MaxValue, + VoxelSnappingEnabled = false + }; + using var simulation = new AtmosSimulation(config, 5, 1, 1); + var automatic = simulation.CreateAndRegisterChunk( + default, 2, VoxelClassification.RoomSolid); + var manual = simulation.CreateAndRegisterChunk( + new Int3(2, 0, 0), 2, VoxelClassification.RoomSolid); + + foreach (var chunk in new[] { automatic, manual }) + { + simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 1, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 3, new VoxelClassification(2)); + simulation.SetVoxelClassification(chunk, 4, new VoxelClassification(2)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(chunk, 1, 1, 1f, 300f); + simulation.AddGasToVoxel(chunk, 3, 0, 1f, 300f); + simulation.AddGasToVoxel(chunk, 4, 1, 1f, 300f); + } + + simulation.Tick(); + Assert.Multiple(() => + { + Assert.That(simulation.GetChunkSnapshot(automatic).IsAwake, Is.False); + Assert.That(simulation.GetChunkSnapshot(automatic).ActiveAirCount, Is.EqualTo(4)); + Assert.That(simulation.GetChunkSnapshot(manual).IsAwake, Is.False); + Assert.That(simulation.GetChunkSnapshot(manual).ActiveAirCount, Is.EqualTo(4)); + }); + + simulation.SleepChunk(manual); + simulation.SetVoxelTemperature(automatic, 0, 301f); + simulation.SetVoxelTemperature(manual, 0, 301f); + + Assert.Multiple(() => + { + Assert.That(simulation.GetChunkSnapshot(automatic).IsAwake, Is.True); + Assert.That(simulation.GetChunkSnapshot(automatic).ActiveAirCount, Is.EqualTo(4), + "A local mutation in one retained room must resume the complete auto-slept domain."); + Assert.That(simulation.GetChunkSnapshot(manual).IsAwake, Is.False, + "An explicit sleeper must remain frozen after a local temperature mutation."); + }); + + config.DefaultDiffusionCoefficient = 1f; + simulation.Tick(); + + var automaticAfter = simulation.GetChunkSnapshot(automatic); + var manualAfter = simulation.GetChunkSnapshot(manual); + Assert.Multiple(() => + { + Assert.That(automaticAfter.Gases.Single(gas => gas.GasId == 0).Moles, + Is.EqualTo(new[] { 0.5f, 0.5f, 0f, 0.5f, 0.5f })); + Assert.That(automaticAfter.Gases.Single(gas => gas.GasId == 1).Moles, + Is.EqualTo(new[] { 0.5f, 0.5f, 0f, 0.5f, 0.5f })); + Assert.That(manualAfter.IsAwake, Is.False); + Assert.That(manualAfter.Gases.Single(gas => gas.GasId == 0).Moles, + Is.EqualTo(new[] { 1f, 0f, 0f, 1f, 0f })); + Assert.That(manualAfter.Gases.Single(gas => gas.GasId == 1).Moles, + Is.EqualTo(new[] { 0f, 1f, 0f, 0f, 1f })); + }); + } + + [Test] + public void AutomaticSleep_NewComponentWakePreservesRetainedDomainWithoutActivatingUnrelatedComponents() + { + var config = new AtmosConfig + { + VacuumThreshold = 0f, + BulkFlowCoefficient = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = float.MaxValue, + VoxelSnappingEnabled = false + }; + using var simulation = new AtmosSimulation(config, 9, 1, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, 3, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 1, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 3, new VoxelClassification(2)); + simulation.SetVoxelClassification(chunk, 4, new VoxelClassification(2)); + simulation.SetVoxelClassification(chunk, 6, new VoxelClassification(3)); + simulation.SetVoxelClassification(chunk, 8, new VoxelClassification(4)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(chunk, 3, 0, 1f, 300f); + simulation.Tick(); + + Assert.That(simulation.GetChunkSnapshot(chunk).ActiveAirCount, Is.EqualTo(4)); + + simulation.SetVoxelTemperature(chunk, 6, 300f); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(snapshot.IsAwake, Is.True); + Assert.That(snapshot.ActiveAirCount, Is.EqualTo(5), + "A new target joins the retained domain instead of replacing it."); + Assert.That(snapshot.Temperature[8], Is.Zero, + "An unrelated never-active component must not be activated by the wake."); + }); + } + + [Test] + public void AutomaticSleep_NewRoomCapacityFailureLeavesCanisterAndChunkUnchanged() + { + var config = new AtmosConfig + { + VacuumThreshold = 0f, + BulkFlowCoefficient = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = float.MaxValue, + VoxelSnappingEnabled = false + }; + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, 1, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 2, new VoxelClassification(2)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.Tick(); + + var canister = simulation.CreateGasMixture(1f, 300f); + canister.AddGas(0, 1f, 300f); + var target = simulation.GetVoxelGasMixture(chunk, 2); + var chunkBefore = simulation.GetChunkSnapshot(chunk); + var canisterBefore = canister.GetSnapshot(); + + Assert.That(() => canister.TransferTo(target, 1f), Throws.InvalidOperationException); + + var chunkAfter = simulation.GetChunkSnapshot(chunk); + var canisterAfter = canister.GetSnapshot(); + Assert.Multiple(() => + { + Assert.That(chunkBefore.IsAwake, Is.False); + Assert.That(chunkBefore.ActiveAirCount, Is.EqualTo(1)); + Assert.That(chunkAfter.Version, Is.EqualTo(chunkBefore.Version)); + Assert.That(chunkAfter.IsAwake, Is.False); + Assert.That(chunkAfter.ActiveAirCount, Is.EqualTo(chunkBefore.ActiveAirCount)); + Assert.That(chunkAfter.VoxelRoomMap, Is.EqualTo(chunkBefore.VoxelRoomMap)); + Assert.That(chunkAfter.Temperature, Is.EqualTo(chunkBefore.Temperature)); + Assert.That(chunkAfter.TotalPressure, Is.EqualTo(chunkBefore.TotalPressure)); + Assert.That(chunkAfter.Gases[0].Moles, Is.EqualTo(chunkBefore.Gases[0].Moles)); + Assert.That(canisterAfter.Volume, Is.EqualTo(canisterBefore.Volume)); + Assert.That(canisterAfter.Temperature, Is.EqualTo(canisterBefore.Temperature)); + Assert.That(canisterAfter.TotalMoles, Is.EqualTo(canisterBefore.TotalMoles)); + Assert.That(canisterAfter.Gases, Is.EqualTo(canisterBefore.Gases)); + }); + } + + [Test] + public void AutomaticSleep_RegistrationCapacityFailureRollsBackWithoutPartialWake() + { + var config = new AtmosConfig + { + VacuumThreshold = 0f, + BulkFlowCoefficient = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = float.MaxValue, + VoxelSnappingEnabled = false + }; + using var simulation = new AtmosSimulation(config, 1, 5, 1); + var existing = simulation.CreateAndRegisterChunk( + default, 2, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(existing, 0, 2, 0, new VoxelClassification(3)); + simulation.AddGasToVoxel(existing, 0, 2, 0, 0, 1f, 300f); + simulation.SleepChunk(existing); + simulation.SetVoxelClassification(existing, 0, 0, 0, new VoxelClassification(1)); + simulation.AddGasToVoxel(existing, 0, 0, 0, 0, 1f, 300f); + simulation.SetVoxelClassification(existing, 0, 4, 0, new VoxelClassification(2)); + simulation.WakeRoom(existing, 2); + simulation.Tick(); + var before = simulation.GetChunkSnapshot(existing); + + Assert.That( + () => simulation.CreateAndRegisterChunk( + new Int3(1, 0, 0), 1, new VoxelClassification(4)), + Throws.InvalidOperationException); + + var after = simulation.GetChunkSnapshot(existing); + Assert.Multiple(() => + { + Assert.That(before.IsAwake, Is.False); + Assert.That(before.ActiveAirCount, Is.EqualTo(2)); + Assert.That(simulation.ChunkCount, Is.EqualTo(1)); + Assert.That(after.Version, Is.EqualTo(before.Version)); + Assert.That(after.IsAwake, Is.False); + Assert.That(after.ActiveAirCount, Is.EqualTo(before.ActiveAirCount)); + Assert.That(after.VoxelRoomMap, Is.EqualTo(before.VoxelRoomMap)); + Assert.That(after.Temperature, Is.EqualTo(before.Temperature)); + Assert.That(after.TotalPressure, Is.EqualTo(before.TotalPressure)); + Assert.That(after.Gases.Length, Is.EqualTo(before.Gases.Length)); + Assert.That(after.Gases[0].Moles, Is.EqualTo(before.Gases[0].Moles)); + }); + } + + [Test] + public void AutomaticSleep_ConfigInvalidationCompactsRemovedRetainedSeeds() + { + var config = new AtmosConfig + { + VacuumThreshold = 0f, + BulkFlowCoefficient = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = float.MaxValue, + VoxelSnappingEnabled = false + }; + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, 1, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 2, new VoxelClassification(2)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.Tick(); + Assert.That(simulation.GetChunkSnapshot(chunk).IsAwake, Is.False); + + simulation.SetVoxelClassification(chunk, 0, VoxelClassification.RoomSolid); + config.DefaultDiffusionCoefficient = 0.25f; + simulation.Tick(); + + Assert.DoesNotThrow(() => simulation.WakeRoom(chunk, 2)); + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(after.IsAwake, Is.True); + Assert.That(after.ActiveAirCount, Is.EqualTo(1)); + Assert.That(after.VoxelRoomMap[2], Is.EqualTo(2)); + }); + } + + [Test] + public void SleepingTopologyEdit_DoesNotActivateAnUnrelatedEmptyComponent() + { + using var simulation = new AtmosSimulation(3, 1, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, 1, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 1, VoxelClassification.RoomVoid); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.SleepChunk(chunk); + + Assert.That(() => simulation.SetVoxelClassification(chunk, 2, new VoxelClassification(2)), + Throws.Nothing); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(snapshot.IsAwake, Is.False); + Assert.That(snapshot.VoxelRoomMap, + Is.EqualTo(new[] { 1, VoxelClassification.RoomVoid, 2 })); + Assert.That(snapshot.Gases.Single().Moles, + Is.EqualTo(new[] { 1f, 0f, 0f })); + }); + } + [Test] public void AddGasToVoxel_MixesUnequalMolarHeatCapacitiesBySensibleEnergy() { @@ -562,6 +839,66 @@ public void AddGasToVoxel_RejectsInvalidPhysicalInputs() Assert.That(simulation.GetChunkSnapshot(chunk).Gases, Is.Empty); } + [Test] + public void AddGasToVoxel_ResultOverflowIsRejectedWithoutWakingOrMutatingState() + { + var config = new AtmosConfig + { + VoxelVolume = float.MaxValue, + DefaultMolarHeatCapacityAtConstantVolume = float.Epsilon + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(7)); + simulation.AddGasToVoxel(chunk, 0, 0, float.MaxValue, 1f); + simulation.SleepChunk(chunk); + var before = simulation.GetChunkSnapshot(chunk); + + Assert.That(() => simulation.AddGasToVoxel(chunk, 0, 0, float.MaxValue, 1f), + Throws.TypeOf()); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(float.IsFinite(before.TotalPressure[0]), Is.True); + Assert.That(before.TotalPressure[0], Is.GreaterThan(0f)); + Assert.That(after.Version, Is.EqualTo(before.Version)); + Assert.That(after.IsAwake, Is.False); + Assert.That(after.SleepTimer, Is.EqualTo(before.SleepTimer)); + Assert.That(after.TotalPressure, Is.EqualTo(before.TotalPressure)); + Assert.That(after.Temperature, Is.EqualTo(before.Temperature)); + Assert.That(after.Gases.Length, Is.EqualTo(1)); + Assert.That(after.Gases[0].Moles, Is.EqualTo(before.Gases[0].Moles)); + Assert.That(after.Gases[0].Moles[0], Is.EqualTo(float.MaxValue)); + }); + } + + [Test] + public void SetVoxelTemperature_UnrepresentablePressureIsRejectedWithoutMutation() + { + var config = new AtmosConfig { VoxelVolume = 1f }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.SleepChunk(chunk); + var before = simulation.GetChunkSnapshot(chunk); + + Assert.That(() => simulation.SetVoxelTemperature(chunk, 0, float.MaxValue), + Throws.TypeOf()); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(after.Version, Is.EqualTo(before.Version)); + Assert.That(after.IsAwake, Is.EqualTo(before.IsAwake)); + Assert.That(after.SleepTimer, Is.EqualTo(before.SleepTimer)); + Assert.That(after.Temperature, Is.EqualTo(before.Temperature)); + Assert.That(after.TotalPressure, Is.EqualTo(before.TotalPressure)); + Assert.That(after.Gases[0].Moles, Is.EqualTo(before.Gases[0].Moles)); + }); + } + [Test] public void GetChunkSnapshot_ReturnsDeepDetachedCopies() { diff --git a/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs b/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs index 8425aaa..f206922 100644 --- a/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs +++ b/tests/Numos.API.Tests/AtmosSolverPipelineTests.cs @@ -215,6 +215,190 @@ public void StandardTemperatureMutation_RefreshesPressureBeforeThermodynamics() Assert.That(simulation.GetVoxelSnapshot(chunk, 0).Temperature, Is.EqualTo(1f)); } + [Test] + public void SnapSleepCoordinator_ObservesCustomStageAtEndOfPipeline() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + BulkFlowCoefficient = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = 0.5f, + VoxelSnappingEnabled = true, + VoxelSnapTemperatureEpsilon = 0.01f, + VoxelSnapMoleFractionEpsilon = 0.001f, + GasRegistry = [new GasProperties { MolarHeatCapacityAtConstantVolume = 1f }] + }; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(chunk, 1, 0, 1f, 300f); + simulation.Solvers.RegisterAfter(AtmosBuiltInSolvers.ThermalBoundary, "late-heating", + context => context.SetVoxelTemperature(chunk, 0, 600f)); + + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(snapshot.IsAwake, Is.True); + Assert.That(snapshot.SleepTimer, Is.Zero); + Assert.That(snapshot.Temperature, Is.EqualTo(new[] { 600f, 300f })); + }); + } + + [Test] + public void ThermodynamicsWithoutAdvection_RefreshesLiveHeatCapacitiesBeforeDiffusion() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + ThermalConductance = 100f, + VoxelSnappingEnabled = false, + SleepThreshold = int.MaxValue, + GasRegistry = + [ + new GasProperties { MolarHeatCapacityAtConstantVolume = 1f }, + new GasProperties { MolarHeatCapacityAtConstantVolume = 1f } + ] + }; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 0, 0, 1f, 400f); + simulation.AddGasToVoxel(chunk, 1, 0, 0, 1, 1f, 200f); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + GasProperties revaluedGas = config.GasRegistry[0]; + revaluedGas.MolarHeatCapacityAtConstantVolume = 9f; + config.GasRegistry[0] = revaluedGas; + + simulation.Tick(); + simulation.Tick(); + + var snapshot = simulation.GetChunkSnapshot(chunk); + double sensibleEnergy = snapshot.Gases[0].Moles[0] * 9d * snapshot.Temperature[0] + + snapshot.Gases[1].Moles[1] * snapshot.Temperature[1]; + Assert.Multiple(() => + { + Assert.That(snapshot.Temperature[0], Is.EqualTo(380f).Within(0.0001f)); + Assert.That(snapshot.Temperature[1], Is.EqualTo(380f).Within(0.0001f)); + Assert.That(sensibleEnergy, Is.EqualTo(3800d).Within(0.001d)); + }); + } + + [TestCase(false)] + [TestCase(true)] + public void ThermalDiffusion_UnrepresentableProjectedPressureDefersConservativeBatch(bool crossChunk) + { + var config = new AtmosConfig + { + VoxelVolume = float.MaxValue, + VacuumThreshold = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = float.MaxValue, + SleepThreshold = int.MaxValue, + VoxelSnappingEnabled = false, + GasRegistry = + [ + new GasProperties + { + MolarHeatCapacityAtConstantVolume = float.Epsilon, + DiffusionCoefficient = 0f + }, + new GasProperties + { + MolarHeatCapacityAtConstantVolume = float.MaxValue, + DiffusionCoefficient = 0f + } + ] + }; + using var simulation = new AtmosSimulation(config, crossChunk ? 1 : 2, 1, 1); + var cold = simulation.CreateAndRegisterChunk(default); + AtmosChunkHandle hot = crossChunk + ? simulation.CreateAndRegisterChunk(Int3.PosX) + : cold; + simulation.SetChunkClassification(cold, new VoxelClassification(1)); + if (crossChunk) + simulation.SetChunkClassification(hot, new VoxelClassification(2)); + simulation.AddGasToVoxel(cold, 0, 0, float.MaxValue, 1f); + simulation.AddGasToVoxel(hot, crossChunk ? (ushort)0 : (ushort)1, 1, 1f, float.MaxValue); + var coldBefore = simulation.GetVoxelSnapshot(cold, 0); + var hotBefore = simulation.GetVoxelSnapshot(hot, crossChunk ? (ushort)0 : (ushort)1); + + simulation.Tick(); + simulation.Tick(); + + var coldAfter = simulation.GetVoxelSnapshot(cold, 0); + var hotAfter = simulation.GetVoxelSnapshot(hot, crossChunk ? (ushort)0 : (ushort)1); + Assert.Multiple(() => + { + Assert.That(coldAfter.Temperature, Is.EqualTo(coldBefore.Temperature)); + Assert.That(hotAfter.Temperature, Is.EqualTo(hotBefore.Temperature)); + Assert.That(coldAfter.Gases.Single(gas => gas.GasId == 0).Moles, + Is.EqualTo(coldBefore.Gases.Single(gas => gas.GasId == 0).Moles)); + Assert.That(hotAfter.Gases.Single(gas => gas.GasId == 1).Moles, + Is.EqualTo(hotBefore.Gases.Single(gas => gas.GasId == 1).Moles)); + Assert.That(float.IsFinite(coldAfter.Pressure), Is.True); + Assert.That(float.IsFinite(hotAfter.Pressure), Is.True); + }); + } + + [Test] + public void Advection_UnrepresentableSimultaneousInflowsAreDeferredWithoutPoisoningState() + { + var config = new AtmosConfig + { + VoxelVolume = float.MaxValue, + DefaultMolarHeatCapacityAtConstantVolume = float.Epsilon, + VacuumThreshold = 0f, + BulkFlowCoefficient = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + ThermalConductance = 0f, + VoxelSnappingEnabled = false, + SleepThreshold = int.MaxValue, + GasRegistry = + [ + new GasProperties + { + MolarHeatCapacityAtConstantVolume = float.Epsilon, + DiffusionCoefficient = 1f + } + ] + }; + using var simulation = new AtmosSimulation(config, 3, 3, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, VoxelClassification.RoomSolid); + var openVoxels = new[] { (1, 1), (1, 0), (0, 1), (2, 1), (1, 2) }; + foreach ((int x, int y) in openVoxels) + simulation.SetVoxelClassification(chunk, x, y, 0, new VoxelClassification(1)); + + float sourceMoles = float.MaxValue * 0.75f; + foreach ((int x, int y) in openVoxels.Skip(1)) + simulation.AddGasToVoxel(chunk, x, y, 0, 0, sourceMoles, 1f); + var before = simulation.GetChunkSnapshot(chunk); + + Assert.That(simulation.Tick, Throws.Nothing); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(after.Gases.Single().Moles, + Is.EqualTo(before.Gases.Single().Moles)); + Assert.That(after.Gases.Single().Moles.All(float.IsFinite), Is.True); + Assert.That(after.TotalPressure.All(float.IsFinite), Is.True); + Assert.That(after.Temperature.All(float.IsFinite), Is.True); + Assert.That(after.IsAwake, Is.True); + Assert.That(after.SleepTimer, Is.Zero); + }); + } + [Test] public void DisabledConsumer_DoesNotReplayProducerEventsOnALaterTick() { @@ -273,6 +457,342 @@ public void BoundaryConsumer_RevalidatesSourceTopologyAfterCustomStage() Assert.That(simulation.GetVoxelSnapshot(target, 0).Gases, Is.Empty); } + [Test] + public void AwakeLowPressureEndpoint_WakesSleepingHigherPressureNeighborAndRestoresBoundaryFlow() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MinimumPressureTransfer = 0f, + BulkFlowCoefficient = 0.25f, + BulkFlowDamping = 0.5f, + MaxPressureTransferFractionPerNeighbor = 0.16f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + GasRegistry = [new GasProperties()] + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var first = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + var second = simulation.CreateAndRegisterChunk( + Int3.PosX, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(2)); + simulation.AddGasToVoxel(first, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(second, 0, 0, 1f, 300f); + for (var tick = 0; tick < 4; tick++) + simulation.Tick(); + Assert.That(simulation.GetChunkSnapshot(first).IsAwake, Is.False); + Assert.That(simulation.GetChunkSnapshot(second).IsAwake, Is.False); + + simulation.SetVoxelTemperature(second, 0, 150f); + simulation.WakeRoom(second, 2); + simulation.Tick(); + Assert.That(simulation.GetChunkSnapshot(first).IsAwake, Is.True, + "The low-pressure endpoint must wake an actionable sleeping source across the edge."); + + simulation.Tick(); + + float firstMoles = simulation.GetVoxelSnapshot(first, 0).Gases.Single().Moles; + float secondMoles = simulation.GetVoxelSnapshot(second, 0).Gases.Single().Moles; + Assert.Multiple(() => + { + Assert.That(firstMoles, Is.LessThan(1f)); + Assert.That(secondMoles, Is.GreaterThan(1f)); + Assert.That(firstMoles + secondMoles, Is.EqualTo(2f).Within(0.000001f)); + }); + } + + [Test] + public void BoundaryBatch_CapacityLimitedTargetDefersBlockedEdgeWithoutLosingGas() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MinimumPressureTransfer = 0f, + BulkFlowCoefficient = 0.25f, + BulkFlowDamping = 0.5f, + MaxPressureTransferFractionPerNeighbor = 0.16f, + DefaultDiffusionCoefficient = 0f, + VoxelSnappingEnabled = false, + SleepThreshold = int.MaxValue, + GasRegistry = [new GasProperties()] + }; + using var simulation = new AtmosSimulation(config, 1, 3, 1); + var source = simulation.CreateAndRegisterChunk( + default, 2, VoxelClassification.RoomSolid); + var target = simulation.CreateAndRegisterChunk( + Int3.PosX, 1, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(source, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(source, 2, new VoxelClassification(2)); + simulation.SetVoxelClassification(target, 0, new VoxelClassification(3)); + simulation.SetVoxelClassification(target, 2, new VoxelClassification(4)); + simulation.AddGasToVoxel(source, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(source, 2, 0, 1f, 300f); + + Assert.That(simulation.Tick, Throws.Nothing); + + float[] sourceMoles = simulation.GetChunkSnapshot(source).Gases.Single().Moles; + var targetSnapshot = simulation.GetChunkSnapshot(target); + float[] targetMoles = targetSnapshot.Gases.Single().Moles; + Assert.Multiple(() => + { + Assert.That(sourceMoles[0], Is.LessThan(1f)); + Assert.That(targetMoles[0], Is.GreaterThan(0f)); + Assert.That(sourceMoles[2], Is.EqualTo(1f)); + Assert.That(targetMoles[2], Is.Zero); + Assert.That(sourceMoles.Sum() + targetMoles.Sum(), Is.EqualTo(2f).Within(0.000001f)); + Assert.That(targetSnapshot.IsAwake, Is.True); + }); + } + + [Test] + public void BoundaryChain_TransferCreatedDownstreamFlowUsesCapacityBackpressureConservatively() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MinimumPressureTransfer = 0f, + BulkFlowCoefficient = 0.25f, + BulkFlowDamping = 0.5f, + MaxPressureTransferFractionPerNeighbor = 0.16f, + DefaultDiffusionCoefficient = 0f, + VoxelSnappingEnabled = false, + SleepThreshold = int.MaxValue, + GasRegistry = [new GasProperties()] + }; + using var simulation = new AtmosSimulation(config, 1, 3, 1); + var first = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, VoxelClassification.RoomSolid); + var middle = simulation.CreateAndRegisterChunk( + Int3.PosX, AtmosChunkConstants.DefaultMaxActiveRooms, VoxelClassification.RoomSolid); + var last = simulation.CreateAndRegisterChunk(new Int3(2, 0, 0), + 1, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(first, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(middle, 0, new VoxelClassification(2)); + simulation.SetVoxelClassification(last, 0, new VoxelClassification(3)); + simulation.SetVoxelClassification(last, 2, new VoxelClassification(4)); + simulation.AddGasToVoxel(first, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(middle, 0, 0, 1f, 300f); + simulation.GetVoxelGasMixture(middle, 0).SetMoles(0, 0f); + simulation.WakeRoom(last, 4); + + Assert.That(simulation.Tick, Throws.Nothing); + + float firstMoles = simulation.GetVoxelSnapshot(first, 0).Gases.Single().Moles; + float middleMoles = simulation.GetVoxelSnapshot(middle, 0).Gases.Single().Moles; + var lastSnapshot = simulation.GetChunkSnapshot(last); + float lastMoles = lastSnapshot.Gases.Length == 0 ? 0f : lastSnapshot.Gases.Single().Moles[0]; + Assert.Multiple(() => + { + Assert.That(firstMoles, Is.LessThan(1f)); + Assert.That(middleMoles, Is.GreaterThan(0f)); + Assert.That(lastMoles, Is.Zero); + Assert.That(firstMoles + middleMoles + lastMoles, + Is.EqualTo(1f).Within(0.000001f)); + Assert.That(lastSnapshot.ActiveAirCount, Is.EqualTo(1)); + }); + } + + [Test] + public void BoundaryFlow_UnrepresentableTargetStateDefersWholeEdgeWithoutPoisoningEitherChunk() + { + var config = new AtmosConfig + { + VoxelVolume = float.MaxValue, + DefaultMolarHeatCapacityAtConstantVolume = float.Epsilon, + VacuumThreshold = 0f, + BulkFlowCoefficient = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + ThermalConductance = 0f, + VoxelSnappingEnabled = false, + SleepThreshold = int.MaxValue, + GasRegistry = + [ + new GasProperties + { + MolarHeatCapacityAtConstantVolume = float.Epsilon, + DiffusionCoefficient = 1f + } + ] + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + var target = simulation.CreateAndRegisterChunk( + Int3.PosX, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(2)); + simulation.AddGasToVoxel(source, 0, 0, float.MaxValue * 0.75f, 2f); + simulation.AddGasToVoxel(target, 0, 0, float.MaxValue * 0.75f, 0.0001f); + var sourceBefore = simulation.GetChunkSnapshot(source); + var targetBefore = simulation.GetChunkSnapshot(target); + + Assert.That(simulation.Tick, Throws.Nothing); + + var sourceAfter = simulation.GetChunkSnapshot(source); + var targetAfter = simulation.GetChunkSnapshot(target); + Assert.Multiple(() => + { + Assert.That(sourceAfter.Gases.Single().Moles, + Is.EqualTo(sourceBefore.Gases.Single().Moles)); + Assert.That(targetAfter.Gases.Single().Moles, + Is.EqualTo(targetBefore.Gases.Single().Moles)); + Assert.That(sourceAfter.Gases.Single().Moles.All(float.IsFinite), Is.True); + Assert.That(targetAfter.Gases.Single().Moles.All(float.IsFinite), Is.True); + Assert.That(sourceAfter.TotalPressure.All(float.IsFinite), Is.True); + Assert.That(targetAfter.TotalPressure.All(float.IsFinite), Is.True); + Assert.That(sourceAfter.IsAwake, Is.True); + Assert.That(sourceAfter.SleepTimer, Is.Zero); + }); + } + + [Test] + public void BoundaryDiffusion_UnitCoefficientRelaxesTwoVoxelsWithoutSequentialOvershoot() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + ThermalConductance = 0f, + SleepThreshold = int.MaxValue, + GasRegistry = + [ + new GasProperties { MolarHeatCapacityAtConstantVolume = 1f, DiffusionCoefficient = 1f }, + new GasProperties { MolarHeatCapacityAtConstantVolume = 1f, DiffusionCoefficient = 1f } + ] + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var left = simulation.CreateAndRegisterChunk(default); + var right = simulation.CreateAndRegisterChunk(Int3.PosX); + simulation.SetChunkClassification(left, new VoxelClassification(1)); + simulation.SetChunkClassification(right, new VoxelClassification(2)); + simulation.AddGasToVoxel(left, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(right, 0, 1, 1f, 300f); + + simulation.Tick(); + + var leftAfter = simulation.GetVoxelSnapshot(left, 0); + var rightAfter = simulation.GetVoxelSnapshot(right, 0); + Assert.Multiple(() => + { + Assert.That(leftAfter.Gases.Single(gas => gas.GasId == 0).Moles, + Is.EqualTo(0.5f).Within(0.000001f)); + Assert.That(leftAfter.Gases.Single(gas => gas.GasId == 1).Moles, + Is.EqualTo(0.5f).Within(0.000001f)); + Assert.That(rightAfter.Gases.Single(gas => gas.GasId == 0).Moles, + Is.EqualTo(0.5f).Within(0.000001f)); + Assert.That(rightAfter.Gases.Single(gas => gas.GasId == 1).Moles, + Is.EqualTo(0.5f).Within(0.000001f)); + Assert.That(leftAfter.Pressure, Is.EqualTo(300f).Within(0.0001f)); + Assert.That(rightAfter.Pressure, Is.EqualTo(300f).Within(0.0001f)); + }); + } + + [Test] + public void IntraChunkDiffusion_UnitCoefficientRelaxesTwoVoxelsWithoutOscillation() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + ThermalConductance = 0f, + SleepThreshold = int.MaxValue, + GasRegistry = + [ + new GasProperties { MolarHeatCapacityAtConstantVolume = 1f, DiffusionCoefficient = 1f }, + new GasProperties { MolarHeatCapacityAtConstantVolume = 1f, DiffusionCoefficient = 1f } + ] + }; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default, + AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(chunk, 1, 1, 1f, 300f); + + simulation.Tick(); + var leftAfterFirst = simulation.GetVoxelSnapshot(chunk, 0); + var rightAfterFirst = simulation.GetVoxelSnapshot(chunk, 1); + simulation.Tick(); + var leftAfterSecond = simulation.GetVoxelSnapshot(chunk, 0); + var rightAfterSecond = simulation.GetVoxelSnapshot(chunk, 1); + + Assert.Multiple(() => + { + foreach (var snapshot in new[] + { + leftAfterFirst, rightAfterFirst, leftAfterSecond, rightAfterSecond + }) + { + Assert.That(snapshot.Gases.Single(gas => gas.GasId == 0).Moles, + Is.EqualTo(0.5f).Within(0.000001f)); + Assert.That(snapshot.Gases.Single(gas => gas.GasId == 1).Moles, + Is.EqualTo(0.5f).Within(0.000001f)); + } + }); + } + + [Test] + public void BoundaryCapacityBackpressure_KeepsProducerAwakeUntilSleepingTargetFreesCapacity() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MinimumPressureTransfer = 0f, + BulkFlowCoefficient = 0.25f, + BulkFlowDamping = 0.5f, + MaxPressureTransferFractionPerNeighbor = 0.16f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + VoxelSnappingEnabled = true, + GasRegistry = [new GasProperties()] + }; + using var simulation = new AtmosSimulation(config, 1, 3, 1); + var source = simulation.CreateAndRegisterChunk( + default, 1, VoxelClassification.RoomSolid); + var target = simulation.CreateAndRegisterChunk( + Int3.PosX, 1, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(source, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(target, 0, new VoxelClassification(2)); + simulation.SetVoxelClassification(target, 2, new VoxelClassification(3)); + simulation.AddGasToVoxel(source, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(target, 2, 0, 1f, 300f); + + simulation.Tick(); + simulation.Tick(); + + var blockedSource = simulation.GetChunkSnapshot(source); + var blockedTarget = simulation.GetChunkSnapshot(target); + Assert.Multiple(() => + { + Assert.That(blockedSource.IsAwake, Is.True, + "The producer must remain awake while target capacity blocks the edge."); + Assert.That(blockedSource.Gases.Single().Moles[0], Is.EqualTo(1f)); + Assert.That(blockedTarget.Gases.Single().Moles[0], Is.Zero); + }); + + // Automatic sleep retains the blocker room by design. Explicit sleep discards that provenance and + // makes capacity available, after which the still-awake producer must retry the boundary edge. + simulation.SleepChunk(target); + for (var tick = 0; tick < 6; tick++) + simulation.Tick(); + + float sourceMoles = simulation.GetVoxelSnapshot(source, 0).Gases.Single().Moles; + var targetSnapshot = simulation.GetChunkSnapshot(target); + float targetReceivingMoles = targetSnapshot.Gases.Single().Moles[0]; + Assert.Multiple(() => + { + Assert.That(sourceMoles, Is.LessThan(1f)); + Assert.That(targetReceivingMoles, Is.GreaterThan(0f)); + Assert.That(sourceMoles + targetSnapshot.Gases.Single().Moles.Sum(), + Is.EqualTo(2f).Within(0.000001f)); + }); + } + [Test] public void ThermalBoundaryConsumer_RevalidatesSourceTopologyAfterCustomStage() { @@ -308,6 +828,463 @@ public void ThermalBoundaryConsumer_RevalidatesSourceTopologyAfterCustomStage() }); } + [Test] + public void ThermalBoundaryCapacityBackpressure_DefersBatchUntilTargetCanWake() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 100f, + SleepThreshold = 0, + VoxelSnappingEnabled = true, + GasRegistry = + [ + new GasProperties + { + MolarHeatCapacityAtConstantVolume = 1f, + DiffusionCoefficient = 0f + } + ] + }; + using var simulation = new AtmosSimulation(config, 1, 3, 1); + var source = simulation.CreateAndRegisterChunk( + default, 1, VoxelClassification.RoomSolid); + var target = simulation.CreateAndRegisterChunk( + Int3.PosX, 1, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(source, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(target, 0, new VoxelClassification(2)); + simulation.SetVoxelClassification(target, 2, new VoxelClassification(3)); + simulation.AddGasToVoxel(source, 0, 0, 1f, 400f); + simulation.AddGasToVoxel(target, 0, 0, 1f, 200f); + simulation.SleepChunk(target); + simulation.AddGasToVoxel(target, 2, 0, 1f, 300f); + + Assert.That(simulation.Tick, Throws.Nothing); + Assert.That(simulation.Tick, Throws.Nothing, + "The first thermal pass must defer instead of throwing at room capacity."); + Assert.That(simulation.GetVoxelSnapshot(source, 0).Temperature, Is.EqualTo(400f)); + Assert.That(simulation.GetVoxelSnapshot(target, 0).Temperature, Is.EqualTo(200f)); + + simulation.SleepChunk(target); + for (var tick = 0; tick < 4; tick++) + simulation.Tick(); + + float sourceTemperature = simulation.GetVoxelSnapshot(source, 0).Temperature; + var targetSnapshot = simulation.GetChunkSnapshot(target); + Assert.Multiple(() => + { + Assert.That(sourceTemperature, Is.EqualTo(300f).Within(0.0001f)); + Assert.That(targetSnapshot.Temperature[0], Is.EqualTo(300f).Within(0.0001f)); + Assert.That(sourceTemperature + targetSnapshot.Temperature[0] + + targetSnapshot.Temperature[2], + Is.EqualTo(900f).Within(0.001f)); + }); + } + + [Test] + public void ThermalBoundaryCapacityBackpressure_KeepsBalancedMediatorAwakeForRetry() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 100f, + SleepThreshold = 0, + VoxelSnappingEnabled = true, + GasRegistry = + [ + new GasProperties + { + MolarHeatCapacityAtConstantVolume = 1f, + DiffusionCoefficient = 0f + } + ] + }; + using var simulation = new AtmosSimulation(config, 1, 3, 1); + var center = simulation.CreateAndRegisterChunk( + default, 1, VoxelClassification.RoomSolid); + var left = simulation.CreateAndRegisterChunk( + Int3.NegX, 1, VoxelClassification.RoomSolid); + var right = simulation.CreateAndRegisterChunk( + Int3.PosX, 1, VoxelClassification.RoomSolid); + + simulation.SetVoxelClassification(center, 0, new VoxelClassification(1)); + foreach (var target in new[] { left, right }) + { + simulation.SetVoxelClassification(target, 0, new VoxelClassification(2)); + simulation.SetVoxelClassification(target, 2, new VoxelClassification(3)); + } + + simulation.AddGasToVoxel(center, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(left, 0, 0, 1f, 200f); + simulation.SleepChunk(left); + simulation.AddGasToVoxel(left, 2, 0, 1f, 300f); + simulation.AddGasToVoxel(right, 0, 0, 1f, 400f); + simulation.SleepChunk(right); + simulation.AddGasToVoxel(right, 2, 0, 1f, 300f); + + for (var tick = 0; tick < 4; tick++) + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(simulation.GetChunkSnapshot(center).IsAwake, Is.True, + "A zero-net mediator must keep publishing the deferred boundary component."); + Assert.That(simulation.GetVoxelSnapshot(center, 0).Temperature, Is.EqualTo(300f)); + Assert.That(simulation.GetVoxelSnapshot(left, 0).Temperature, Is.EqualTo(200f)); + Assert.That(simulation.GetVoxelSnapshot(right, 0).Temperature, Is.EqualTo(400f)); + }); + + simulation.SleepChunk(left); + simulation.SleepChunk(right); + simulation.Tick(); + simulation.Tick(); + + float centerTemperature = simulation.GetVoxelSnapshot(center, 0).Temperature; + float leftTemperature = simulation.GetVoxelSnapshot(left, 0).Temperature; + float rightTemperature = simulation.GetVoxelSnapshot(right, 0).Temperature; + Assert.Multiple(() => + { + Assert.That(leftTemperature, Is.GreaterThan(200f)); + Assert.That(rightTemperature, Is.LessThan(400f)); + Assert.That(centerTemperature + leftTemperature + rightTemperature, + Is.EqualTo(900f).Within(0.001f)); + }); + } + + [Test] + public void LivePhysicsConfigChange_WakesAutomaticSleepAndPreservesManualSleep() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MaxPressureTransferFractionPerNeighbor = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = float.MaxValue, + VoxelSnappingEnabled = false + }; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var automatic = simulation.CreateAndRegisterChunk(default); + var manual = simulation.CreateAndRegisterChunk(new Int3(2, 0, 0)); + simulation.SetChunkClassification(automatic, new VoxelClassification(1)); + simulation.SetChunkClassification(manual, new VoxelClassification(2)); + simulation.AddGasToVoxel(automatic, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(automatic, 1, 1, 1f, 300f); + simulation.AddGasToVoxel(manual, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(manual, 1, 1, 1f, 300f); + + simulation.Tick(); + Assert.That(simulation.GetChunkSnapshot(automatic).IsAwake, Is.False); + simulation.SleepChunk(manual); + var manualBefore = simulation.GetChunkSnapshot(manual); + + config.DefaultDiffusionCoefficient = 0.25f; + simulation.Tick(); + + var automaticAfter = simulation.GetChunkSnapshot(automatic); + var manualAfter = simulation.GetChunkSnapshot(manual); + Assert.Multiple(() => + { + Assert.That(automaticAfter.Gases.Single(gas => gas.GasId == 0).Moles, + Is.EqualTo(new[] { 0.75f, 0.25f })); + Assert.That(automaticAfter.Gases.Single(gas => gas.GasId == 1).Moles, + Is.EqualTo(new[] { 0.25f, 0.75f })); + Assert.That(manualAfter.Version, Is.EqualTo(manualBefore.Version)); + Assert.That(manualAfter.IsAwake, Is.False); + Assert.That(manualAfter.Gases.Single(gas => gas.GasId == 0).Moles, + Is.EqualTo(new[] { 1f, 0f })); + Assert.That(manualAfter.Gases.Single(gas => gas.GasId == 1).Moles, + Is.EqualTo(new[] { 0f, 1f })); + }); + } + + [Test] + public void DirectTemperatureMutation_WakesAutomaticSleepButNotExplicitSleep() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = float.MaxValue, + VoxelSnappingEnabled = false + }; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(chunk, 1, 0, 1f, 300f); + simulation.Tick(); + Assert.That(simulation.GetChunkSnapshot(chunk).IsAwake, Is.False); + + simulation.SetVoxelTemperature(chunk, 0, 600f); + Assert.That(simulation.GetChunkSnapshot(chunk).IsAwake, Is.True); + simulation.Tick(); + Assert.That(simulation.GetVoxelSnapshot(chunk, 0).Gases.Single().Moles, Is.LessThan(1f)); + + simulation.SleepChunk(chunk); + simulation.SetVoxelTemperature(chunk, 0, 500f); + Assert.That(simulation.GetChunkSnapshot(chunk).IsAwake, Is.False); + } + + [Test] + public void UnrepresentableVoxelVolumeUsesNormalizedFallbackWithoutPoisoningState() + { + var config = new AtmosConfig { VoxelVolume = 1f }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.Tick(); + + config.VoxelVolume = float.Epsilon; + Assert.That(simulation.Tick, Throws.Nothing); + + float pressure = simulation.GetVoxelSnapshot(chunk, 0).Pressure; + Assert.Multiple(() => + { + Assert.That(float.IsFinite(pressure), Is.True); + Assert.That(pressure, + Is.EqualTo(AtmosPhysicalConstants.MolarGasConstant * 300f).Within(0.001f)); + }); + } + + [Test] + public void LiveConfigChange_RefreshesManualSleeperCachesWithoutResumingPhysics() + { + var config = new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + SleepThreshold = int.MaxValue + }; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.Tick(); + simulation.SleepChunk(chunk); + var before = simulation.GetChunkSnapshot(chunk); + + config.VoxelVolume *= 2f; + simulation.Tick(); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(after.IsAwake, Is.False); + Assert.That(after.Version, Is.Not.EqualTo(before.Version)); + Assert.That(after.Temperature, Is.EqualTo(before.Temperature)); + Assert.That(after.Gases[0].Moles, Is.EqualTo(before.Gases[0].Moles)); + Assert.That(after.TotalPressure[0], Is.EqualTo(before.TotalPressure[0] / 2f).Within(0.0001f)); + }); + } + + [Test] + public void ReenabledBoundaryFlow_ResumesAutomaticSleepButPreservesManualSleep() + { + var config = CreatePipelineMutationSleepConfig(); + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var automaticLeft = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + var automaticRight = simulation.CreateAndRegisterChunk( + Int3.PosX, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(2)); + var manualLeft = simulation.CreateAndRegisterChunk( + new Int3(3, 0, 0), AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(3)); + var manualRight = simulation.CreateAndRegisterChunk( + new Int3(4, 0, 0), AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(4)); + foreach ((AtmosChunkHandle chunk, float moles) in new[] + { + (automaticLeft, 2f), (automaticRight, 1f), + (manualLeft, 2f), (manualRight, 1f) + }) + { + simulation.AddGasToVoxel(chunk, 0, 0, moles, 300f); + } + + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.BoundaryFlow, false); + simulation.Tick(); + simulation.SleepChunk(manualLeft); + simulation.SleepChunk(manualRight); + var manualLeftBefore = simulation.GetChunkSnapshot(manualLeft); + var manualRightBefore = simulation.GetChunkSnapshot(manualRight); + + Assert.That(simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.BoundaryFlow, true), Is.True); + Assert.That(simulation.GetChunkSnapshot(automaticLeft).IsAwake, Is.False, + "Pipeline invalidation takes effect at the next tick boundary."); + simulation.Tick(); + + var automaticLeftAfter = simulation.GetVoxelSnapshot(automaticLeft, 0); + var automaticRightAfter = simulation.GetVoxelSnapshot(automaticRight, 0); + var manualLeftAfter = simulation.GetChunkSnapshot(manualLeft); + var manualRightAfter = simulation.GetChunkSnapshot(manualRight); + Assert.Multiple(() => + { + Assert.That(automaticLeftAfter.Gases.Single().Moles, Is.LessThan(2f)); + Assert.That(automaticRightAfter.Gases.Single().Moles, Is.GreaterThan(1f)); + Assert.That(automaticLeftAfter.Gases.Single().Moles + automaticRightAfter.Gases.Single().Moles, + Is.EqualTo(3f).Within(0.000001f)); + Assert.That(manualLeftAfter.Version, Is.EqualTo(manualLeftBefore.Version)); + Assert.That(manualRightAfter.Version, Is.EqualTo(manualRightBefore.Version)); + Assert.That(manualLeftAfter.IsAwake, Is.False); + Assert.That(manualRightAfter.IsAwake, Is.False); + Assert.That(manualLeftAfter.Gases.Single().Moles[0], Is.EqualTo(2f)); + Assert.That(manualRightAfter.Gases.Single().Moles[0], Is.EqualTo(1f)); + }); + } + + [Test] + public void RemovingOrRepeatingPipelineState_DoesNotWakeAutomaticSleep() + { + var config = CreatePipelineMutationSleepConfig(); + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.Tick(); + var before = simulation.GetChunkSnapshot(chunk); + Assert.That(before.IsAwake, Is.False); + + Assert.That(simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false), Is.True); + Assert.That(simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false), Is.True); + Assert.That(simulation.Solvers.SetEnabled("missing", true), Is.False); + simulation.Tick(); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(after.IsAwake, Is.False); + Assert.That(after.Version, Is.EqualTo(before.Version)); + Assert.That(after.Gases.Single().Moles, Is.EqualTo(before.Gases.Single().Moles)); + }); + } + + [Test] + public void ResetToDefaults_ReenabledBoundaryFlowResumesAutomaticSleep() + { + var config = CreatePipelineMutationSleepConfig(); + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var left = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + var right = simulation.CreateAndRegisterChunk( + Int3.PosX, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(2)); + simulation.AddGasToVoxel(left, 0, 0, 2f, 300f); + simulation.AddGasToVoxel(right, 0, 0, 1f, 300f); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.BoundaryFlow, false); + simulation.Tick(); + Assert.That(simulation.GetChunkSnapshot(left).IsAwake, Is.False); + + simulation.Solvers.ResetToDefaults(); + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(simulation.GetVoxelSnapshot(left, 0).Gases.Single().Moles, Is.LessThan(2f)); + Assert.That(simulation.GetVoxelSnapshot(right, 0).Gases.Single().Moles, Is.GreaterThan(1f)); + }); + } + + [Test] + public void EnabledCustomRegistration_WakesOnlyAutomaticSleepOnNextTick() + { + var config = CreatePipelineMutationSleepConfig(); + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var automatic = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + var manual = simulation.CreateAndRegisterChunk( + new Int3(2, 0, 0), AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(2)); + simulation.AddGasToVoxel(automatic, 0, 0, 1f, 300f); + simulation.AddGasToVoxel(manual, 0, 0, 1f, 300f); + simulation.Tick(); + simulation.SleepChunk(manual); + bool? automaticWasAwake = null; + bool? manualWasAwake = null; + + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.Advection, "observe-wake", context => + { + automaticWasAwake = context.GetChunkSnapshot(automatic).IsAwake; + manualWasAwake = context.GetChunkSnapshot(manual).IsAwake; + }); + Assert.That(simulation.GetChunkSnapshot(automatic).IsAwake, Is.False); + simulation.Tick(); + + Assert.Multiple(() => + { + Assert.That(automaticWasAwake, Is.True); + Assert.That(manualWasAwake, Is.False); + }); + } + + [Test] + public void SolverCallbackReenable_WakesAutomaticSleepOnFollowingTick() + { + var config = CreatePipelineMutationSleepConfig(); + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var left = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + var right = simulation.CreateAndRegisterChunk( + Int3.PosX, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(2)); + simulation.AddGasToVoxel(left, 0, 0, 2f, 300f); + simulation.AddGasToVoxel(right, 0, 0, 1f, 300f); + var shouldReenable = false; + simulation.Solvers.RegisterBefore(AtmosBuiltInSolvers.BoundaryFlow, "reenable-boundary", _ => + { + if (shouldReenable) + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.BoundaryFlow, true); + }); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.BoundaryFlow, false); + simulation.Tick(); + Assert.That(simulation.GetChunkSnapshot(left).IsAwake, Is.False); + + shouldReenable = true; + simulation.Tick(); + Assert.Multiple(() => + { + Assert.That(simulation.GetVoxelSnapshot(left, 0).Gases.Single().Moles, Is.EqualTo(2f)); + Assert.That(simulation.GetVoxelSnapshot(right, 0).Gases.Single().Moles, Is.EqualTo(1f)); + }); + + simulation.Tick(); + Assert.Multiple(() => + { + Assert.That(simulation.GetVoxelSnapshot(left, 0).Gases.Single().Moles, Is.LessThan(2f)); + Assert.That(simulation.GetVoxelSnapshot(right, 0).Gases.Single().Moles, Is.GreaterThan(1f)); + }); + } + + [Test] + public void CustomOnlyResetAndAlreadyEnabledStage_DoNotWakeAutomaticSleep() + { + var config = CreatePipelineMutationSleepConfig(); + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, AtmosChunkConstants.DefaultMaxActiveRooms, new VoxelClassification(1)); + simulation.AddGasToVoxel(chunk, 0, 0, 1f, 300f); + simulation.Solvers.Register("no-op", _ => { }); + simulation.Tick(); + var before = simulation.GetChunkSnapshot(chunk); + + Assert.That(simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, true), Is.True); + simulation.Solvers.ResetToDefaults(); + simulation.Tick(); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(after.IsAwake, Is.False); + Assert.That(after.Version, Is.EqualTo(before.Version)); + }); + } + [Test] public void ResetToDefaults_RemovesCustomizations() { @@ -327,6 +1304,22 @@ public void ResetToDefaults_RemovesCustomizations() })); } + private static AtmosConfig CreatePipelineMutationSleepConfig() + { + return new AtmosConfig + { + VoxelVolume = AtmosPhysicalConstants.MolarGasConstant, + VacuumThreshold = 0f, + MinimumPressureTransfer = 0f, + DefaultDiffusionCoefficient = 0f, + ThermalConductance = 0f, + SleepThreshold = 0, + SleepEpsilon = float.MaxValue, + VoxelSnappingEnabled = false, + GasRegistry = [new GasProperties { MolarHeatCapacityAtConstantVolume = 1f }] + }; + } + private sealed class ConfiguredInjectionSolver : IAtmosSolver { public InjectionSolverConfig Config { get; } = new(); @@ -356,4 +1349,4 @@ public void Dispose() IsDisposed = true; } } -} \ No newline at end of file +} diff --git a/tests/Numos.API.Tests/GasMixtureTests.cs b/tests/Numos.API.Tests/GasMixtureTests.cs index eb709ab..a62ba20 100644 --- a/tests/Numos.API.Tests/GasMixtureTests.cs +++ b/tests/Numos.API.Tests/GasMixtureTests.cs @@ -90,6 +90,37 @@ public void AddGas_MixesTemperatureByConstantVolumeHeatCapacity() }); } + [Test] + public void AddGas_LargeFiniteEnergiesMixWithoutIntermediateOverflow() + { + using var simulation = CreateSimulation(float.MaxValue); + var chunk = simulation.CreateAndRegisterChunk(default); + var owned = simulation.CreateGasMixture(float.MaxValue, 300f); + var voxel = simulation.GetVoxelGasMixture(chunk, 0); + owned.AddGas(0, 1e30f, 300f); + voxel.AddGas(0, 1e30f, 300f); + + Assert.Multiple(() => + { + Assert.That(() => owned.AddGas(1, 1e30f, 1e30f), Throws.Nothing); + Assert.That(() => voxel.AddGas(1, 1e30f, 1e30f), Throws.Nothing); + }); + + // The test registry uses Cv=10 for gas 0 and Cv=20 for gas 1. + float expectedTemperature = 2e30f / 3f; + Assert.Multiple(() => + { + Assert.That(float.IsFinite(owned.Temperature), Is.True); + Assert.That(float.IsFinite(voxel.Temperature), Is.True); + Assert.That(owned.Temperature, + Is.EqualTo(expectedTemperature).Within(expectedTemperature * 0.000001f)); + Assert.That(voxel.Temperature, + Is.EqualTo(expectedTemperature).Within(expectedTemperature * 0.000001f)); + Assert.That(float.IsFinite(owned.Pressure), Is.True); + Assert.That(float.IsFinite(voxel.Pressure), Is.True); + }); + } + [Test] public void EnergyOperationsUseOwnersCurrentGasRegistry() { @@ -220,6 +251,25 @@ public void VoxelMixture_MutatesLiveSoaStateWithoutExposingStorage() }); } + [Test] + public void VoxelMixture_TemperatureMutationRefreshesSnapshotPressureImmediately() + { + using var simulation = CreateSimulation(voxelVolume: 2f); + var chunk = simulation.CreateAndRegisterChunk(default); + var mixture = simulation.GetVoxelGasMixture(chunk, 0); + mixture.SetMoles(0, 2f); + + mixture.Temperature = 450f; + + float expectedPressure = 2f * AtmosPhysicalConstants.MolarGasConstant * 450f / 2f; + Assert.Multiple(() => + { + Assert.That(mixture.Pressure, Is.EqualTo(expectedPressure).Within(0.001f)); + Assert.That(simulation.GetVoxelSnapshot(chunk, 0).Pressure, + Is.EqualTo(expectedPressure).Within(0.001f)); + }); + } + [Test] public void VoxelMixture_ChannelTableGrowsPastInitialCapacity() { @@ -266,15 +316,89 @@ public void StoredTemperature_AllowsRawValuesAndUsesConfiguredFallback() }); } + [Test] + public void Pressure_UnrepresentableLiveFallbackIsRejectedWithoutMutatingMixtures() + { + var config = CreateSimulationConfig(); + config.DefaultTemperatureFallback = 300f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + var owned = simulation.CreateGasMixture(1f, 0f); + var voxel = simulation.GetVoxelGasMixture(chunk, 0); + owned.SetMoles(0, 1f); + voxel.SetMoles(0, 1f); + float ownedPressure = owned.Pressure; + float voxelPressure = voxel.Pressure; + + config.DefaultTemperatureFallback = float.MaxValue; + + Assert.Multiple(() => + { + Assert.That(() => _ = owned.Pressure, Throws.TypeOf()); + Assert.That(() => _ = voxel.Pressure, Throws.TypeOf()); + Assert.That(owned.Temperature, Is.Zero); + Assert.That(voxel.Temperature, Is.Zero); + Assert.That(owned.TotalMoles, Is.EqualTo(1f)); + Assert.That(voxel.TotalMoles, Is.EqualTo(1f)); + }); + + config.DefaultTemperatureFallback = 300f; + + Assert.Multiple(() => + { + Assert.That(owned.Pressure, Is.EqualTo(ownedPressure)); + Assert.That(voxel.Pressure, Is.EqualTo(voxelPressure)); + }); + } + + [Test] + public void TemperatureMutation_UnrepresentablePressureIsRejectedAtomically() + { + using var simulation = CreateSimulation(); + var chunk = simulation.CreateAndRegisterChunk(default); + var owned = simulation.CreateGasMixture(1f, 300f); + var voxel = simulation.GetVoxelGasMixture(chunk, 0); + owned.SetMoles(0, 1f); + voxel.SetMoles(0, 1f); + simulation.SleepChunk(chunk); + var beforeVoxel = simulation.GetVoxelSnapshot(chunk, 0); + var beforeOwned = owned.GetSnapshot(); + + Assert.Multiple(() => + { + Assert.That(() => owned.Temperature = float.MaxValue, + Throws.TypeOf()); + Assert.That(() => voxel.Temperature = float.MaxValue, + Throws.TypeOf()); + }); + + var afterVoxel = simulation.GetVoxelSnapshot(chunk, 0); + var afterOwned = owned.GetSnapshot(); + Assert.Multiple(() => + { + Assert.That(afterOwned.Volume, Is.EqualTo(beforeOwned.Volume)); + Assert.That(afterOwned.Temperature, Is.EqualTo(beforeOwned.Temperature)); + Assert.That(afterOwned.Pressure, Is.EqualTo(beforeOwned.Pressure)); + Assert.That(afterOwned.TotalMoles, Is.EqualTo(beforeOwned.TotalMoles)); + Assert.That(afterOwned.Gases, Is.EqualTo(beforeOwned.Gases)); + Assert.That(afterVoxel.ChunkVersion, Is.EqualTo(beforeVoxel.ChunkVersion)); + Assert.That(afterVoxel.Temperature, Is.EqualTo(beforeVoxel.Temperature)); + Assert.That(afterVoxel.Pressure, Is.EqualTo(beforeVoxel.Pressure)); + Assert.That(afterVoxel.Gases, Is.EqualTo(beforeVoxel.Gases)); + Assert.That(simulation.GetChunkSnapshot(chunk).IsAwake, Is.False); + }); + } + [Test] public void VoxelMixture_ScalarMutationPreservesRoomCapacityGuard() { - using var simulation = new AtmosSimulation(CreateSimulationConfig(), 2, 1, 1); + using var simulation = new AtmosSimulation(CreateSimulationConfig(), 3, 1, 1); var chunk = simulation.CreateAndRegisterChunk(default, maxActiveRooms: 1); + simulation.SetChunkClassification(chunk, VoxelClassification.RoomSolid); simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); - simulation.SetVoxelClassification(chunk, 1, new VoxelClassification(2)); + simulation.SetVoxelClassification(chunk, 2, new VoxelClassification(2)); var first = simulation.GetVoxelGasMixture(chunk, 0); - var second = simulation.GetVoxelGasMixture(chunk, 1); + var second = simulation.GetVoxelGasMixture(chunk, 2); first.SetMoles(0, 1f); Assert.That(() => second.SetMoles(0, 1f), Throws.InvalidOperationException); @@ -316,12 +440,13 @@ public void TransferTo_NonGasVoxelIsRejectedAtomically() [Test] public void TransferTo_ActiveRoomCapacityFailureIsAtomic() { - using var simulation = new AtmosSimulation(CreateSimulationConfig(), 2, 1, 1); + using var simulation = new AtmosSimulation(CreateSimulationConfig(), 3, 1, 1); var chunk = simulation.CreateAndRegisterChunk(default, maxActiveRooms: 1); + simulation.SetChunkClassification(chunk, VoxelClassification.RoomSolid); simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); - simulation.SetVoxelClassification(chunk, 1, new VoxelClassification(2)); + simulation.SetVoxelClassification(chunk, 2, new VoxelClassification(2)); var source = simulation.GetVoxelGasMixture(chunk, 0); - var destination = simulation.GetVoxelGasMixture(chunk, 1); + var destination = simulation.GetVoxelGasMixture(chunk, 2); source.AddGas(0, 2f, 300f); Assert.That(() => source.TransferTo(destination, 1f), Throws.InvalidOperationException); @@ -448,4 +573,4 @@ private sealed class ExternalMixture(AtmosSimulation owner) : IGasMixture public GasMixture Clone() => throw new NotSupportedException(); public GasMixtureSnapshot GetSnapshot() => throw new NotSupportedException(); } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs b/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs index add954c..d2b4421 100644 --- a/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/IntraChunkFlowTests.cs @@ -347,7 +347,7 @@ public void Diffusion_CounterflowsAgainstTotalPressureWhenBulkFlowIsDisabled() } [Test] - public void DiffusionCoefficient_AboveOneIsClampedToOne() + public void DiffusionCoefficient_AboveOneUsesHalfPairRelaxation() { var config = SimTestHelpers.CreateDeterministicConfig(); config.MaxPressureTransferFractionPerNeighbor = 0f; @@ -364,9 +364,10 @@ public void DiffusionCoefficient_AboveOneIsClampedToOne() var snapshot = simulation.GetChunkSnapshot(chunk); Assert.Multiple(() => { - Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), Is.Zero); + Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(0.5f).Within(SimTestHelpers.Tolerance)); Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 1), - Is.EqualTo(1f).Within(SimTestHelpers.Tolerance)); + Is.EqualTo(0.5f).Within(SimTestHelpers.Tolerance)); }); } @@ -544,4 +545,4 @@ public void VacuumCleanup_UsesStrictPressureThreshold(float initialMoles, float Assert.That(SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 0), Is.EqualTo(expectedMoles).Within(SimTestHelpers.Tolerance)); } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/ProgressiveVoxelSnappingIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/ProgressiveVoxelSnappingIntegrationTests.cs new file mode 100644 index 0000000..3d7dc72 --- /dev/null +++ b/tests/Numos.CoreSim.IntegrationTests/ProgressiveVoxelSnappingIntegrationTests.cs @@ -0,0 +1,1914 @@ +using Numos.API; +using Numos.CoreSim.Datatypes.Primitives; +using Numos.CoreSim.Datatypes.Snapshots; +using Numos.CoreSim.Solvers; +using Numos.Maths; + +namespace Numos.CoreSim.IntegrationTests; + +[TestFixture] +public sealed class ProgressiveVoxelSnappingIntegrationTests +{ + [Test] + public void ExactlyRepresentableMultiGasMixture_ConservesSpeciesAndSensibleEnergy() + { + var config = CreateForcedSnappingConfig(); + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 2f); + SetHeatCapacity(config, SimTestHelpers.SecondGasId, 4f); + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 3f, 400f); + simulation.AddGasToVoxel(chunk, 1, 0, 0, SimTestHelpers.SecondGasId, 1f, 200f); + var before = simulation.GetChunkSnapshot(chunk); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1.5f, 1.5f })); + Assert.That(ReadMoles(after, SimTestHelpers.SecondGasId), + Is.EqualTo(new[] { 0.5f, 0.5f })); + Assert.That(after.Temperature, Is.EqualTo(new[] { 320f, 320f })); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), + Is.EqualTo(SpeciesTotal(before, SimTestHelpers.FirstGasId))); + Assert.That(SpeciesTotal(after, SimTestHelpers.SecondGasId), + Is.EqualTo(SpeciesTotal(before, SimTestHelpers.SecondGasId))); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, after), + Is.EqualTo(SimTestHelpers.TotalThermalEnergyPrecise(config, before))); + }); + } + + [Test] + public void EqualGasVoxels_WithRawNaNTemperaturesUseFallbackAndSleep() + { + const float fallbackTemperature = 275f; + var config = CreateForcedSnappingConfig(); + config.DefaultTemperatureFallback = fallbackTemperature; + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 1f); + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + simulation.SetVoxelTemperature(chunk, 0, float.NaN); + simulation.SetVoxelTemperature(chunk, 1, float.NaN); + var raw = simulation.GetChunkSnapshot(chunk); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(raw.Temperature.All(float.IsNaN), Is.True, + "The setup must exercise raw non-finite storage rather than eager API normalization."); + Assert.That(raw.TotalPressure, + Is.EqualTo(new[] { fallbackTemperature, fallbackTemperature }) + .Within(SimTestHelpers.Tolerance)); + Assert.That(after.IsAwake, Is.False); + Assert.That(after.Temperature, + Is.EqualTo(new[] { fallbackTemperature, fallbackTemperature }) + .Within(SimTestHelpers.Tolerance)); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), Is.EqualTo(2d)); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, after), + Is.EqualTo(2d * fallbackTemperature)); + }); + } + + [Test] + public void EmptyPassableNaNVoxels_DoNotBlockSleepOrInventThermodynamicState() + { + var config = CreateForcedSnappingConfig(); + config.DefaultTemperatureFallback = 275f; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.SetVoxelTemperature(chunk, 0, float.NaN); + simulation.SetVoxelTemperature(chunk, 1, float.NaN); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(after.IsAwake, Is.False); + Assert.That(after.Temperature.All(float.IsNaN), Is.True, + "Vacuum has no physical temperature, so projection need not invent one."); + Assert.That(after.Gases, Is.Empty); + Assert.That(after.TotalPressure, Is.All.Zero); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, after), Is.Zero); + }); + } + + [Test] + public void OneMoleAcrossThreeVoxels_ReconcilesRemainderAndIsIdempotent() + { + var config = CreateForcedSnappingConfig(); + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 1f); + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + double initialEnergy = SimTestHelpers.TotalThermalEnergyPrecise(config, + simulation.GetChunkSnapshot(chunk)); + + var after = RunUntilSleeping(simulation, chunk); + float[] canonicalMoles = ReadMoles(after, SimTestHelpers.FirstGasId); + float allowedVoxelSpread = Ulp(1f / 3f); + + Assert.Multiple(() => + { + Assert.That(canonicalMoles.Max() - canonicalMoles.Min(), + Is.LessThanOrEqualTo(allowedVoxelSpread)); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), + Is.EqualTo(1d).Within(FloatSumTolerance(1d, canonicalMoles.Length))); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, after), + Is.EqualTo(initialEnergy).Within(FloatEnergyTolerance(initialEnergy, canonicalMoles.Length))); + }); + + for (var cycle = 0; cycle < 5; cycle++) + { + simulation.WakeRoom(chunk, SimTestHelpers.RoomId); + after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), Is.EqualTo(canonicalMoles), + $"Unchanged snap cycle {cycle + 1} must reproduce the canonical float remainder."); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, after), + Is.EqualTo(initialEnergy).Within(FloatEnergyTolerance(initialEnergy, canonicalMoles.Length))); + }); + } + } + + [Test] + public void ThreeVoxelLine_DoesNotLoseMassThroughOverlappingNeighborAggregates() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 3f, SimTestHelpers.DefaultTemperature); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1f, 1f, 1f })); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), Is.EqualTo(3d)); + }); + } + + [Test] + public void ActionableBulkTransfer_DoesNotVetoEligibleSnapProjection() + { + const float temperature = 300f; + var config = SimTestHelpers.CreateDeterministicConfig(); + config.VoxelSnappingEnabled = true; + config.SleepEpsilon = 0.5f; + config.VoxelSnapPressureRelativeEpsilon = 0f; + config.SleepThreshold = 0; + config.MinimumPressureTransfer = 0.1f; + config.VoxelSnapTemperatureEpsilon = 0.01f; + config.VoxelSnapMoleFractionEpsilon = 0.001f; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, temperature); + simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.FirstGasId, 1f + 0.8f / temperature, temperature); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + var before = simulation.GetChunkSnapshot(chunk); + float pressureDelta = before.TotalPressure.Max() - before.TotalPressure.Min(); + float requestedTransfer = pressureDelta * config.MaxPressureTransferFractionPerNeighbor; + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(pressureDelta, Is.EqualTo(0.8f).Within(SimTestHelpers.Tolerance)); + Assert.That(requestedTransfer, Is.GreaterThan(config.MinimumPressureTransfer), + "The setup must exercise an edge that the removed actual-transfer veto rejected."); + Assert.That(pressureDelta / 2f, Is.LessThanOrEqualTo(config.SleepEpsilon), + "Each member's correction to the proposed equilibrium must remain eligible."); + Assert.That(after.IsAwake, Is.False); + Assert.That(after.TotalPressure.Max() - after.TotalPressure.Min(), + Is.LessThanOrEqualTo(Ulp(after.TotalPressure.Max()))); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), + Is.EqualTo(SpeciesTotal(before, SimTestHelpers.FirstGasId))); + }); + } + + [Test] + public void HighPressureCorrection_UsesRelativeToleranceWhenAbsoluteToleranceIsTooSmall() + { + const float temperature = 1f; + var config = CreateForcedSnappingConfig(); + config.SleepEpsilon = 0.5f; + config.VoxelSnapPressureRelativeEpsilon = 0.001f; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 100_000f, temperature); + simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.FirstGasId, 100_160f, temperature); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + var before = simulation.GetChunkSnapshot(chunk); + double equilibriumPressure = before.TotalPressure.Average(static pressure => (double)pressure); + double maximumCorrection = before.TotalPressure + .Max(pressure => Math.Abs(pressure - equilibriumPressure)); + double minimumRelativeLimit = before.TotalPressure + .Min(pressure => config.VoxelSnapPressureRelativeEpsilon * + Math.Max(Math.Max(pressure, equilibriumPressure), config.VacuumThreshold)); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(maximumCorrection, Is.GreaterThan(config.SleepEpsilon)); + Assert.That(maximumCorrection, Is.LessThanOrEqualTo(minimumRelativeLimit)); + Assert.That(after.IsAwake, Is.False); + Assert.That(after.TotalPressure.Max() - after.TotalPressure.Min(), + Is.LessThanOrEqualTo(Ulp(after.TotalPressure.Max()))); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), Is.EqualTo(200_160d)); + }); + } + + [Test] + public void HighPressureCorrection_AboveHybridLimitBlocksUntilRelativeToleranceIsRelaxed() + { + const float temperature = 1f; + var config = CreateForcedSnappingConfig(); + config.SleepEpsilon = 0.5f; + config.VoxelSnapPressureRelativeEpsilon = 0.001f; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 100_000f, temperature); + simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.FirstGasId, 100_240f, temperature); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + + for (var tick = 0; tick < 4; tick++) + simulation.Tick(); + var blocked = simulation.GetChunkSnapshot(chunk); + + config.VoxelSnapPressureRelativeEpsilon = 0.002f; + var afterRelaxing = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(blocked.IsAwake, Is.True); + Assert.That(blocked.SleepTimer, Is.Zero); + Assert.That(blocked.TotalPressure.Max() - blocked.TotalPressure.Min(), + Is.EqualTo(240f).Within(SimTestHelpers.Tolerance)); + Assert.That(afterRelaxing.IsAwake, Is.False); + Assert.That(afterRelaxing.TotalPressure.Max() - afterRelaxing.TotalPressure.Min(), + Is.LessThanOrEqualTo(Ulp(afterRelaxing.TotalPressure.Max()))); + Assert.That(SpeciesTotal(afterRelaxing, SimTestHelpers.FirstGasId), Is.EqualTo(200_240d)); + }); + } + + [Test] + public void NearVacuumCorrection_UsesAbsoluteSleepEpsilonFloor() + { + const float temperature = 300f; + var config = CreateForcedSnappingConfig(); + config.SleepEpsilon = 0.5f; + config.VoxelSnapPressureRelativeEpsilon = 0.001f; + config.VacuumThreshold = 1f; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.FirstGasId, 0.8f / temperature, temperature); + simulation.Solvers.SetEnabled(AtmosBuiltInSolvers.Advection, false); + var before = simulation.GetChunkSnapshot(chunk); + double equilibriumPressure = before.TotalPressure.Average(static pressure => (double)pressure); + double relativeLimit = config.VoxelSnapPressureRelativeEpsilon * config.VacuumThreshold; + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(relativeLimit, Is.LessThan(config.SleepEpsilon)); + Assert.That(before.TotalPressure.Max() - equilibriumPressure, + Is.EqualTo(0.4d).Within(SimTestHelpers.Tolerance)); + Assert.That(after.IsAwake, Is.False); + Assert.That(after.TotalPressure.Max() - after.TotalPressure.Min(), + Is.LessThanOrEqualTo(Ulp(after.TotalPressure.Max()))); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), + Is.EqualTo(SpeciesTotal(before, SimTestHelpers.FirstGasId))); + }); + } + + [Test] + public void EstablishedRamp_RemainsEligibleWhenEveryRealEdgeIsBelowBulkCutoff() + { + const int width = 5; + const float temperature = 256f; + float[] rampPressures = [100f, 100.5f, 101f, 101.5f, 102f]; + var config = CreateForcedSnappingConfig(); + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 1f); + config.BulkFlowCoefficient = 0.25f; + config.MaxPressureTransferFractionPerNeighbor = 0.16f; + config.MinimumPressureTransfer = 0.1f; + config.LowPressureDeltaThreshold = 5f; + config.SleepEpsilon = 1f; + using var kernel = new AtmosKernel(width, 1, 1); + kernel.SetAtmosConfig(config); + var chunk = new AtmosChunk(width, 1, 1); + chunk.Initialize(default, width, 1, 1, AtmosChunkConstants.DefaultMaxActiveRooms); + chunk.VoxelRoomMap.Fill(SimTestHelpers.RoomId); + chunk.WakeRoom(SimTestHelpers.RoomId); + float pressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / config.VoxelVolume; + for (ushort voxelIndex = 0; voxelIndex < width; voxelIndex++) + { + chunk.InjectGasToVoxel(voxelIndex, SimTestHelpers.FirstGasId, 1f, temperature, + 1f, pressurePerMoleKelvin); + } + + kernel.RegisterChunk(chunk); + for (var tick = 0; + tick < 8 && !chunk.VoxelAggregates.AreAggregatedTogether(0, (ushort)(width - 1)); + tick++) + { + kernel.Tick(); + } + Assert.That(chunk.VoxelAggregates.AreAggregatedTogether(0, (ushort)(width - 1)), Is.True, + "The uniform positive control must first establish one five-member aggregate."); + + var gasChannel = chunk.ActiveGases.Take(chunk.ActiveGasCount) + .Single(channel => channel.GasId == SimTestHelpers.FirstGasId); + for (var voxelIndex = 0; voxelIndex < width; voxelIndex++) + { + float moles = rampPressures[voxelIndex] / temperature; + gasChannel.Moles[voxelIndex] = moles; + chunk.Temperature[voxelIndex] = temperature; + chunk.TotalHeatCapacity[voxelIndex] = moles; + chunk.TotalPressure[voxelIndex] = rampPressures[voxelIndex]; + } + chunk.MarkChanged(); + + float maximumRealEdgeDelta = rampPressures.Zip(rampPressures.Skip(1), + static (left, right) => right - left).Max(); + float meanPressure = rampPressures.Average(); + float maximumEquilibriumCorrection = rampPressures + .Max(pressure => MathF.Abs(pressure - meanPressure)); + double initialMoles = gasChannel.Moles.ToArray().Sum(static moles => (double)moles); + Assert.Multiple(() => + { + Assert.That(maximumRealEdgeDelta * config.MaxPressureTransferFractionPerNeighbor, + Is.LessThan(config.MinimumPressureTransfer)); + Assert.That(maximumEquilibriumCorrection * config.MaxPressureTransferFractionPerNeighbor, + Is.GreaterThan(config.MinimumPressureTransfer), + "The endpoint-to-mean correction is the deliberately fictitious actionable edge."); + }); + + kernel.Tick(); + Assert.Multiple(() => + { + Assert.That(chunk.VoxelAggregates.AreAggregatedTogether(0, (ushort)(width - 1)), Is.True, + "Eligibility must inspect physical neighbor edges, not each voxel's correction to the mean."); + Assert.That(chunk.TotalPressure.ToArray(), + Is.EqualTo(Enumerable.Repeat(meanPressure, width).ToArray()) + .Within(SimTestHelpers.Tolerance)); + Assert.That(gasChannel.Moles.ToArray().Sum(static moles => (double)moles), + Is.EqualTo(initialMoles).Within(FloatSumTolerance(initialMoles, width))); + }); + + for (var tick = 0; tick < 8 && chunk.IsAwake; tick++) + kernel.Tick(); + Assert.That(chunk.IsAwake, Is.False, + "A real-edge-quiet aggregate must complete its unchanged verification window."); + } + + [Test] + public void CompositionCorrection_BlocksEqualPressureMergeUntilToleranceIsRelaxed() + { + var config = CreateForcedSnappingConfig(); + config.VoxelSnapMoleFractionEpsilon = 0.001f; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.SecondGasId, 1f, SimTestHelpers.DefaultTemperature); + + for (var tick = 0; tick < 4; tick++) + simulation.Tick(); + var whileCompositionDiffers = simulation.GetChunkSnapshot(chunk); + + config.VoxelSnapMoleFractionEpsilon = 1f; + var afterRelaxingTolerance = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(whileCompositionDiffers.TotalPressure[0], + Is.EqualTo(whileCompositionDiffers.TotalPressure[1])); + Assert.That(whileCompositionDiffers.IsAwake, Is.True); + Assert.That(whileCompositionDiffers.SleepTimer, Is.Zero); + Assert.That(ReadMoles(whileCompositionDiffers, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1f, 0f })); + Assert.That(ReadMoles(whileCompositionDiffers, SimTestHelpers.SecondGasId), + Is.EqualTo(new[] { 0f, 1f })); + Assert.That(ReadMoles(afterRelaxingTolerance, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 0.5f, 0.5f })); + Assert.That(ReadMoles(afterRelaxingTolerance, SimTestHelpers.SecondGasId), + Is.EqualTo(new[] { 0.5f, 0.5f })); + }); + } + + [Test] + public void TemperatureCorrection_BlocksEqualPressureMergeUntilToleranceIsRelaxed() + { + var config = CreateForcedSnappingConfig(); + config.VoxelSnapTemperatureEpsilon = 0.01f; + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 1f); + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, 300f); + simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.FirstGasId, 0.75f, 400f); + double initialEnergy = SimTestHelpers.TotalThermalEnergyPrecise(config, + simulation.GetChunkSnapshot(chunk)); + + for (var tick = 0; tick < 4; tick++) + simulation.Tick(); + var whileTemperatureDiffers = simulation.GetChunkSnapshot(chunk); + + config.VoxelSnapTemperatureEpsilon = float.MaxValue; + var afterRelaxingTolerance = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(whileTemperatureDiffers.TotalPressure[0], + Is.EqualTo(whileTemperatureDiffers.TotalPressure[1])); + Assert.That(whileTemperatureDiffers.IsAwake, Is.True); + Assert.That(whileTemperatureDiffers.SleepTimer, Is.Zero); + Assert.That(whileTemperatureDiffers.Temperature, Is.EqualTo(new[] { 300f, 400f })); + Assert.That(ReadMoles(afterRelaxingTolerance, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 0.875f, 0.875f })); + Assert.That(afterRelaxingTolerance.Temperature.Max() - + afterRelaxingTolerance.Temperature.Min(), + Is.LessThanOrEqualTo(Ulp(afterRelaxingTolerance.Temperature.Max()))); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, afterRelaxingTolerance), + Is.EqualTo(initialEnergy).Within(FloatEnergyTolerance(initialEnergy, 2))); + }); + } + + [Test] + public void EmptyPassableComponent_SleepsWithoutCreatingGasOrNonFiniteState() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.WakeRoom(chunk, SimTestHelpers.RoomId); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(after.Gases, Is.Empty); + Assert.That(after.TotalPressure, Is.All.EqualTo(0f)); + Assert.That(after.Temperature, Is.All.EqualTo(0f)); + Assert.That(after.IsAwake, Is.False); + }); + } + + [Test] + public void SolidSeparatedComponents_ConserveAndEquilibrateIndependently() + { + var config = CreateForcedSnappingConfig(); + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 2f); + SetHeatCapacity(config, SimTestHelpers.SecondGasId, 4f); + using var simulation = new AtmosSimulation(config, 5, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.SetVoxelClassification(chunk, 2, 0, 0, VoxelClassification.RoomSolid); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 4f, 400f); + simulation.AddGasToVoxel(chunk, 3, 0, 0, SimTestHelpers.SecondGasId, 6f, 200f); + + var after = RunUntilSleeping(simulation, chunk); + float[] first = ReadMoles(after, SimTestHelpers.FirstGasId); + float[] second = ReadMoles(after, SimTestHelpers.SecondGasId); + + Assert.Multiple(() => + { + Assert.That(first, Is.EqualTo(new[] { 2f, 2f, 0f, 0f, 0f })); + Assert.That(second, Is.EqualTo(new[] { 0f, 0f, 0f, 3f, 3f })); + Assert.That(after.Temperature[0], Is.EqualTo(400f)); + Assert.That(after.Temperature[1], Is.EqualTo(400f)); + Assert.That(after.Temperature[3], Is.EqualTo(200f)); + Assert.That(after.Temperature[4], Is.EqualTo(200f)); + Assert.That(after.VoxelRoomMap[2], Is.EqualTo(VoxelClassification.RoomSolid)); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId, 0, 1), Is.EqualTo(4d)); + Assert.That(SpeciesTotal(after, SimTestHelpers.SecondGasId, 3, 4), Is.EqualTo(6d)); + }); + } + + [Test] + public void VoidSeparatedVoxels_DoNotJoinTheSameAggregate() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.SetVoxelClassification(chunk, 1, 0, 0, VoxelClassification.RoomVoid); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + simulation.AddGasToVoxel(chunk, 2, 0, 0, + SimTestHelpers.FirstGasId, 4f, SimTestHelpers.DefaultTemperature); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 2f, 0f, 4f })); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), Is.EqualTo(6d)); + Assert.That(after.VoxelRoomMap[1], Is.EqualTo(VoxelClassification.RoomVoid)); + }); + } + + [Test] + public void AdjacentActiveRoomIds_AggregateAcrossPassableFace() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId)); + simulation.SetVoxelClassification(chunk, 1, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + simulation.WakeRoom(chunk, SimTestHelpers.RoomId + 1); + Assert.That(simulation.GetChunkSnapshot(chunk).ActiveAirCount, Is.EqualTo(2)); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1f, 1f })); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), Is.EqualTo(2d)); + }); + } + + [Test] + public void AdjacentInactiveRoomId_IsIncludedByPassableClosure() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId)); + simulation.SetVoxelClassification(chunk, 1, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + Assert.That(simulation.GetChunkSnapshot(chunk).ActiveAirCount, Is.EqualTo(2), + "Only room 1 is explicitly woken; active air must close over the adjacent passable room."); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1f, 1f })); + } + + [Test] + public void ConnectedRoomMutation_WithOneRoomCapacityResetsWithoutConsumingAnotherSlot() + { + var config = CreateForcedSnappingConfig(); + config.SleepThreshold = 10; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default, maxActiveRooms: 1); + simulation.SetChunkClassification(chunk, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId)); + simulation.SetVoxelClassification(chunk, 1, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + simulation.Tick(); + simulation.Tick(); + var quietBeforeInjection = simulation.GetChunkSnapshot(chunk); + + Assert.DoesNotThrow(() => simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.FirstGasId, 0.5f, SimTestHelpers.DefaultTemperature)); + var afterInjection = simulation.GetChunkSnapshot(chunk); + simulation.Tick(); + simulation.Tick(); + var quietBeforeMixtureMutation = simulation.GetChunkSnapshot(chunk); + IGasMixture mixture = simulation.GetVoxelGasMixture(chunk, 1, 0, 0); + Assert.DoesNotThrow(() => mixture.AdjustMoles(SimTestHelpers.FirstGasId, 0.25f)); + var afterMixtureMutation = simulation.GetChunkSnapshot(chunk); + + Assert.Multiple(() => + { + Assert.That(quietBeforeInjection.SleepTimer, Is.GreaterThan(0)); + Assert.That(afterInjection.IsAwake, Is.True); + Assert.That(afterInjection.SleepTimer, Is.Zero); + Assert.That(afterInjection.ActiveAirCount, Is.EqualTo(2)); + Assert.That(quietBeforeMixtureMutation.SleepTimer, Is.GreaterThan(0)); + Assert.That(afterMixtureMutation.IsAwake, Is.True); + Assert.That(afterMixtureMutation.SleepTimer, Is.Zero); + Assert.That(afterMixtureMutation.ActiveAirCount, Is.EqualTo(2), + "Room 2 is already in room 1's passable closure and must not require a second seed slot."); + Assert.That(SpeciesTotal(afterMixtureMutation, SimTestHelpers.FirstGasId), + Is.EqualTo(1.75d).Within(FloatSumTolerance(1.75d, 2))); + }); + } + + [Test] + public void RelabelingWholeAwakeChunk_PreservesActiveGasDomain() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; + using var kernel = new AtmosKernel(2, 1, 1); + kernel.SetAtmosConfig(config); + var chunk = new AtmosChunk(2, 1, 1, maxActiveRooms: 1); + chunk.Initialize(default, 2, 1, 1, maxActiveRooms: 1); + chunk.VoxelRoomMap.Fill(SimTestHelpers.RoomId); + chunk.WakeRoom(SimTestHelpers.RoomId); + float pressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / config.VoxelVolume; + chunk.InjectGasToVoxel(0, SimTestHelpers.FirstGasId, 1f, + SimTestHelpers.DefaultTemperature, 1f, pressurePerMoleKelvin); + kernel.RegisterChunk(chunk); + + for (var roomId = SimTestHelpers.RoomId + 1; + roomId <= SimTestHelpers.RoomId + 4; + roomId++) + { + kernel.SetChunkClassification(default, new VoxelClassification(roomId)); + Assert.Multiple(() => + { + Assert.That(chunk.IsAwake, Is.True); + Assert.That(chunk.ActiveRoomCount, Is.EqualTo(1), + $"Relabel to room {roomId} must replace, not accumulate, obsolete seeds."); + Assert.That(chunk.ActiveRoomIds[0], Is.EqualTo(roomId)); + Assert.That(chunk.ActiveAirCount, Is.EqualTo(2), + "Replacing the last active room ID must reseed the still-passable gas domain."); + Assert.That(chunk.VoxelRoomMap.ToArray(), Is.All.EqualTo(roomId)); + }); + } + + kernel.Tick(); + Assert.That(chunk.ActiveGases[0].Moles.Take(chunk.VoxelCount) + .Sum(static value => (double)value), + Is.EqualTo(1d).Within(FloatSumTolerance(1d, 2))); + } + + [Test] + public void RemovingLastActiveSeed_ReseedsRemainingGasBearingPassableComponent() + { + var config = CreateForcedSnappingConfig(); + config.SleepThreshold = 10; + using var kernel = new AtmosKernel(2, 1, 1); + kernel.SetAtmosConfig(config); + var chunk = new AtmosChunk(2, 1, 1, maxActiveRooms: 1); + chunk.Initialize(default, 2, 1, 1, maxActiveRooms: 1); + chunk.VoxelRoomMap[0] = SimTestHelpers.RoomId; + chunk.VoxelRoomMap[1] = SimTestHelpers.RoomId + 1; + chunk.WakeRoom(SimTestHelpers.RoomId); + float pressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / config.VoxelVolume; + chunk.InjectGasToVoxel(0, SimTestHelpers.FirstGasId, 2f, + SimTestHelpers.DefaultTemperature, 1f, pressurePerMoleKelvin); + kernel.RegisterChunk(chunk); + kernel.Tick(); + float[] beforeRemoval = chunk.ActiveGases[0].Moles.Take(chunk.VoxelCount).ToArray(); + + kernel.SetVoxelClassification(default, 0, VoxelClassification.RoomSolid); + + Assert.Multiple(() => + { + Assert.That(beforeRemoval, Is.EqualTo(new[] { 1f, 1f })); + Assert.That(chunk.IsAwake, Is.True); + Assert.That(chunk.ActiveRoomCount, Is.EqualTo(1)); + Assert.That(chunk.ActiveRoomIds[0], Is.EqualTo(SimTestHelpers.RoomId + 1), + "The obsolete room-1 seed must be replaced within the one-slot capacity."); + Assert.That(chunk.ActiveAirCount, Is.EqualTo(1), + "Removing the last room-1 seed must not leave gas-bearing room 2 outside the solver domain."); + Assert.That(chunk.ActiveAirIndices[0], Is.EqualTo(1)); + Assert.That(chunk.VoxelRoomMap.ToArray(), + Is.EqualTo(new[] { VoxelClassification.RoomSolid, SimTestHelpers.RoomId + 1 })); + }); + + kernel.Tick(); + Assert.That(chunk.ActiveGases[0].Moles[1], Is.EqualTo(1f)); + Assert.That(chunk.ActiveAirCount, Is.EqualTo(1)); + } + + [Test] + public void OpeningVoidBesideSleepingGasComponent_WakesAndVentsIt() + { + var config = CreateForcedSnappingConfig(); + config.BulkFlowCoefficient = 0.25f; + config.MaxPressureTransferFractionPerNeighbor = 0.16f; + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.SetVoxelClassification(chunk, 1, 0, 0, VoxelClassification.RoomSolid); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + var sleeping = RunUntilSleeping(simulation, chunk); + + simulation.SetVoxelClassification(chunk, 1, 0, 0, VoxelClassification.RoomVoid); + var afterOpening = simulation.GetChunkSnapshot(chunk); + simulation.Tick(); + var afterVent = simulation.GetChunkSnapshot(chunk); + + Assert.Multiple(() => + { + Assert.That(sleeping.IsAwake, Is.False); + Assert.That(afterOpening.IsAwake, Is.True, + "A newly exposed void must wake the adjacent gas-bearing component."); + Assert.That(afterOpening.SleepTimer, Is.Zero); + Assert.That(afterOpening.ActiveAirCount, Is.EqualTo(1)); + Assert.That(ReadMoles(afterVent, SimTestHelpers.FirstGasId)[0], Is.LessThan(2f)); + Assert.That(ReadMoles(afterVent, SimTestHelpers.FirstGasId)[1], Is.Zero); + Assert.That(SpeciesTotal(afterVent, SimTestHelpers.FirstGasId), Is.LessThan(2d), + "The first awake solve must vent a nonzero amount into the void."); + }); + } + + [Test] + public void UnrelatedTopologyEdit_DoesNotWakeAutomaticSleeper() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, 1, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, new VoxelClassification(1)); + simulation.SetVoxelClassification(chunk, 1, VoxelClassification.RoomVoid); + simulation.AddGasToVoxel(chunk, 0, SimTestHelpers.FirstGasId, 1f, 300f); + var sleeping = RunUntilSleeping(simulation, chunk); + + simulation.SetVoxelClassification(chunk, 2, new VoxelClassification(2)); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(sleeping.IsAwake, Is.False); + Assert.That(after.IsAwake, Is.False); + Assert.That(after.VoxelRoomMap, Is.EqualTo(new[] { 1, -1, 2 })); + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), + Is.EqualTo(ReadMoles(sleeping, SimTestHelpers.FirstGasId))); + }); + } + + [Test] + public void SplittingAutomaticSleeper_ValidatesRetainedActiveDomainAtomically() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 5, 1, 1); + var chunk = simulation.CreateAndRegisterChunk( + default, 1, VoxelClassification.RoomSolid); + for (ushort voxelIndex = 0; voxelIndex < 5; voxelIndex++) + simulation.SetVoxelClassification(chunk, voxelIndex, new VoxelClassification(voxelIndex + 1)); + simulation.AddGasToVoxel(chunk, 0, SimTestHelpers.FirstGasId, 5f, 300f); + var before = RunUntilSleeping(simulation, chunk); + + Assert.That(() => simulation.SetVoxelClassification( + chunk, 2, VoxelClassification.RoomSolid), + Throws.TypeOf()); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(after.Version, Is.EqualTo(before.Version)); + Assert.That(after.IsAwake, Is.False); + Assert.That(after.VoxelRoomMap, Is.EqualTo(before.VoxelRoomMap)); + Assert.That(after.Gases[0].Moles, Is.EqualTo(before.Gases[0].Moles)); + }); + } + + [Test] + public void InactiveSolidSeparatedRoom_RemainsBitwiseUntouchedWhileActiveRoomSnaps() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 5, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId)); + simulation.SetVoxelClassification(chunk, 1, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId)); + simulation.SetVoxelClassification(chunk, 3, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + simulation.SetVoxelClassification(chunk, 4, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + simulation.AddGasToVoxel(chunk, 3, 0, 0, + SimTestHelpers.FirstGasId, 4f, 450f); + simulation.SleepChunk(chunk); + simulation.WakeRoom(chunk, SimTestHelpers.RoomId); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + var before = simulation.GetChunkSnapshot(chunk); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(before.ActiveAirCount, Is.EqualTo(2)); + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId).Take(2), + Is.EqualTo(new[] { 1f, 1f })); + Assert.That(BitsAt(ReadMoles(after, SimTestHelpers.FirstGasId), 3, 4), + Is.EqualTo(BitsAt(ReadMoles(before, SimTestHelpers.FirstGasId), 3, 4))); + Assert.That(BitsAt(after.Temperature, 3, 4), + Is.EqualTo(BitsAt(before.Temperature, 3, 4))); + }); + } + + [Test] + public void SeededPassableClosure_WithRealSolversConservesAndExcludesSolidSeparatedInactiveRoom() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.VoxelSnappingEnabled = true; + config.VoxelSnapTemperatureEpsilon = 0.01f; + config.VoxelSnapMoleFractionEpsilon = 1f; + config.SleepEpsilon = float.MaxValue; + config.SleepThreshold = int.MaxValue; + GasProperties gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.DiffusionCoefficient = 0.1f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, 5, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(chunk, 0, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId)); + simulation.SetVoxelClassification(chunk, 1, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + simulation.SetVoxelClassification(chunk, 3, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId + 2)); + simulation.SetVoxelClassification(chunk, 4, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId + 2)); + simulation.AddGasToVoxel(chunk, 1, 0, 0, + SimTestHelpers.FirstGasId, 1f, 200f); + simulation.AddGasToVoxel(chunk, 3, 0, 0, + SimTestHelpers.FirstGasId, 4f, 450f); + simulation.SleepChunk(chunk); + simulation.WakeRoom(chunk, SimTestHelpers.RoomId); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, 400f); + var before = simulation.GetChunkSnapshot(chunk); + double initialMoles = SpeciesTotal(before, SimTestHelpers.FirstGasId); + double initialEnergy = SimTestHelpers.TotalThermalEnergyPrecise(config, before); + + simulation.Tick(); + simulation.Tick(); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(config.BulkFlowCoefficient, Is.GreaterThan(0f)); + Assert.That(config.MaxPressureTransferFractionPerNeighbor, Is.GreaterThan(0f)); + Assert.That(config.ThermalConductance, Is.GreaterThan(0f)); + Assert.That(before.ActiveAirCount, Is.EqualTo(2), + "The active seed must close over the adjacent passable room but stop at the solid separator."); + Assert.That(after.ActiveAirCount, Is.EqualTo(2)); + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId).Take(2), + Is.Not.EqualTo(ReadMoles(before, SimTestHelpers.FirstGasId).Take(2)), + "The adjacent inactive voxel must participate in real advection."); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), + Is.EqualTo(initialMoles).Within(FloatSumTolerance(initialMoles, 5))); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, after), + Is.EqualTo(initialEnergy).Within(FloatEnergyTolerance(initialEnergy, 5))); + Assert.That(BitsAt(ReadMoles(after, SimTestHelpers.FirstGasId), 3, 4), + Is.EqualTo(BitsAt(ReadMoles(before, SimTestHelpers.FirstGasId), 3, 4))); + Assert.That(BitsAt(after.Temperature, 3, 4), + Is.EqualTo(BitsAt(before.Temperature, 3, 4))); + }); + } + + [Test] + public void TopologyChangeWhileAwake_DiscardsStaleAggregateMembership() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 3f, SimTestHelpers.DefaultTemperature); + + simulation.Tick(); + var afterFirstMerge = simulation.GetChunkSnapshot(chunk); + simulation.SetVoxelClassification(chunk, 1, 0, 0, VoxelClassification.RoomSolid); + var afterTopologyChange = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(ReadMoles(afterFirstMerge, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1.5f, 1.5f, 0f })); + Assert.That(ReadMoles(afterTopologyChange, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1.5f, 1.5f, 0f }), + "The formerly joined endpoints must not remain connected through the new solid voxel."); + Assert.That(afterTopologyChange.VoxelRoomMap[1], + Is.EqualTo(VoxelClassification.RoomSolid)); + Assert.That(SpeciesTotal(afterTopologyChange, SimTestHelpers.FirstGasId), Is.EqualTo(3d)); + }); + } + + [Test] + public void OpeningSolidSeparator_WakesSleepingChunkAndSnapsNewConnectedComponent() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.SetVoxelClassification(chunk, 1, 0, 0, VoxelClassification.RoomSolid); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + simulation.AddGasToVoxel(chunk, 2, 0, 0, + SimTestHelpers.FirstGasId, 3f, SimTestHelpers.DefaultTemperature); + var whileSeparated = RunUntilSleeping(simulation, chunk); + + simulation.SetVoxelClassification(chunk, 1, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId)); + var afterOpening = simulation.GetChunkSnapshot(chunk); + var afterResleep = RunUntilSleeping(simulation, chunk); + float[] finalMoles = ReadMoles(afterResleep, SimTestHelpers.FirstGasId); + + Assert.Multiple(() => + { + Assert.That(ReadMoles(whileSeparated, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1f, 0f, 3f })); + Assert.That(afterOpening.IsAwake, Is.True, + "Making a gas-bearing sleeping topology passable must schedule it for recomputation."); + Assert.That(afterOpening.SleepTimer, Is.Zero); + Assert.That(afterResleep.IsAwake, Is.False); + Assert.That(finalMoles.Max() - finalMoles.Min(), + Is.LessThanOrEqualTo(Ulp(finalMoles.Max()))); + Assert.That(SpeciesTotal(afterResleep, SimTestHelpers.FirstGasId), + Is.EqualTo(4d).Within(FloatSumTolerance(4d, 3))); + }); + } + + [Test] + public void DisabledSnapping_PreservesLegacyNonUniformSleepState() + { + var config = CreateForcedSnappingConfig(); + config.VoxelSnappingEnabled = false; + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 3f, SimTestHelpers.DefaultTemperature); + + var after = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(after.IsAwake, Is.False); + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 3f, 0f, 0f })); + Assert.That(SpeciesTotal(after, SimTestHelpers.FirstGasId), Is.EqualTo(3d)); + }); + } + + [Test] + public void ManualSleep_DoesNotMaterializeSnappedVoxels() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 3f, SimTestHelpers.DefaultTemperature); + var before = simulation.GetChunkSnapshot(chunk); + + simulation.SleepChunk(chunk); + simulation.Tick(); + var after = simulation.GetChunkSnapshot(chunk); + + Assert.Multiple(() => + { + Assert.That(after.IsAwake, Is.False); + Assert.That(ReadMoles(after, SimTestHelpers.FirstGasId), + Is.EqualTo(ReadMoles(before, SimTestHelpers.FirstGasId))); + Assert.That(after.Temperature, Is.EqualTo(before.Temperature)); + }); + } + + [Test] + public void InjectionWakesSnappedChunkAndNextSleepConservesAddedMassAndEnergy() + { + var config = CreateForcedSnappingConfig(); + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 1f); + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, 300f); + RunUntilSleeping(simulation, chunk); + + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, 600f); + var afterInjection = simulation.GetChunkSnapshot(chunk); + double energyAfterInjection = SimTestHelpers.TotalThermalEnergyPrecise(config, afterInjection); + var afterResleep = RunUntilSleeping(simulation, chunk); + + Assert.Multiple(() => + { + Assert.That(afterInjection.IsAwake, Is.True); + Assert.That(afterInjection.SleepTimer, Is.Zero); + Assert.That(ReadMoles(afterResleep, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 1.5f, 1.5f })); + Assert.That(afterResleep.Temperature, Is.EqualTo(new[] { 400f, 400f })); + Assert.That(SpeciesTotal(afterResleep, SimTestHelpers.FirstGasId), Is.EqualTo(3d)); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, afterResleep), + Is.EqualTo(energyAfterInjection)); + }); + } + + [Test] + public void InjectionBeyondCorrectionLimits_DissolvesAggregateAndKeepsChunkAwake() + { + var config = CreateForcedSnappingConfig(); + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 1f); + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, 300f); + RunUntilSleeping(simulation, chunk); + + config.SleepEpsilon = 0.5f; + config.VoxelSnapTemperatureEpsilon = 0.01f; + config.VoxelSnapMoleFractionEpsilon = 0.001f; + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, 600f); + simulation.Tick(); + var afterRejectedProjection = simulation.GetChunkSnapshot(chunk); + + Assert.Multiple(() => + { + Assert.That(afterRejectedProjection.IsAwake, Is.True); + Assert.That(afterRejectedProjection.SleepTimer, Is.Zero); + Assert.That(ReadMoles(afterRejectedProjection, SimTestHelpers.FirstGasId), + Is.EqualTo(new[] { 2f, 1f })); + Assert.That(afterRejectedProjection.Temperature, Is.EqualTo(new[] { 450f, 300f })); + Assert.That(SpeciesTotal(afterRejectedProjection, SimTestHelpers.FirstGasId), Is.EqualTo(3d)); + }); + } + + [Test] + public void CrossChunkTransfer_CancelsSnapSleepAndWakesTarget() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.VoxelSnappingEnabled = true; + config.VoxelSnapTemperatureEpsilon = float.MaxValue; + config.VoxelSnapMoleFractionEpsilon = 1f; + config.SleepEpsilon = float.MaxValue; + config.SleepThreshold = 0; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = SimTestHelpers.CreateOpenChunk(simulation, default); + var target = SimTestHelpers.CreateOpenChunk(simulation, Int3.PosX); + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + + simulation.Tick(); + var sourceAfterFirst = simulation.GetChunkSnapshot(source); + var targetAfterFirst = simulation.GetChunkSnapshot(target); + simulation.Tick(); + var sourceAfterSecond = simulation.GetChunkSnapshot(source); + var targetAfterSecond = simulation.GetChunkSnapshot(target); + + Assert.Multiple(() => + { + Assert.That(sourceAfterFirst.IsAwake, Is.True); + Assert.That(targetAfterFirst.IsAwake, Is.True); + Assert.That(SpeciesTotal(sourceAfterFirst, SimTestHelpers.FirstGasId) + + SpeciesTotal(targetAfterFirst, SimTestHelpers.FirstGasId), Is.EqualTo(2d)); + Assert.That(SpeciesTotal(targetAfterFirst, SimTestHelpers.FirstGasId), Is.EqualTo(0.25d)); + Assert.That(sourceAfterSecond.IsAwake, Is.True); + Assert.That(targetAfterSecond.IsAwake, Is.True); + Assert.That(SpeciesTotal(targetAfterSecond, SimTestHelpers.FirstGasId), + Is.GreaterThan(SpeciesTotal(targetAfterFirst, SimTestHelpers.FirstGasId))); + }); + } + + [Test] + public void BoundaryTransferIntoAwakeChunk_ResetsItsEstablishedSleepTimer() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.SleepEpsilon = 1f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = SimTestHelpers.CreateOpenChunk(simulation, default); + var target = SimTestHelpers.CreateOpenChunk(simulation, Int3.PosX); + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + simulation.AddGasToVoxel(target, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + for (var tick = 0; tick < 3; tick++) + simulation.Tick(); + var targetBeforeTransfer = simulation.GetChunkSnapshot(target); + + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + simulation.Tick(); + + var targetAfterTransfer = simulation.GetChunkSnapshot(target); + Assert.Multiple(() => + { + Assert.That(targetBeforeTransfer.IsAwake, Is.True); + Assert.That(targetBeforeTransfer.SleepTimer, Is.GreaterThan(0)); + Assert.That(SpeciesTotal(targetAfterTransfer, SimTestHelpers.FirstGasId), + Is.GreaterThan(SpeciesTotal(targetBeforeTransfer, SimTestHelpers.FirstGasId))); + Assert.That(targetAfterTransfer.IsAwake, Is.True); + Assert.That(targetAfterTransfer.SleepTimer, Is.Zero, + "Boundary injection must reset an already-awake target without relying on a wake transition."); + }); + } + + [Test] + public void RegisteringMissingBoundaryNeighbor_WakesSleepingSourceAndRestoresFlow() + { + var config = CreateForcedSnappingConfig(); + config.BulkFlowCoefficient = 0.25f; + config.MaxPressureTransferFractionPerNeighbor = 0.16f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + var sleepingSource = RunUntilSleeping(simulation, source); + + var target = simulation.CreateAndRegisterChunk(Int3.PosX); + simulation.SetChunkClassification(target, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + var sourceAfterRegistration = simulation.GetChunkSnapshot(source); + simulation.Tick(); + + var sourceAfterFlow = simulation.GetChunkSnapshot(source); + var targetAfterFlow = simulation.GetChunkSnapshot(target); + Assert.Multiple(() => + { + Assert.That(sleepingSource.IsAwake, Is.False); + Assert.That(sourceAfterRegistration.IsAwake, Is.True, + "Registering the formerly missing boundary must wake a gas-bearing source face."); + Assert.That(sourceAfterRegistration.SleepTimer, Is.Zero); + Assert.That(SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId), Is.GreaterThan(0d)); + Assert.That(SpeciesTotal(sourceAfterFlow, SimTestHelpers.FirstGasId), Is.LessThan(2d)); + Assert.That(SpeciesTotal(sourceAfterFlow, SimTestHelpers.FirstGasId) + + SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId), + Is.EqualTo(2d).Within(FloatSumTolerance(2d, 2))); + }); + } + + [Test] + public void RegisteringNeighbor_WakesManuallySleepingComponentWithInteriorGas() + { + var config = CreateForcedSnappingConfig(); + config.BulkFlowCoefficient = 0.25f; + config.MaxPressureTransferFractionPerNeighbor = 0.16f; + using var simulation = new AtmosSimulation(config, 3, 1, 1); + var source = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + simulation.SleepChunk(source); + var manuallySleeping = simulation.GetChunkSnapshot(source); + + var target = simulation.CreateAndRegisterChunk(Int3.PosX); + var sourceAfterRegistration = simulation.GetChunkSnapshot(source); + AtmosChunkSnapshot targetAfterFlow = simulation.GetChunkSnapshot(target); + for (var tick = 0; + tick < 8 && SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId) == 0d; + tick++) + { + simulation.Tick(); + targetAfterFlow = simulation.GetChunkSnapshot(target); + } + var sourceAfterFlow = simulation.GetChunkSnapshot(source); + + Assert.Multiple(() => + { + Assert.That(manuallySleeping.IsAwake, Is.False); + Assert.That(ReadMoles(manuallySleeping, SimTestHelpers.FirstGasId)[2], Is.Zero, + "The gas must start away from the newly exposed boundary face."); + Assert.That(sourceAfterRegistration.IsAwake, Is.True, + "Registration must scan the whole boundary-connected component for gas."); + Assert.That(sourceAfterRegistration.SleepTimer, Is.Zero); + Assert.That(SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId), Is.GreaterThan(0d)); + Assert.That(SpeciesTotal(sourceAfterFlow, SimTestHelpers.FirstGasId) + + SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId), + Is.EqualTo(2d).Within(FloatSumTolerance(2d, 6))); + }); + } + + [TestCase(false, TestName = "CreateAndRegisterChunk_CapacityFailureIsAtomic")] + [TestCase(true, TestName = "RegisterChunk_CapacityFailureIsAtomic")] + public void BoundaryWakeCapacityFailure_DoesNotRegisterOrPartiallyWake(bool callerOwnedTarget) + { + const int height = 3; + var config = SimTestHelpers.CreateDeterministicConfig(); + using var kernel = new AtmosKernel(1, height, 1); + kernel.SetAtmosConfig(config); + var source = new AtmosChunk(1, height, 1, maxActiveRooms: 1); + source.Initialize(default, 1, height, 1, maxActiveRooms: 1); + source.VoxelRoomMap[0] = SimTestHelpers.RoomId; + source.VoxelRoomMap[1] = VoxelClassification.RoomSolid; + source.VoxelRoomMap[2] = SimTestHelpers.RoomId + 1; + source.WakeRoom(SimTestHelpers.RoomId); + source.InjectGasToVoxel(0, SimTestHelpers.FirstGasId, 1f, + SimTestHelpers.DefaultTemperature, 1f, 1f); + source.ActiveGases[0].Moles[2] = 1f; + source.Temperature[2] = SimTestHelpers.DefaultTemperature; + source.TotalHeatCapacity[2] = 1f; + source.TotalPressure[2] = SimTestHelpers.DefaultTemperature; + source.Sleep(); + source.SleepTimer = 7; + kernel.RegisterChunk(source); + Assert.That(kernel.TryGetChunkPositions(-1, out long revisionBefore, out _), Is.True); + AtmosChunkVersion versionBefore = source.Version; + int[] activeRoomsBefore = source.ActiveRoomIds.Take(source.ActiveRoomCount).ToArray(); + ushort[] activeAirBefore = source.ActiveAirIndices.Take(source.ActiveAirCount).ToArray(); + float[] gasesBefore = source.ActiveGases[0].Moles.ToArray(); + AtmosChunk? target = null; + + TestDelegate register = callerOwnedTarget + ? () => + { + target = new AtmosChunk(1, height, 1); + target.Initialize(Int3.PosX, 1, height, 1, + AtmosChunkConstants.DefaultMaxActiveRooms); + kernel.RegisterChunk(target); + } + : () => kernel.CreateAndRegisterChunk(Int3.PosX, 1, height, 1, + AtmosChunkConstants.DefaultMaxActiveRooms); + + Assert.Catch(register, + "Exposing two disconnected gas-bearing boundary rooms must exceed the one-room capacity."); + Int3[] positionsAfter = kernel.GetChunkPositions(); + kernel.TryGetChunkPositions(revisionBefore, out long revisionAfter, out _); + + Assert.Multiple(() => + { + Assert.That(positionsAfter, Is.EqualTo(new[] { default(Int3) }), + "A failed registration must not leave a hidden target chunk."); + Assert.That(revisionAfter, Is.EqualTo(revisionBefore), + "A rolled-back registration must not publish a collection revision."); + Assert.That(source.IsAwake, Is.False); + Assert.That(source.SleepTimer, Is.EqualTo(7)); + Assert.That(source.ActiveRoomCount, Is.EqualTo(activeRoomsBefore.Length)); + Assert.That(source.ActiveRoomIds.Take(source.ActiveRoomCount), Is.EqualTo(activeRoomsBefore)); + Assert.That(source.ActiveAirIndices.Take(source.ActiveAirCount), Is.EqualTo(activeAirBefore)); + Assert.That(source.ActiveGases[0].Moles, Is.EqualTo(gasesBefore)); + Assert.That(source.Version, Is.EqualTo(versionBefore)); + }); + + if (target != null && !positionsAfter.Contains(Int3.PosX)) + target.Release(); + } + + [Test] + public void ClassifyingExistingBoundaryPassable_WakesSleepingGasNeighborAndRestoresFlow() + { + var config = CreateForcedSnappingConfig(); + config.BulkFlowCoefficient = 0.25f; + config.MaxPressureTransferFractionPerNeighbor = 0.16f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var source = simulation.CreateAndRegisterChunk(default); + var target = simulation.CreateAndRegisterChunk(Int3.PosX); + simulation.SetChunkClassification(source, + new VoxelClassification(SimTestHelpers.RoomId)); + simulation.SetChunkClassification(target, VoxelClassification.RoomSolid); + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + var sleepingSource = RunUntilSleeping(simulation, source); + + simulation.SetChunkClassification(target, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + var sourceAfterOpening = simulation.GetChunkSnapshot(source); + simulation.Tick(); + + var sourceAfterFlow = simulation.GetChunkSnapshot(source); + var targetAfterFlow = simulation.GetChunkSnapshot(target); + Assert.Multiple(() => + { + Assert.That(sleepingSource.IsAwake, Is.False); + Assert.That(SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId), Is.GreaterThan(0d)); + Assert.That(sourceAfterOpening.IsAwake, Is.True, + "Opening an existing empty boundary must wake the sleeping gas-bearing neighbor."); + Assert.That(sourceAfterOpening.SleepTimer, Is.Zero); + Assert.That(SpeciesTotal(sourceAfterFlow, SimTestHelpers.FirstGasId) + + SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId), + Is.EqualTo(2d).Within(FloatSumTolerance(2d, 2))); + }); + } + + [Test] + public void OpeningBoundaryToConnectedDifferentlyLabeledSource_UsesOneWakeCapacitySlot() + { + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, 1, 2, 1); + var source = simulation.CreateAndRegisterChunk(default, maxActiveRooms: 1); + var target = simulation.CreateAndRegisterChunk(Int3.PosX); + simulation.SetChunkClassification(source, VoxelClassification.RoomSolid); + simulation.SetVoxelClassification(source, 0, 0, 0, + new VoxelClassification(SimTestHelpers.RoomId)); + simulation.SetVoxelClassification(source, 0, 1, 0, + new VoxelClassification(SimTestHelpers.RoomId + 1)); + simulation.SetChunkClassification(target, VoxelClassification.RoomSolid); + simulation.AddGasToVoxel(source, 0, 0, 0, + SimTestHelpers.FirstGasId, 2f, SimTestHelpers.DefaultTemperature); + var sleepingSource = RunUntilSleeping(simulation, source); + + config.BulkFlowCoefficient = 0.25f; + config.MaxPressureTransferFractionPerNeighbor = 0.16f; + Assert.DoesNotThrow(() => simulation.SetChunkClassification(target, + new VoxelClassification(SimTestHelpers.RoomId + 2))); + var sourceAfterOpening = simulation.GetChunkSnapshot(source); + simulation.Tick(); + var sourceAfterFlow = simulation.GetChunkSnapshot(source); + var targetAfterFlow = simulation.GetChunkSnapshot(target); + + Assert.Multiple(() => + { + Assert.That(sleepingSource.IsAwake, Is.False); + Assert.That(sourceAfterOpening.IsAwake, Is.True); + Assert.That(sourceAfterOpening.SleepTimer, Is.Zero); + Assert.That(sourceAfterOpening.ActiveAirCount, Is.EqualTo(2), + "Different labels in one passable component must consume only one wake-capacity slot."); + Assert.That(SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId), Is.GreaterThan(0d)); + Assert.That(SpeciesTotal(sourceAfterFlow, SimTestHelpers.FirstGasId) + + SpeciesTotal(targetAfterFlow, SimTestHelpers.FirstGasId), + Is.EqualTo(2d).Within(FloatSumTolerance(2d, 4))); + }); + } + + [Test] + public void TemperatureMutationBeforeThermodynamics_InvalidatesAggregateForSameTickExchange() + { + var config = CreateForcedSnappingConfig(); + config.SleepThreshold = 10; + config.VoxelSnapTemperatureEpsilon = 0.01f; + config.ThermalConductance = 0.05f; + using var kernel = new AtmosKernel(2, 1, 1); + kernel.SetAtmosConfig(config); + var chunk = new AtmosChunk(2, 1, 1); + chunk.Initialize(default, 2, 1, 1, AtmosChunkConstants.DefaultMaxActiveRooms); + chunk.VoxelRoomMap.Fill(SimTestHelpers.RoomId); + chunk.WakeRoom(SimTestHelpers.RoomId); + float pressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / config.VoxelVolume; + chunk.InjectGasToVoxel(0, SimTestHelpers.FirstGasId, 1f, + SimTestHelpers.DefaultTemperature, 1f, pressurePerMoleKelvin); + chunk.InjectGasToVoxel(1, SimTestHelpers.FirstGasId, 1f, + SimTestHelpers.DefaultTemperature, 1f, pressurePerMoleKelvin); + kernel.RegisterChunk(chunk); + kernel.RegisterSolverBefore(AtmosBuiltInSolvers.Thermodynamics, + "heat-established-aggregate", SolverStepKind.Dangerous, context => + { + if (context.TickCount == 2) + { + AtmosChunk current = context.Chunks.Single(); + current.Temperature[0] = 400f; + current.MarkChanged(); + } + }); + + kernel.Tick(); + Assert.That(chunk.VoxelAggregates.AreAggregatedTogether(0, 1), Is.True, + "The equal starting state must establish the aggregate that the fingerprint guards."); + Assert.That(chunk.Temperature.ToArray(), + Is.EqualTo(new[] { 300f, 300f }).Within(SimTestHelpers.Tolerance)); + kernel.Tick(); + + Assert.Multiple(() => + { + Assert.That(chunk.Temperature[0], + Is.EqualTo(395f).Within(SimTestHelpers.Tolerance), + "A stale aggregate fingerprint must not suppress heat leaving the mutated voxel."); + Assert.That(chunk.Temperature[1], + Is.EqualTo(305f).Within(SimTestHelpers.Tolerance), + "Thermodynamics must observe the custom-stage mutation on the same tick."); + Assert.That(chunk.Temperature.ToArray().Sum(), + Is.EqualTo(700f).Within(SimTestHelpers.Tolerance)); + Assert.That(chunk.VoxelAggregates.AreAggregatedTogether(0, 1), Is.False, + "The terminal coordinator must split the aggregate after thermodynamics observes it."); + Assert.That(chunk.IsAwake, Is.True); + Assert.That(chunk.SleepTimer, Is.Zero, + "The terminal coordinator must split the now-ineligible aggregate and reset quiet time."); + }); + } + + [Test] + public void ZeroSleepThreshold_WaitsThroughInterveningTickForCrossChunkThermodynamics() + { + var config = CreateForcedSnappingConfig(); + config.ThermalConductance = 0.05f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var hot = SimTestHelpers.CreateOpenChunk(simulation, default); + var cold = SimTestHelpers.CreateOpenChunk(simulation, Int3.PosX); + simulation.AddGasToVoxel(hot, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, 400f); + simulation.AddGasToVoxel(cold, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, 200f); + + simulation.Tick(); + simulation.Tick(); + var afterThermodynamics = new[] + { + simulation.GetChunkSnapshot(hot), simulation.GetChunkSnapshot(cold) + }; + simulation.Tick(); + var afterInterveningTick = new[] + { + simulation.GetChunkSnapshot(hot), simulation.GetChunkSnapshot(cold) + }; + + Assert.Multiple(() => + { + Assert.That(afterThermodynamics[0].Temperature[0], + Is.GreaterThan(afterThermodynamics[1].Temperature[0]), + "The first thermal pass must leave an actionable boundary gradient for the regression."); + Assert.That(afterInterveningTick.Select(snapshot => snapshot.IsAwake), Is.All.True, + "A quiet odd tick cannot commit sleep before the next lower-frequency thermal pass."); + Assert.That(afterInterveningTick.Select(snapshot => snapshot.SleepTimer), Is.All.EqualTo(1)); + Assert.That(afterInterveningTick.Select(snapshot => snapshot.Temperature[0]), + Is.EqualTo(afterThermodynamics.Select(snapshot => snapshot.Temperature[0]))); + }); + } + + [Test] + public void ThermalBoundaryTransfer_IntoSleepingChunkWakesAndResetsTimer() + { + var config = CreateForcedSnappingConfig(); + config.SleepThreshold = 10; + config.ThermalConductance = 0.05f; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var hot = SimTestHelpers.CreateOpenChunk(simulation, default); + var cold = SimTestHelpers.CreateOpenChunk(simulation, Int3.PosX); + simulation.AddGasToVoxel(hot, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, 300f); + simulation.AddGasToVoxel(cold, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, 300f); + for (var tick = 0; tick < 3; tick++) + simulation.Tick(); + + simulation.SetVoxelTemperature(hot, 0, 0, 0, 400f); + simulation.SleepChunk(cold); + var beforeTransfer = simulation.GetChunkSnapshot(cold); + double energyBeforeTransfer = SimTestHelpers.TotalThermalEnergyPrecise(config, + simulation.GetChunkSnapshot(hot), beforeTransfer); + + simulation.Tick(); + + var hotAfter = simulation.GetChunkSnapshot(hot); + var coldAfter = simulation.GetChunkSnapshot(cold); + Assert.Multiple(() => + { + Assert.That(beforeTransfer.IsAwake, Is.False); + Assert.That(beforeTransfer.SleepTimer, Is.GreaterThan(0), + "The sleeping target must carry a stale timer so the reset assertion is meaningful."); + Assert.That(coldAfter.IsAwake, Is.True); + Assert.That(coldAfter.SleepTimer, Is.Zero); + Assert.That(coldAfter.Temperature[0], Is.GreaterThan(beforeTransfer.Temperature[0]), + "Only a nonzero boundary transfer should wake the sleeping target."); + Assert.That(hotAfter.Temperature[0], Is.LessThan(400f)); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, hotAfter, coldAfter), + Is.EqualTo(energyBeforeTransfer).Within(SimTestHelpers.EnergyTolerance)); + }); + } + + [Test] + public void ArbitraryFloatMixture_ConservesWithinFloatScaleAndIsDeterministic() + { + ConservationRun first = RunArbitraryConservationScenario(); + ConservationRun second = RunArbitraryConservationScenario(reverseGasInjectionOrder: true); + + Assert.Multiple(() => + { + for (var gasId = 0; gasId < first.InitialSpeciesTotals.Length; gasId++) + { + double expected = first.InitialSpeciesTotals[gasId]; + Assert.That(SpeciesTotal(first.After, gasId), + Is.EqualTo(expected).Within(FloatSumTolerance(expected, first.After.Temperature.Length)), + $"Gas {gasId} must be conserved across the final reprojection."); + } + + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(first.Config, first.After), + Is.EqualTo(first.InitialEnergy) + .Within(FloatEnergyTolerance(first.InitialEnergy, first.After.Temperature.Length))); + Assert.That(first.After.Temperature, Is.EqualTo(second.After.Temperature)); + Assert.That(first.After.TotalPressure, Is.EqualTo(second.After.TotalPressure)); + Assert.That(ReadMoles(first.After, SimTestHelpers.FirstGasId), + Is.EqualTo(ReadMoles(second.After, SimTestHelpers.FirstGasId))); + Assert.That(ReadMoles(first.After, SimTestHelpers.SecondGasId), + Is.EqualTo(ReadMoles(second.After, SimTestHelpers.SecondGasId))); + }); + } + + [Test] + public void TraceSpeciesWithSubCutoffUniformShare_IsNotDiscardedBySnapping() + { + const int width = 16; + const float traceMoles = 0.0008f; + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, width, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + for (var x = 0; x < width; x++) + { + simulation.AddGasToVoxel(chunk, x, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + } + + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.SecondGasId, traceMoles, SimTestHelpers.DefaultTemperature); + + var after = RunUntilSleeping(simulation, chunk); + float[] trace = ReadMoles(after, SimTestHelpers.SecondGasId); + + Assert.Multiple(() => + { + Assert.That(SpeciesTotal(after, SimTestHelpers.SecondGasId), + Is.EqualTo(traceMoles).Within(FloatSumTolerance(traceMoles, width))); + Assert.That(trace, Is.All.GreaterThan(0f), + "Snapping must not turn a conserved trace channel into zero-valued voxels."); + }); + } + + [Test] + public void NonPowerOfTwoTraceRemainder_SurvivesWakeAndUlpScaleDiffusion() + { + const int width = 3; + const float traceMoles = 0.0002f; + var config = CreateForcedSnappingConfig(); + using var simulation = new AtmosSimulation(config, width, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + for (var x = 0; x < width; x++) + { + simulation.AddGasToVoxel(chunk, x, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + } + + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.SecondGasId, traceMoles, SimTestHelpers.DefaultTemperature); + var snapped = RunUntilSleeping(simulation, chunk); + float[] snappedTrace = ReadMoles(snapped, SimTestHelpers.SecondGasId); + + GasProperties trace = config.GasRegistry[SimTestHelpers.SecondGasId]; + trace.DiffusionCoefficient = 0.1f; + config.GasRegistry[SimTestHelpers.SecondGasId] = trace; + simulation.WakeRoom(chunk, SimTestHelpers.RoomId); + simulation.Tick(); + var afterWakeTick = simulation.GetChunkSnapshot(chunk); + + Assert.Multiple(() => + { + Assert.That(SpeciesTotal(snapped, SimTestHelpers.SecondGasId), + Is.EqualTo(traceMoles).Within(FloatSumTolerance(traceMoles, width))); + Assert.That(snappedTrace, Is.All.GreaterThan(0f)); + Assert.That(snappedTrace.Max() - snappedTrace.Min(), + Is.LessThanOrEqualTo(Ulp(snappedTrace.Max()))); + Assert.That(SpeciesTotal(afterWakeTick, SimTestHelpers.SecondGasId), + Is.EqualTo(traceMoles).Within(FloatSumTolerance(traceMoles, width)), + "An ULP-scale redistribution must not trigger whole-voxel trace deletion."); + Assert.That(ReadMoles(afterWakeTick, SimTestHelpers.SecondGasId), + Is.All.GreaterThan(0f)); + }); + } + + [Test] + public void ThirtyOneVoxelRemainder_WithDiffusionSleepsAndEstablishedAggregateIsIdempotent() + { + const int width = 31; + var config = CreateForcedSnappingConfig(); + GasProperties gas = config.GasRegistry[SimTestHelpers.FirstGasId]; + gas.DiffusionCoefficient = 0.1f; + config.GasRegistry[SimTestHelpers.FirstGasId] = gas; + using var simulation = new AtmosSimulation(config, width, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, + SimTestHelpers.FirstGasId, 1f, SimTestHelpers.DefaultTemperature); + + var foundEstablishedAggregate = false; + AtmosChunkSnapshot established = default; + for (var tick = 0; tick < 128; tick++) + { + simulation.Tick(); + AtmosChunkSnapshot snapshot = simulation.GetChunkSnapshot(chunk); + if (snapshot.IsAwake && snapshot.SleepTimer == 1) + { + established = snapshot; + foundEstablishedAggregate = true; + break; + } + } + + Assert.That(foundEstablishedAggregate, Is.True, + "The 31-member component must finish progressive merging and enter verification."); + simulation.Tick(); + var afterUnchangedVerificationTick = simulation.GetChunkSnapshot(chunk); + simulation.Tick(); + var sleeping = simulation.GetChunkSnapshot(chunk); + float[] establishedMoles = ReadMoles(established, SimTestHelpers.FirstGasId); + + Assert.Multiple(() => + { + Assert.That(establishedMoles.Max() - establishedMoles.Min(), + Is.LessThanOrEqualTo(Ulp(establishedMoles.Max()))); + Assert.That(SpeciesTotal(established, SimTestHelpers.FirstGasId), + Is.EqualTo(1d).Within(FloatSumTolerance(1d, width))); + Assert.That(afterUnchangedVerificationTick.IsAwake, Is.True); + Assert.That(afterUnchangedVerificationTick.SleepTimer, Is.EqualTo(2)); + Assert.That(ReadMoles(afterUnchangedVerificationTick, SimTestHelpers.FirstGasId), + Is.EqualTo(establishedMoles)); + Assert.That(afterUnchangedVerificationTick.Temperature, Is.EqualTo(established.Temperature)); + Assert.That(sleeping.IsAwake, Is.False); + Assert.That(ReadMoles(sleeping, SimTestHelpers.FirstGasId), + Is.EqualTo(establishedMoles)); + Assert.That(sleeping.Temperature, Is.EqualTo(established.Temperature)); + }); + } + + [Test] + public void ProjectionWhosePerVoxelHeatCapacityWouldOverflow_IsRejectedWithoutInfinity() + { + const int width = 3; + var config = CreateForcedSnappingConfig(); + SetHeatCapacity(config, SimTestHelpers.FirstGasId, float.MaxValue); + SetHeatCapacity(config, SimTestHelpers.SecondGasId, float.MaxValue); + config.GasRegistry.Add(new GasProperties + { + Name = "Third", + MolarHeatCapacityAtConstantVolume = float.MaxValue, + DiffusionCoefficient = 0f + }); + double projectedHeatCapacity = 3d * (1f / 3f) * float.MaxValue; + Assert.That(projectedHeatCapacity, Is.GreaterThan((double)float.MaxValue), + "Rounded one-third shares must make this an actual float-cache overflow probe."); + + using var kernel = new AtmosKernel(width, 1, 1); + kernel.SetAtmosConfig(config); + var chunk = new AtmosChunk(width, 1, 1); + chunk.Initialize(default, width, 1, 1, AtmosChunkConstants.DefaultMaxActiveRooms); + chunk.VoxelRoomMap.Fill(SimTestHelpers.RoomId); + chunk.WakeRoom(SimTestHelpers.RoomId); + float pressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / config.VoxelVolume; + for (ushort voxelIndex = 0; voxelIndex < width; voxelIndex++) + { + chunk.InjectGasToVoxel(voxelIndex, voxelIndex, 1f, SimTestHelpers.DefaultTemperature, + float.MaxValue, pressurePerMoleKelvin); + } + + kernel.RegisterChunk(chunk); + kernel.Tick(); + float[][] afterSafePairMerge = chunk.ActiveGases.Take(chunk.ActiveGasCount) + .Select(channel => channel.Moles.ToArray()) + .ToArray(); + for (var tick = 0; tick < 8; tick++) + { + kernel.Tick(); + Assert.That(chunk.TotalHeatCapacity.ToArray().All(float.IsFinite), Is.True, + $"Tick {kernel.TickCount} must not create an infinite heat-capacity cache."); + } + + Assert.Multiple(() => + { + Assert.That(chunk.VoxelAggregates.AreAggregatedTogether(0, 1), Is.True, + "The exactly representable two-member projection is the positive control."); + Assert.That(chunk.VoxelAggregates.AreAggregatedTogether(0, 2), Is.False, + "The three-member projection must be refused before its cache overflows."); + Assert.That(chunk.IsAwake, Is.True); + Assert.That(chunk.SleepTimer, Is.Zero); + Assert.That(chunk.TotalHeatCapacity.ToArray(), Is.All.EqualTo(float.MaxValue)); + Assert.That(chunk.TotalPressure.ToArray().All(float.IsFinite), Is.True); + Assert.That(chunk.Temperature.ToArray().All(float.IsFinite), Is.True); + Assert.That(chunk.ActiveGases.Take(chunk.ActiveGasCount) + .Select(channel => channel.Moles.ToArray()), + Is.EqualTo(afterSafePairMerge)); + }); + } + + [Test] + public void ProjectionWhosePerSpeciesWritebackWouldOverflowTotalMoles_IsRejected() + { + const int width = 25; + var config = CreateForcedSnappingConfig(); + config.DefaultTemperatureFallback = 1f; + config.VoxelVolume = float.MaxValue; + SetHeatCapacity(config, SimTestHelpers.FirstGasId, float.Epsilon); + SetHeatCapacity(config, SimTestHelpers.SecondGasId, float.Epsilon); + while (config.GasRegistry.Count < width) + { + config.GasRegistry.Add(new GasProperties + { + Name = $"Gas {config.GasRegistry.Count}", + MolarHeatCapacityAtConstantVolume = float.Epsilon, + DiffusionCoefficient = 0f + }); + } + + float projectedSpeciesShare = (float)((double)float.MaxValue / width); + double projectedTotalMoles = width * (double)projectedSpeciesShare; + Assert.That(float.IsFinite((float)projectedTotalMoles), Is.False, + "Rounded per-species shares must make this an actual float total-moles overflow probe."); + + using var kernel = new AtmosKernel(width, 1, 1); + kernel.SetAtmosConfig(config); + var chunk = new AtmosChunk(width, 1, 1); + chunk.Initialize(default, width, 1, 1, AtmosChunkConstants.DefaultMaxActiveRooms); + chunk.VoxelRoomMap.Fill(SimTestHelpers.RoomId); + chunk.WakeRoom(SimTestHelpers.RoomId); + float pressurePerMoleKelvin = AtmosPhysicalConstants.MolarGasConstant / config.VoxelVolume; + for (ushort voxelIndex = 0; voxelIndex < width; voxelIndex++) + { + chunk.InjectGasToVoxel(voxelIndex, voxelIndex, float.MaxValue, 1f, + float.Epsilon, pressurePerMoleKelvin); + } + + Assert.That(chunk.TotalPressure.ToArray().All(float.IsFinite), Is.True, + "Every input voxel must begin with finite pressure."); + Assert.That(chunk.TotalHeatCapacity.ToArray().All(float.IsFinite), Is.True, + "Every input voxel must begin with finite heat capacity."); + kernel.RegisterChunk(chunk); + for (var tick = 0; tick < 6; tick++) + kernel.Tick(); + float[][] blockedState = chunk.ActiveGases.Take(chunk.ActiveGasCount) + .Select(channel => channel.Moles.ToArray()) + .ToArray(); + for (var tick = 0; tick < 4; tick++) + kernel.Tick(); + + Assert.Multiple(() => + { + Assert.That(chunk.VoxelAggregates.AreAggregatedTogether(0, 1), Is.True, + "Two half-MaxValue species still form a finite positive-control aggregate."); + Assert.That(chunk.VoxelAggregates.AreAggregatedTogether(0, width - 1), Is.False, + "The twenty-five-species projection must be refused before summing to infinity."); + Assert.That(chunk.IsAwake, Is.True); + Assert.That(chunk.SleepTimer, Is.Zero); + Assert.That(chunk.TotalPressure.ToArray().All(float.IsFinite), Is.True); + Assert.That(chunk.TotalHeatCapacity.ToArray().All(float.IsFinite), Is.True); + Assert.That(chunk.Temperature.ToArray().All(float.IsFinite), Is.True); + Assert.That(chunk.ActiveGases.Take(chunk.ActiveGasCount) + .Select(channel => channel.Moles.ToArray()), + Is.EqualTo(blockedState)); + }); + } + + [Test] + public void ProductionDefaults_CornerInjectionSleepsNearUniformWithinOneThousandTicks() + { + const int size = 16; + var config = new AtmosConfig(); + config.GasRegistry.Add(new GasProperties + { + Name = "O2", + MolarHeatCapacityAtConstantVolume = 20.786157f, + BoilingPoint = 90.2f, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = 6820f, + LiquidId = 0, + DiffusionCoefficient = 0.1f + }); + using var simulation = new AtmosSimulation(config, size, size, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, 0, 100f, 293.15f); + + var final = RunUntilSleeping(simulation, chunk, 1000); + float pressureSpread = final.TotalPressure.Max() - final.TotalPressure.Min(); + float temperatureSpread = final.Temperature.Max() - final.Temperature.Min(); + + Assert.Multiple(() => + { + Assert.That(final.IsAwake, Is.False); + Assert.That(final.SleepTimer, Is.GreaterThan(config.SleepThreshold)); + Assert.That(simulation.TickCount, Is.LessThanOrEqualTo(1000)); + Assert.That(pressureSpread, + Is.LessThanOrEqualTo(8f * Ulp(final.TotalPressure.Max()))); + Assert.That(temperatureSpread, + Is.LessThanOrEqualTo(8f * Ulp(final.Temperature.Max()))); + Assert.That(final.TotalPressure, Is.All.GreaterThan(0f)); + Assert.That(ReadMoles(final, 0), Is.All.GreaterThan(0f)); + Assert.That(SpeciesTotal(final, 0), Is.InRange(99d, 100d)); + Assert.That(SimTestHelpers.TotalThermalEnergyPrecise(config, final), + Is.GreaterThan(0d)); + }); + } + + private static AtmosConfig CreateForcedSnappingConfig() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.VoxelSnappingEnabled = true; + config.VoxelSnapTemperatureEpsilon = float.MaxValue; + config.VoxelSnapMoleFractionEpsilon = 1f; + config.SleepEpsilon = float.MaxValue; + config.SleepThreshold = 0; + config.BulkFlowCoefficient = 0f; + config.MaxPressureTransferFractionPerNeighbor = 0f; + config.ThermalConductance = 0f; + return config; + } + + private static AtmosChunkSnapshot RunUntilSleeping(AtmosSimulation simulation, + AtmosChunkHandle chunk, int maximumTicks = 128) + { + for (var tick = 0; tick < maximumTicks; tick++) + { + simulation.Tick(); + var snapshot = simulation.GetChunkSnapshot(chunk); + if (!snapshot.IsAwake) + return snapshot; + } + + throw new AssertionException($"Chunk remained awake after {maximumTicks} ticks."); + } + + private static ConservationRun RunArbitraryConservationScenario( + bool reverseGasInjectionOrder = false) + { + float[] firstMoles = [1.125f, 0.25f, 2.375f, 0.5f, 1.75f, 0.125f, 0.875f]; + float[] secondMoles = [0.375f, 1.625f, 0.125f, 2.25f, 0.5f, 0.75f, 0.875f]; + float[] temperatures = [240.25f, 310.5f, 405.75f, 275.125f, 350.875f, 190.5f, 500.25f]; + var config = CreateForcedSnappingConfig(); + SetHeatCapacity(config, SimTestHelpers.FirstGasId, 1.25f); + SetHeatCapacity(config, SimTestHelpers.SecondGasId, 3.75f); + using var simulation = new AtmosSimulation(config, firstMoles.Length, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + for (var x = 0; x < firstMoles.Length; x++) + { + if (reverseGasInjectionOrder) + { + simulation.AddGasToVoxel(chunk, x, 0, 0, + SimTestHelpers.SecondGasId, secondMoles[x], temperatures[x]); + simulation.AddGasToVoxel(chunk, x, 0, 0, + SimTestHelpers.FirstGasId, firstMoles[x], temperatures[x]); + } + else + { + simulation.AddGasToVoxel(chunk, x, 0, 0, + SimTestHelpers.FirstGasId, firstMoles[x], temperatures[x]); + simulation.AddGasToVoxel(chunk, x, 0, 0, + SimTestHelpers.SecondGasId, secondMoles[x], temperatures[x]); + } + } + + var before = simulation.GetChunkSnapshot(chunk); + double[] initialSpeciesTotals = + [ + SpeciesTotal(before, SimTestHelpers.FirstGasId), + SpeciesTotal(before, SimTestHelpers.SecondGasId) + ]; + double initialEnergy = SimTestHelpers.TotalThermalEnergyPrecise(config, before); + var after = RunUntilSleeping(simulation, chunk); + return new ConservationRun(config, initialSpeciesTotals, initialEnergy, after); + } + + private static void SetHeatCapacity(AtmosConfig config, int gasId, float heatCapacity) + { + GasProperties gas = config.GasRegistry[gasId]; + gas.MolarHeatCapacityAtConstantVolume = heatCapacity; + config.GasRegistry[gasId] = gas; + } + + private static float[] ReadMoles(AtmosChunkSnapshot snapshot, int gasId) + { + foreach (var gas in snapshot.Gases) + { + if (gas.GasId == gasId) + return gas.Moles; + } + + return new float[snapshot.Temperature.Length]; + } + + private static int[] BitsAt(float[] values, params int[] indices) + { + return indices.Select(index => BitConverter.SingleToInt32Bits(values[index])).ToArray(); + } + + private static double SpeciesTotal(AtmosChunkSnapshot snapshot, int gasId, + params int[]? selectedIndices) + { + float[] moles = ReadMoles(snapshot, gasId); + if (selectedIndices is not { Length: > 0 }) + return moles.Aggregate(0d, static (total, value) => total + value); + + double selectedTotal = 0d; + foreach (int index in selectedIndices) + selectedTotal += moles[index]; + return selectedTotal; + } + + private static double FloatSumTolerance(double total, int valueCount) + { + float mean = (float)(Math.Abs(total) / Math.Max(1, valueCount)); + return 2d * Math.Max(Ulp((float)Math.Abs(total)), valueCount * (double)Ulp(mean)); + } + + private static double FloatEnergyTolerance(double totalEnergy, int voxelCount) + { + float meanEnergy = (float)(Math.Abs(totalEnergy) / Math.Max(1, voxelCount)); + return 8d * Math.Max(Ulp((float)Math.Abs(totalEnergy)), voxelCount * (double)Ulp(meanEnergy)); + } + + private static float Ulp(float value) + { + if (!float.IsFinite(value)) + return float.PositiveInfinity; + + float next = MathF.BitIncrement(value); + return MathF.Abs(next - value); + } + + private sealed record ConservationRun( + AtmosConfig Config, + double[] InitialSpeciesTotals, + double InitialEnergy, + AtmosChunkSnapshot After); +} diff --git a/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs b/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs index f880c20..168e62d 100644 --- a/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs +++ b/tests/Numos.CoreSim.IntegrationTests/SimTestHelpers.cs @@ -30,6 +30,8 @@ internal static AtmosConfig CreateDeterministicConfig() VacuumThreshold = 0f, SleepThreshold = int.MaxValue, SleepEpsilon = 0f, + // Progressive projection has dedicated fixtures; ordinary solver tests isolate it here. + VoxelSnappingEnabled = false, ThermalConductance = 0.05f, CondensationRateFactor = 0.5f, MaxPressureTransferFractionPerNeighbor = 0.16f, diff --git a/tests/Numos.CoreSim.IntegrationTests/SimulationLifecycleIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/SimulationLifecycleIntegrationTests.cs index 1f211bc..58bff04 100644 --- a/tests/Numos.CoreSim.IntegrationTests/SimulationLifecycleIntegrationTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/SimulationLifecycleIntegrationTests.cs @@ -13,9 +13,9 @@ public void StableChunk_SleepsOnlyAfterThresholdPlusOneTicks() { Assert.Multiple(() => { - Assert.That(FlowsAfterStableTicks(2), Is.True, + Assert.That(IsAwakeAfterStableTicks(2), Is.True, "The chunk must remain awake when SleepTimer equals SleepThreshold."); - Assert.That(FlowsAfterStableTicks(3), Is.False, + Assert.That(IsAwakeAfterStableTicks(3), Is.False, "The chunk must sleep when SleepTimer becomes greater than SleepThreshold."); }); } @@ -98,6 +98,7 @@ public void ClosedLShapedRoom_ConvergesToSleepAndCanBeWokenAgain() var converged = simulation.GetChunkSnapshot(chunk); float[] convergedMoles = ReadOpenMoles(converged, openVoxels, width, height); + simulation.SleepChunk(chunk); simulation.SetVoxelTemperature(chunk, 0, 0, 0, 600f); simulation.Tick(); var stillSleeping = simulation.GetChunkSnapshot(chunk); @@ -117,7 +118,7 @@ public void ClosedLShapedRoom_ConvergesToSleepAndCanBeWokenAgain() }); } - private static bool FlowsAfterStableTicks(int stableTicks) + private static bool IsAwakeAfterStableTicks(int stableTicks) { var config = SimTestHelpers.CreateDeterministicConfig(); config.SleepThreshold = 2; @@ -131,10 +132,7 @@ private static bool FlowsAfterStableTicks(int stableTicks) for (var i = 0; i < stableTicks; i++) simulation.Tick(); - simulation.SetVoxelTemperature(chunk, 0, 0, 0, 600f); - simulation.Tick(); - var snapshot = simulation.GetChunkSnapshot(chunk); - return SimTestHelpers.Moles(snapshot, SimTestHelpers.FirstGasId, 1) > 1f; + return simulation.GetChunkSnapshot(chunk).IsAwake; } private static float[] ReadOpenMoles(AtmosChunkSnapshot snapshot, (int X, int Y)[] openVoxels, @@ -145,4 +143,4 @@ private static float[] ReadOpenMoles(AtmosChunkSnapshot snapshot, (int X, int Y) SimTestHelpers.Index(voxel.X, voxel.Y, 0, width, height))) .ToArray(); } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/SimulationStabilityTests.cs b/tests/Numos.CoreSim.IntegrationTests/SimulationStabilityTests.cs index deebf42..a124e6c 100644 --- a/tests/Numos.CoreSim.IntegrationTests/SimulationStabilityTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/SimulationStabilityTests.cs @@ -20,6 +20,7 @@ public void StableChunk_SleepsAtConfiguredThresholdUntilExplicitlyWoken() simulation.AddGasToVoxel(chunk, 1, 0, 0, SimTestHelpers.FirstGasId, 1f, 300f); simulation.Tick(); + simulation.SleepChunk(chunk); simulation.SetVoxelTemperature(chunk, 0, 0, 0, 600f); simulation.Tick(); var whileSleeping = simulation.GetChunkSnapshot(chunk); @@ -110,4 +111,4 @@ private static void AssertGeometryConverges(int width, int height, (int X, int Y Is.EqualTo(openVoxels.Length).Within(0.002f)); }); } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs index 0708820..4828672 100644 --- a/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/ThermodynamicsIntegrationTests.cs @@ -844,6 +844,48 @@ public void Condensation_LargeHeatCapacityAvoidsEnergyCancellationOverflow() }); } + [Test] + public void Condensation_UnrepresentableLatentHeatingIsDeferredAtomically() + { + var config = SimTestHelpers.CreateDeterministicConfig(); + config.VoxelVolume = float.MaxValue; + config.CondensationRateFactor = 1f; + config.VoxelSnappingEnabled = true; + config.SleepThreshold = 0; + config.GasRegistry = + [ + new GasProperties + { + Name = "Extreme condensable", + MolarHeatCapacityAtConstantVolume = float.Epsilon, + BoilingPoint = float.MaxValue, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = float.MaxValue, + LiquidId = 1, + DiffusionCoefficient = 0f + } + ]; + using var simulation = new AtmosSimulation(config, 1, 1, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 0, 0, 0, SimTestHelpers.FirstGasId, 1e10f, 1f); + var before = simulation.GetChunkSnapshot(chunk); + + for (var tick = 0; tick < 6; tick++) + simulation.Tick(); + + var after = simulation.GetChunkSnapshot(chunk); + Assert.Multiple(() => + { + Assert.That(SimTestHelpers.Moles(after, SimTestHelpers.FirstGasId, 0), + Is.EqualTo(SimTestHelpers.Moles(before, SimTestHelpers.FirstGasId, 0))); + Assert.That(after.Temperature[0], Is.EqualTo(before.Temperature[0])); + Assert.That(float.IsFinite(after.Temperature[0]), Is.True); + Assert.That(float.IsFinite(after.TotalPressure[0]), Is.True); + Assert.That(after.IsAwake, Is.True, + "Deferred phase work must keep the chunk eligible for a later representable retry."); + }); + } + [Test] public void Condensation_InfiniteSaturationPressureDoesNotCondense() { diff --git a/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs b/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs index e53a84b..b733b62 100644 --- a/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosChunkInjectionTests.cs @@ -140,6 +140,29 @@ public void InjectGasToVoxel_GrowsGasChannelTableWhenInitialCapacityIsExceeded() }); } + [Test] + public void InjectGasToVoxel_UnrepresentableCombinedStateIsRejectedBeforeMutation() + { + var chunk = CreateAwakeChunk(1); + chunk.InjectGasToVoxel(0, 3, float.MaxValue, 1f, float.Epsilon, float.Epsilon); + chunk.SleepTimer = 9; + var version = chunk.Version; + + Assert.That(() => chunk.InjectGasToVoxel( + 0, 3, float.MaxValue, 1f, float.Epsilon, float.Epsilon), + Throws.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(chunk.ActiveGasCount, Is.EqualTo(1)); + Assert.That(chunk.ActiveGases[0].Moles[0], Is.EqualTo(float.MaxValue)); + Assert.That(float.IsFinite(chunk.TotalPressure[0]), Is.True); + Assert.That(float.IsFinite(chunk.TotalHeatCapacity[0]), Is.True); + Assert.That(chunk.SleepTimer, Is.EqualTo(9)); + Assert.That(chunk.Version, Is.EqualTo(version)); + }); + } + private AtmosChunk CreateChunk(int width, int height, int depth) { var chunk = new AtmosChunk(width, height, depth); @@ -154,4 +177,4 @@ private AtmosChunk CreateAwakeChunk(int width) chunk.WakeRoom(7); return chunk; } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs b/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs index 05d503a..3527df1 100644 --- a/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosChunkTopologyTests.cs @@ -175,7 +175,7 @@ public void WakeRoom_ActivatesUnassignedVoxels() } [Test] - public void WakeRoom_BuildsAscendingUnionOfAllActiveRooms() + public void WakeRoom_ClosesAcrossAdjacentPassableRoomLabels() { var chunk = new AtmosChunk(6, 1, 1); int[] roomIds = [1, 2, 3, 1, 2, VoxelClassification.RoomSolid]; @@ -186,10 +186,12 @@ public void WakeRoom_BuildsAscendingUnionOfAllActiveRooms() Assert.Multiple(() => { - Assert.That(chunk.ActiveRoomCount, Is.EqualTo(2)); + Assert.That(chunk.ActiveRoomCount, Is.EqualTo(2), + "Explicit WakeRoom calls retain their requested seed IDs."); Assert.That(chunk.ActiveRoomIds.Take(chunk.ActiveRoomCount), Is.EqualTo(new[] { 1, 2 })); - Assert.That(chunk.ActiveAirCount, Is.EqualTo(4)); - Assert.That(chunk.ActiveAirIndices.Take(chunk.ActiveAirCount), Is.EqualTo(new ushort[] { 0, 1, 3, 4 })); + Assert.That(chunk.ActiveAirCount, Is.EqualTo(5)); + Assert.That(chunk.ActiveAirIndices.Take(chunk.ActiveAirCount), + Is.EqualTo(new ushort[] { 0, 1, 2, 3, 4 })); }); } @@ -230,23 +232,27 @@ public void WakeRoom_AfterSleep_ReplacesPreviouslyActiveRooms() Assert.That(chunk.IsAwake, Is.True); Assert.That(chunk.ActiveRoomCount, Is.EqualTo(1)); Assert.That(chunk.ActiveRoomIds[0], Is.EqualTo(3)); - Assert.That(chunk.ActiveAirCount, Is.EqualTo(1)); - Assert.That(chunk.ActiveAirIndices[0], Is.EqualTo(2)); + Assert.That(chunk.ActiveAirCount, Is.EqualTo(3)); + Assert.That(chunk.ActiveAirIndices.Take(chunk.ActiveAirCount), + Is.EqualTo(new ushort[] { 0, 1, 2 })); }); } [Test] public void WakeRoom_ThrowsBeforeExceedingRoomCapacity() { - var chunk = new AtmosChunk(3, 1, 1, 2); + var chunk = new AtmosChunk(5, 1, 1, 2); chunk.VoxelRoomMap[0] = 1; - chunk.VoxelRoomMap[1] = 2; - chunk.VoxelRoomMap[2] = 3; + chunk.VoxelRoomMap[1] = VoxelClassification.RoomSolid; + chunk.VoxelRoomMap[2] = 2; + chunk.VoxelRoomMap[3] = VoxelClassification.RoomSolid; + chunk.VoxelRoomMap[4] = 3; chunk.WakeRoom(1); chunk.WakeRoom(2); Assert.That(() => chunk.WakeRoom(3), - Throws.Exception.With.Message.EqualTo("Maximum active rooms reached for this chunk!")); + Throws.TypeOf() + .With.Message.EqualTo("Maximum active rooms reached for this chunk.")); Assert.That(chunk.ActiveRoomCount, Is.EqualTo(2)); } @@ -263,8 +269,9 @@ public void RebuildActiveAirIndices_ReflectsTopologyChangesWithoutDuplicates() Assert.Multiple(() => { - Assert.That(chunk.ActiveAirCount, Is.EqualTo(3)); - Assert.That(chunk.ActiveAirIndices.Take(chunk.ActiveAirCount), Is.EqualTo(new ushort[] { 0, 2, 4 })); + Assert.That(chunk.ActiveAirCount, Is.EqualTo(4)); + Assert.That(chunk.ActiveAirIndices.Take(chunk.ActiveAirCount), + Is.EqualTo(new ushort[] { 0, 2, 3, 4 })); }); } @@ -284,4 +291,4 @@ public void Sleep_MarksChunkAsNotAwakeWithoutDiscardingTopology() }); } -} \ No newline at end of file +} diff --git a/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs b/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs index 942697c..c99ebe6 100644 --- a/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs @@ -31,6 +31,14 @@ public void Constructor_UsesDocumentedSimulationDefaults() Assert.That(config.VacuumThreshold, Is.EqualTo(AtmosConfigDefaults.VacuumThreshold)); Assert.That(config.SleepThreshold, Is.EqualTo(AtmosConfigDefaults.SleepThreshold)); Assert.That(config.SleepEpsilon, Is.EqualTo(AtmosConfigDefaults.SleepEpsilon)); + Assert.That(config.VoxelSnappingEnabled, + Is.EqualTo(AtmosConfigDefaults.VoxelSnappingEnabled)); + Assert.That(config.VoxelSnapPressureRelativeEpsilon, + Is.EqualTo(AtmosConfigDefaults.VoxelSnapPressureRelativeEpsilon)); + Assert.That(config.VoxelSnapTemperatureEpsilon, + Is.EqualTo(AtmosConfigDefaults.VoxelSnapTemperatureEpsilon)); + Assert.That(config.VoxelSnapMoleFractionEpsilon, + Is.EqualTo(AtmosConfigDefaults.VoxelSnapMoleFractionEpsilon)); Assert.That(config.ThermalConductance, Is.EqualTo(AtmosConfigDefaults.ThermalConductance)); Assert.That(config.CondensationRateFactor, Is.EqualTo(AtmosConfigDefaults.CondensationRateFactor)); @@ -43,6 +51,32 @@ public void Constructor_UsesDocumentedSimulationDefaults() }); } + [Test] + public void Constructor_UsesProgressiveVoxelSnappingDefaults() + { + var config = new AtmosConfig(); + + Assert.Multiple(() => + { + Assert.That(config.VoxelSnappingEnabled, Is.True); + Assert.That(config.SleepEpsilon, Is.EqualTo(0.5f)); + Assert.That(config.VoxelSnapPressureRelativeEpsilon, Is.EqualTo(0.001f)); + Assert.That(config.VoxelSnapTemperatureEpsilon, Is.EqualTo(0.01f)); + Assert.That(config.VoxelSnapMoleFractionEpsilon, Is.EqualTo(0.001f)); + }); + } + + [Test] + public void Defaults_LegacyQuietPressureCannotRequestBulkTransfer() + { + var config = new AtmosConfig(); + + Assert.That(config.SleepEpsilon * config.MaxPressureTransferFractionPerNeighbor, + Is.LessThan(config.MinimumPressureTransfer), + "At the default low-delta rate, a pressure difference considered quiet must remain below the " + + "minimum actionable bulk transfer."); + } + [Test] public void Constructor_CreatesIndependentGasRegistryForEachConfig() { @@ -61,4 +95,4 @@ public void Constructor_CreatesIndependentGasRegistryForEachConfig() Assert.That(second.GasRegistry, Is.Not.SameAs(first.GasRegistry)); }); } -} \ No newline at end of file +} diff --git a/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs b/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs new file mode 100644 index 0000000..5a6d21d --- /dev/null +++ b/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs @@ -0,0 +1,592 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; + +namespace Numos.Headless.Tests; + +[TestFixture] +public sealed class HeadlessApplicationTests +{ + private const float Tolerance = 0.0001f; + + [Test] + public async Task JsonlSession_DeterministicTwoCellFlow_ReportsExpectedGasMovement() + { + var run = await RunAsync( + CreateSimulationRequest("create", 2, 1, 1), + Request("chunk", "addChunk", + "\"position\":{\"x\":0,\"y\":0,\"z\":0},\"classification\":1"), + SetTemperatureRequest("temperature-0", 0, 300f), + SetTemperatureRequest("temperature-1", 1, 300f), + Request("inject", "injectGas", + "\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"voxel\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"gasId\":0,\"moles\":2,\"temperatureK\":300"), + Request("tick", "tick", "\"count\":1"), + Request("observe", "observe", "\"includeVoxels\":true"), + Request("exit", "exit")); + + JsonElement observationResponse = FindResponse(run.Responses, "observe"); + JsonElement observation = observationResponse.GetProperty("observation"); + JsonElement voxels = FindArraysNamed(observation, "voxels").Single(); + float[] gasMoles = voxels.EnumerateArray() + .Select(ReadGasZeroMoles) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(run.ExitCode, Is.Zero); + Assert.That(observationResponse.GetProperty("ok").GetBoolean(), Is.True); + Assert.That(observationResponse.GetProperty("state").GetProperty("tick").GetInt32(), + Is.EqualTo(1)); + Assert.That(gasMoles, Has.Length.EqualTo(2)); + Assert.That(gasMoles[0], Is.EqualTo(1.75f).Within(Tolerance)); + Assert.That(gasMoles[1], Is.EqualTo(0.25f).Within(Tolerance)); + Assert.That(gasMoles.Sum(), Is.EqualTo(2f).Within(Tolerance)); + }); + } + + [Test] + public async Task UpdateConfig_VoxelSnappingFields_RoundTripThroughObservation() + { + var run = await RunAsync( + CreateSimulationRequest("create", 1, 1, 1), + Request("update", "updateConfig", + "\"config\":{" + + "\"sleepEpsilonPa\":0.75," + + "\"voxelSnapPressureRelativeEpsilon\":0.375," + + "\"voxelSnappingEnabled\":true," + + "\"voxelSnapTemperatureEpsilonK\":0.125," + + "\"voxelSnapMoleFractionEpsilon\":0.25}"), + Request("observe", "observe"), + Request("exit", "exit")); + + JsonElement updateResponse = FindResponse(run.Responses, "update"); + JsonElement config = FindResponse(run.Responses, "observe") + .GetProperty("observation") + .GetProperty("config"); + + Assert.Multiple(() => + { + Assert.That(run.ExitCode, Is.Zero); + Assert.That(updateResponse.GetProperty("ok").GetBoolean(), Is.True); + Assert.That(config.GetProperty("sleepEpsilonPa").GetSingle(), Is.EqualTo(0.75f)); + Assert.That(config.GetProperty("voxelSnapPressureRelativeEpsilon").GetSingle(), + Is.EqualTo(0.375f)); + Assert.That(config.GetProperty("voxelSnappingEnabled").GetBoolean(), Is.True); + Assert.That(config.GetProperty("voxelSnapTemperatureEpsilonK").GetSingle(), Is.EqualTo(0.125f)); + Assert.That(config.GetProperty("voxelSnapMoleFractionEpsilon").GetSingle(), Is.EqualTo(0.25f)); + }); + } + + [Test] + public async Task JsonlSession_MalformedJson_ReturnsStructuredErrorAndProcessesNextLine() + { + var run = await RunAsync( + "{\"protocolVersion\":1,\"id\":\"broken\",\"op\":", + CreateSimulationRequest("recovered", 1, 1, 1), + Request("exit", "exit")); + + JsonElement errorResponse = run.Responses.Single(response => + response.TryGetProperty("ok", out var ok) && !ok.GetBoolean()); + JsonElement error = errorResponse.GetProperty("error"); + JsonElement recovered = FindResponse(run.Responses, "recovered"); + + Assert.Multiple(() => + { + Assert.That(run.ExitCode, Is.EqualTo(1)); + Assert.That(error.GetProperty("code").GetString(), Is.EqualTo("invalidJson")); + Assert.That(error.GetProperty("message").GetString(), Is.Not.Null.And.Not.Empty); + Assert.That(error.GetProperty("line").GetInt32(), Is.EqualTo(1)); + Assert.That(recovered.GetProperty("ok").GetBoolean(), Is.True); + Assert.That(recovered.GetProperty("state").GetProperty("chunkCount").GetInt32(), Is.Zero); + Assert.That(run.Responses.All(IsValidProtocolResponse), Is.True); + Assert.That(run.StandardError, Is.Empty); + }); + } + + [Test] + public async Task JsonlSession_SchemaErrors_ReturnCorrelatedRequestErrors() + { + var run = await RunAsync( + Request("typo", "createSimulation", + "\"dimensions\":{\"x\":1,\"y\":1,\"z\":1},\"widht\":1"), + "{\"id\":\"no-version\",\"op\":\"observe\"}", + Request("exit", "exit")); + + JsonElement response = FindResponse(run.Responses, "typo"); + JsonElement missingVersion = FindResponse(run.Responses, "no-version"); + + Assert.Multiple(() => + { + Assert.That(run.ExitCode, Is.EqualTo(1)); + Assert.That(response.GetProperty("op").GetString(), Is.EqualTo("createSimulation")); + Assert.That(response.GetProperty("ok").GetBoolean(), Is.False); + Assert.That(response.GetProperty("error").GetProperty("code").GetString(), + Is.EqualTo("invalidRequest")); + Assert.That(missingVersion.GetProperty("op").GetString(), Is.EqualTo("observe")); + Assert.That(missingVersion.GetProperty("error").GetProperty("code").GetString(), + Is.EqualTo("missingProperty")); + }); + } + + [Test] + public async Task JsonlSession_FieldFromAnotherOperationIsRejectedWithoutMutation() + { + var run = await RunAsync( + CreateSimulationRequest("create", 1, 1, 1), + Request("wrong-field", "addChunk", + "\"position\":{\"x\":0,\"y\":0,\"z\":0},\"roomId\":7"), + Request("observe", "observe"), + Request("exit", "exit")); + + JsonElement rejected = FindResponse(run.Responses, "wrong-field"); + JsonElement observation = FindResponse(run.Responses, "observe"); + Assert.Multiple(() => + { + Assert.That(run.ExitCode, Is.EqualTo(1)); + Assert.That(rejected.GetProperty("ok").GetBoolean(), Is.False); + Assert.That(rejected.GetProperty("error").GetProperty("code").GetString(), + Is.EqualTo("invalidRequest")); + Assert.That(observation.GetProperty("state").GetProperty("chunkCount").GetInt32(), Is.Zero); + }); + } + + [Test] + public async Task AddSolidChunk_DoesNotTransientlyWakeSleepingAdjacentSource() + { + var run = await RunAsync( + CreateSimulationRequest("create", 1, 1, 1), + AddChunkRequest("source", 0, 0, 0), + Request("inject", "injectGas", + "\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"voxel\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"gasId\":0,\"moles\":1,\"temperatureK\":300"), + Request("sleep", "sleepChunk", "\"position\":{\"x\":0,\"y\":0,\"z\":0}"), + Request("solid", "addChunk", + "\"position\":{\"x\":1,\"y\":0,\"z\":0},\"classification\":-2"), + Request("observe", "observe", "\"position\":{\"x\":0,\"y\":0,\"z\":0}"), + Request("exit", "exit")); + + JsonElement source = FindResponse(run.Responses, "observe") + .GetProperty("observation") + .GetProperty("chunks") + .EnumerateArray() + .Single(); + Assert.Multiple(() => + { + Assert.That(FindResponse(run.Responses, "solid").GetProperty("ok").GetBoolean(), Is.True); + Assert.That(source.GetProperty("isAwake").GetBoolean(), Is.False); + Assert.That(source.GetProperty("summary").GetProperty("totalMoles").GetDouble(), + Is.EqualTo(1d).Within(0.000001d)); + }); + } + + [Test] + public async Task JsonlSession_IncompleteCoordinate_IsRejectedWithoutMutatingState() + { + var run = await RunAsync( + CreateSimulationRequest("create", 1, 1, 1), + Request("incomplete", "addChunk", "\"position\":{\"x\":7},\"classification\":1"), + Request("observe", "observe"), + Request("exit", "exit")); + + JsonElement rejected = FindResponse(run.Responses, "incomplete"); + JsonElement observation = FindResponse(run.Responses, "observe"); + + Assert.Multiple(() => + { + Assert.That(rejected.GetProperty("ok").GetBoolean(), Is.False); + Assert.That(rejected.GetProperty("error").GetProperty("code").GetString(), + Is.EqualTo("invalidRequest")); + Assert.That(observation.GetProperty("state").GetProperty("chunkCount").GetInt32(), Is.Zero); + Assert.That(observation.GetProperty("observation").GetProperty("chunks").GetArrayLength(), Is.Zero); + }); + } + + [Test] + public async Task InjectGas_UnregisteredGasId_IsRejected() + { + var run = await RunAsync( + CreateSimulationRequest("create", 1, 1, 1), + AddChunkRequest("chunk", 0, 0, 0), + Request("inject", "injectGas", + "\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"voxel\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"gasId\":999,\"moles\":1,\"temperatureK\":300"), + Request("exit", "exit")); + + JsonElement response = FindResponse(run.Responses, "inject"); + + Assert.Multiple(() => + { + Assert.That(run.ExitCode, Is.EqualTo(1)); + Assert.That(response.GetProperty("ok").GetBoolean(), Is.False); + Assert.That(response.GetProperty("error").GetProperty("code").GetString(), + Is.EqualTo("gasNotFound")); + Assert.That(response.GetProperty("state").GetProperty("gasCount").GetInt32(), Is.EqualTo(1)); + }); + } + + [Test] + public async Task Program_StdinProtocol_WritesParseableResponsesAndExitsCleanly() + { + string applicationPath = Path.Combine(AppContext.BaseDirectory, "Numos.Headless.dll"); + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add(applicationPath); + + using Process process = Process.Start(startInfo) ?? + throw new InvalidOperationException("Could not start the Headless process."); + Task standardOutput = process.StandardOutput.ReadToEndAsync(); + Task standardError = process.StandardError.ReadToEndAsync(); + await process.StandardInput.WriteLineAsync(CreateSimulationRequest("process-create", 1, 1, 1)); + await process.StandardInput.WriteLineAsync(Request("process-exit", "exit")); + process.StandardInput.Close(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await process.WaitForExitAsync(timeout.Token); + string output = await standardOutput; + string error = await standardError; + JsonElement[] responses = output + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(ParseJson) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(process.ExitCode, Is.Zero); + Assert.That(error, Is.Empty); + Assert.That(responses, Has.Length.EqualTo(2)); + Assert.That(FindResponse(responses, "process-create").GetProperty("ok").GetBoolean(), Is.True); + Assert.That(FindResponse(responses, "process-exit").GetProperty("ok").GetBoolean(), Is.True); + }); + } + + [Test] + public async Task Application_InvalidScriptPath_ReturnsStructuredSetupError() + { + using var input = new StringReader(string.Empty); + using var output = new StringWriter(CultureInfo.InvariantCulture); + using var error = new StringWriter(CultureInfo.InvariantCulture); + + int exitCode = await HeadlessApplication.RunAsync( + ["\0"], + input, + output, + error, + CancellationToken.None); + JsonElement response = ParseJson(output.ToString().Trim()); + + Assert.Multiple(() => + { + Assert.That(exitCode, Is.EqualTo(2)); + Assert.That(error.ToString(), Is.Empty); + Assert.That(response.GetProperty("ok").GetBoolean(), Is.False); + Assert.That(response.GetProperty("error").GetProperty("code").GetString(), + Is.EqualTo("scriptUnavailable")); + }); + } + + [Test] + public async Task Observe_NonFiniteTemperature_EmitsValidJsonWithNamedValue() + { + var run = await RunAsync( + CreateSimulationRequest("create", 1, 1, 1), + Request("chunk", "addChunk", + "\"position\":{\"x\":0,\"y\":0,\"z\":0},\"classification\":1"), + Request("temperature", "setVoxelTemperature", + "\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"voxel\":{\"x\":0,\"y\":0,\"z\":0},\"temperatureK\":\"NaN\""), + Request("observe", "observe", "\"includeVoxels\":true"), + Request("exit", "exit")); + + JsonElement observationResponse = FindResponse(run.Responses, "observe"); + JsonElement observation = observationResponse.GetProperty("observation"); + JsonElement voxel = observation.GetProperty("chunks")[0].GetProperty("voxels")[0]; + JsonElement[] namedNonfiniteValues = DescendantsAndSelf(observation) + .Where(element => element.ValueKind == JsonValueKind.String && element.GetString() == "NaN") + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(FindResponse(run.Responses, "temperature").GetProperty("ok").GetBoolean(), Is.True); + Assert.That(observationResponse.GetProperty("ok").GetBoolean(), Is.True); + Assert.That(voxel.GetProperty("temperatureK").GetString(), Is.EqualTo("NaN")); + Assert.That(observation.GetProperty("global").GetProperty("anomalies") + .GetProperty("nonFiniteTemperatureCount").GetInt32(), Is.EqualTo(1)); + Assert.That(namedNonfiniteValues, Is.Not.Empty, + "The diagnostic response must remain serializable when a stored simulation value is NaN."); + Assert.That(run.OutputLines.All(IsValidJson), Is.True); + }); + } + + [Test] + public async Task Observe_ChunksCreatedOutOfOrder_ReturnsStableCoordinateOrder() + { + var run = await RunAsync( + CreateSimulationRequest("create", 1, 1, 1), + AddChunkRequest("chunk-2", 2, 0, 0), + AddChunkRequest("chunk-negative-later", -1, 4, 0), + AddChunkRequest("chunk-negative-first", -1, 3, 5), + Request("observe", "observe", "\"includeVoxels\":false"), + Request("exit", "exit")); + + JsonElement observation = FindResponse(run.Responses, "observe").GetProperty("observation"); + JsonElement chunks = FindArraysNamed(observation, "chunks").Single(); + (int X, int Y, int Z)[] positions = chunks.EnumerateArray() + .Select(chunk => ReadCoordinate(chunk.GetProperty("position"))) + .ToArray(); + + Assert.That(positions, Is.EqualTo(new[] + { + (-1, 3, 5), + (-1, 4, 0), + (2, 0, 0) + })); + } + + [Test] + public async Task CheckedInEquilibriumScript_Sample900IsSleepingAndNearUniform() + { + string scriptPath = FindRepositoryFile("examples", "headless", "16x16-equilibrium.jsonl"); + using var input = new StringReader(string.Empty); + using var output = new StringWriter(CultureInfo.InvariantCulture); + using var error = new StringWriter(CultureInfo.InvariantCulture); + + int exitCode = await HeadlessApplication.RunAsync( + [scriptPath], input, output, error, CancellationToken.None); + JsonElement[] responses = output.ToString() + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(ParseJson) + .ToArray(); + JsonElement response = FindResponse(responses, "sample-900"); + JsonElement observation = response.GetProperty("observation"); + JsonElement global = observation.GetProperty("global"); + JsonElement chunk = observation.GetProperty("chunks").EnumerateArray().Single(); + JsonElement pressure = global.GetProperty("pressurePa"); + JsonElement temperature = global.GetProperty("temperatureK"); + float pressureSpread = pressure.GetProperty("maximum").GetSingle() - + pressure.GetProperty("minimum").GetSingle(); + float temperatureSpread = temperature.GetProperty("maximum").GetSingle() - + temperature.GetProperty("minimum").GetSingle(); + + Assert.Multiple(() => + { + Assert.That(exitCode, Is.Zero); + Assert.That(error.ToString(), Is.Empty); + Assert.That(response.GetProperty("ok").GetBoolean(), Is.True); + Assert.That(response.GetProperty("state").GetProperty("tick").GetInt32(), + Is.EqualTo(900)); + Assert.That(global.GetProperty("awakeChunkCount").GetInt32(), Is.Zero); + Assert.That(global.GetProperty("sleepingChunkCount").GetInt32(), Is.EqualTo(1)); + Assert.That(chunk.GetProperty("isAwake").GetBoolean(), Is.False); + Assert.That(chunk.GetProperty("sleepTimer").GetInt32(), Is.GreaterThan(100)); + Assert.That(global.GetProperty("gasBearingVoxelCount").GetInt32(), Is.EqualTo(256)); + Assert.That(pressureSpread, + Is.LessThanOrEqualTo(observation.GetProperty("config") + .GetProperty("sleepEpsilonPa").GetSingle())); + Assert.That(temperatureSpread, + Is.LessThanOrEqualTo(observation.GetProperty("config") + .GetProperty("voxelSnapTemperatureEpsilonK").GetSingle())); + Assert.That(global.GetProperty("totalMoles").GetDouble(), Is.InRange(99d, 100d)); + Assert.That(global.GetProperty("anomalies").GetProperty("totalCount").GetInt32(), Is.Zero); + }); + } + + private static string CreateSimulationRequest(string id, int width, int height, int depth) + { + return Request(id, "createSimulation", + $"\"name\":\"Headless tests\"," + + $"\"dimensions\":{{\"x\":{width},\"y\":{height},\"z\":{depth}}}," + + "\"config\":{" + + "\"defaultTemperatureFallbackK\":300," + + "\"defaultMolarHeatCapacityAtConstantVolume\":1," + + "\"voxelVolumeM3\":8.31446262," + + "\"saturationReferencePressurePa\":1000," + + "\"defaultDiffusionCoefficient\":0," + + "\"bulkFlowCoefficient\":0.25," + + "\"bulkFlowDamping\":0.5," + + "\"lowPressureDeltaThresholdPa\":5," + + "\"minimumPressureTransferPa\":0," + + "\"vacuumThresholdPa\":0," + + "\"sleepThreshold\":2147483647," + + "\"sleepEpsilonPa\":0," + + "\"voxelSnappingEnabled\":false," + + "\"voxelSnapTemperatureEpsilonK\":0.01," + + "\"voxelSnapMoleFractionEpsilon\":0.001," + + "\"thermalConductance\":0.05," + + "\"condensationRateFactor\":0.5," + + "\"maxPressureTransferFractionPerNeighbor\":0.16" + + "}," + + "\"gases\":[{" + + "\"name\":\"First\"," + + "\"molarHeatCapacityAtConstantVolume\":1," + + "\"diffusionCoefficient\":0" + + "}]"); + } + + private static string AddChunkRequest(string id, int x, int y, int z) + { + return Request(id, "addChunk", + $"\"position\":{{\"x\":{x},\"y\":{y},\"z\":{z}}},\"classification\":1"); + } + + private static string SetTemperatureRequest(string id, int x, float temperature) + { + return Request(id, "setVoxelTemperature", + "\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + $"\"voxel\":{{\"x\":{x},\"y\":0,\"z\":0}}," + + $"\"temperatureK\":{temperature.ToString(CultureInfo.InvariantCulture)}"); + } + + private static string Request(string id, string op, string? payload = null) + { + string suffix = string.IsNullOrEmpty(payload) ? string.Empty : $",{payload}"; + return $"{{\"protocolVersion\":1,\"id\":\"{id}\",\"op\":\"{op}\"{suffix}}}"; + } + + private static async Task RunAsync(params string[] requests) + { + string inputText = string.Join('\n', requests) + "\n"; + using var input = new StringReader(inputText); + using var output = new StringWriter(CultureInfo.InvariantCulture); + using var error = new StringWriter(CultureInfo.InvariantCulture); + + int exitCode = await HeadlessApplication.RunAsync( + [], + input, + output, + error, + CancellationToken.None); + + string[] lines = output.ToString() + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + JsonElement[] responses = lines.Select(ParseJson).ToArray(); + return new RunResult(exitCode, lines, responses, error.ToString()); + } + + private static JsonElement ParseJson(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static JsonElement FindResponse(IEnumerable responses, string id) + { + return responses.Single(response => + response.TryGetProperty("id", out var responseId) && responseId.GetString() == id); + } + + private static float ReadGasZeroMoles(JsonElement voxel) + { + if (!voxel.TryGetProperty("gases", out var gases)) + return 0f; + + foreach (var gas in gases.EnumerateArray()) + { + if (gas.GetProperty("gasId").GetInt32() == 0) + return gas.GetProperty("moles").GetSingle(); + } + + return 0f; + } + + private static (int X, int Y, int Z) ReadCoordinate(JsonElement coordinate) + { + return ( + coordinate.GetProperty("x").GetInt32(), + coordinate.GetProperty("y").GetInt32(), + coordinate.GetProperty("z").GetInt32()); + } + + private static IEnumerable FindArraysNamed(JsonElement root, string propertyName) + { + if (root.ValueKind == JsonValueKind.Object) + { + foreach (var property in root.EnumerateObject()) + { + if (property.NameEquals(propertyName) && property.Value.ValueKind == JsonValueKind.Array) + yield return property.Value; + + foreach (var match in FindArraysNamed(property.Value, propertyName)) + yield return match; + } + } + else if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray()) + foreach (var match in FindArraysNamed(item, propertyName)) + yield return match; + } + } + + private static IEnumerable DescendantsAndSelf(JsonElement root) + { + yield return root; + if (root.ValueKind == JsonValueKind.Object) + { + foreach (var property in root.EnumerateObject()) + foreach (var descendant in DescendantsAndSelf(property.Value)) + yield return descendant; + } + else if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray()) + foreach (var descendant in DescendantsAndSelf(item)) + yield return descendant; + } + } + + private static bool IsValidProtocolResponse(JsonElement response) + { + return response.ValueKind == JsonValueKind.Object && + response.TryGetProperty("protocolVersion", out var version) && + version.GetInt32() == 1 && + response.TryGetProperty("ok", out _); + } + + private static bool IsValidJson(string line) + { + try + { + using var document = JsonDocument.Parse(line); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } + + private static string FindRepositoryFile(params string[] relativeSegments) + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); + directory != null; + directory = directory.Parent) + { + if (!File.Exists(Path.Combine(directory.FullName, "Numos.slnx"))) + continue; + + string candidate = Path.Combine([directory.FullName, .. relativeSegments]); + if (File.Exists(candidate)) + return candidate; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{Path.Combine(relativeSegments)}'."); + } + + private sealed record RunResult( + int ExitCode, + string[] OutputLines, + JsonElement[] Responses, + string StandardError); +} diff --git a/tests/Numos.Headless.Tests/Numos.Headless.Tests.csproj b/tests/Numos.Headless.Tests/Numos.Headless.Tests.csproj new file mode 100644 index 0000000..fd323ed --- /dev/null +++ b/tests/Numos.Headless.Tests/Numos.Headless.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + true + Numos.Headless.Tests + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + From 25d2ed049a4d109991c33317e99f0db962b8c4bb Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:36:08 -0500 Subject: [PATCH 12/14] Adjust sleep and mole fraction thresholds for improved stability; update tests and documentation accordingly. --- docs/atmospherics_technical_documentation.md | 4 +- docs/headless_runner.md | 3 +- src/Numos.CoreSim/AtmosConfigDefaults.cs | 4 +- ...rogressiveVoxelSnappingIntegrationTests.cs | 67 +++++++++++++++++++ tests/Numos.CoreSim.Tests/AtmosConfigTests.cs | 3 +- .../HeadlessApplicationTests.cs | 3 +- 6 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 4695f3f..1128592 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -276,12 +276,12 @@ single definition in `VoxelClassification`. | `LowPressureDeltaThreshold` | 5.0 | Below this pressure delta (Pa), flow uses `MaxPressureTransferFractionPerNeighbor` directly instead of `BulkFlowCoefficient * BulkFlowDamping`. Invalid or negative values normalize to zero. | | `MinimumPressureTransfer` | 0.1 | Candidate pressure transfers below this magnitude (Pa/tick) are discarded ("stiction"). Invalid or negative values normalize to zero. | | `VacuumThreshold` | 1.0 | Below this pressure (Pa), voxel contents are zeroed out. Invalid or negative values normalize to zero. | -| `SleepThreshold` | 100 | Consecutive stable verification ticks required before a chunk automatically sleeps. Snap-assisted mode uses at least the built-in two-tick thermodynamics cadence; negative values normalize to zero. | +| `SleepThreshold` | 15 | Consecutive stable verification ticks required before a chunk automatically sleeps (one second at the intended 15 TPS atmospherics cadence). Snap-assisted mode uses at least the built-in two-tick thermodynamics cadence; negative values normalize to zero. | | `SleepEpsilon` | 0.5 | Absolute pressure tolerance (Pa). With voxel snapping enabled, this is the floor in the hybrid per-member pressure bound. With snapping disabled, it is the legacy maximum neighboring pressure delta considered at rest. Invalid or negative values normalize to zero. | | `VoxelSnapPressureRelativeEpsilon` | 0.001 | Relative pressure tolerance used by voxel snapping. For each proposed member, this fraction is multiplied by the greatest of its current pressure, the aggregate equilibrium pressure, and `VacuumThreshold`; the allowed pressure correction is the greater of that result and `SleepEpsilon`. Finite values normalize to [0, 1], and non-finite values normalize to zero. | | `VoxelSnappingEnabled` | `true` | Enables the progressive, conservative intra-chunk projection used before automatic sleep. Disabling it skips projection and retains pressure-only automatic sleep while advection is enabled. It does not disable `SleepChunk`. | | `VoxelSnapTemperatureEpsilon` | 0.01 | Maximum temperature correction (K) permitted for every member of a candidate voxel-snap aggregate. Invalid or negative values normalize to zero. | -| `VoxelSnapMoleFractionEpsilon` | 0.001 | Maximum per-species mole-fraction correction permitted for every member of a candidate voxel-snap aggregate. This value is dimensionless; finite values are clamped to [0, 1], and non-finite values normalize to zero. | +| `VoxelSnapMoleFractionEpsilon` | 0.005 | Maximum per-species mole-fraction correction permitted for every member of a candidate voxel-snap aggregate (the default permits a correction of at most 0.5 percentage points). This value is dimensionless; finite values are clamped to [0, 1], and non-finite values normalize to zero. | | `ThermalConductance` | 0.05 | Effective per-face conductance in J/K per thermodynamics tick. Multiplying it by a temperature difference produces a candidate energy transfer, which is bounded for explicit-solver stability. Invalid or nonpositive values disable thermal diffusion. | | `CondensationRateFactor` | 0.5 | Dimensionless fraction of the heat-coupled equilibrium condensation amount applied per thermodynamics tick. Finite values are clamped to [0, 1]; non-finite values disable condensation. | | `MaxPressureTransferFractionPerNeighbor` | 0.16 | Maximum fraction of a voxel's pressure requested as bulk flow to one neighbor per tick. Finite values are clamped to [0, 1]; non-finite values disable bulk flow. | diff --git a/docs/headless_runner.md b/docs/headless_runner.md index 27054bc..cd5e174 100644 --- a/docs/headless_runner.md +++ b/docs/headless_runner.md @@ -186,7 +186,8 @@ that member's current pressure, the proposed aggregate equilibrium pressure, and `voxelSnapMoleFractionEpsilon` bounds each dimensionless per-species mole-fraction correction. Species totals and sensible internal energy are preserved by the projection, subject to the simulation's single-precision storage. The relative pressure epsilon is dimensionless and normalizes to `[0, 1]`; its default `0.001` means `0.1%`. -The other current defaults are `0.5` Pa, `0.01` K, and `0.001` mole fraction. +The other current defaults are `0.5` Pa, `0.01` K, and `0.005` mole fraction. The composition limit therefore +allows a conservative projection to correct at most 0.5 percentage points of any species' local mole fraction. Snap eligibility is intentionally independent of `minimumPressureTransferPa`, whose production default remains `0.1` Pa/tick. A conservative snap may finish the diminishing flow tail even while ordinary advection would still diff --git a/src/Numos.CoreSim/AtmosConfigDefaults.cs b/src/Numos.CoreSim/AtmosConfigDefaults.cs index 8f6e711..6f61c6b 100644 --- a/src/Numos.CoreSim/AtmosConfigDefaults.cs +++ b/src/Numos.CoreSim/AtmosConfigDefaults.cs @@ -47,7 +47,7 @@ public static class AtmosConfigDefaults public const float VacuumThreshold = 1f; /// Default consecutive quiet ticks required before a chunk sleeps. - public const int SleepThreshold = 100; + public const int SleepThreshold = 15; /// /// Default absolute pressure-correction floor used by voxel snapping, and the legacy neighboring-pressure @@ -65,7 +65,7 @@ public static class AtmosConfigDefaults public const float VoxelSnapTemperatureEpsilon = 0.01f; /// Default maximum per-species mole-fraction correction made by one voxel snap. - public const float VoxelSnapMoleFractionEpsilon = 0.001f; + public const float VoxelSnapMoleFractionEpsilon = 0.005f; /// Default effective per-face thermal conductance, in joules per kelvin per thermodynamics tick. public const float ThermalConductance = 0.05f; diff --git a/tests/Numos.CoreSim.IntegrationTests/ProgressiveVoxelSnappingIntegrationTests.cs b/tests/Numos.CoreSim.IntegrationTests/ProgressiveVoxelSnappingIntegrationTests.cs index 3d7dc72..017cd5d 100644 --- a/tests/Numos.CoreSim.IntegrationTests/ProgressiveVoxelSnappingIntegrationTests.cs +++ b/tests/Numos.CoreSim.IntegrationTests/ProgressiveVoxelSnappingIntegrationTests.cs @@ -1781,6 +1781,73 @@ public void ProductionDefaults_CornerInjectionSleepsNearUniformWithinOneThousand }); } + [Test] + public void ProductionDefaults_FourSourceMixtureSleepsNearUniformWithinFiveHundredTicks() + { + const int size = 16; + const float initialMolesPerSource = 5_000f; + const float initialTemperature = 293.15f; + var config = new AtmosConfig(); + config.GasRegistry.Add(new GasProperties + { + Name = "O2", + MolarHeatCapacityAtConstantVolume = 20.786157f, + BoilingPoint = 90.2f, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = 6820f, + LiquidId = 0, + DiffusionCoefficient = 0.1f + }); + config.GasRegistry.Add(new GasProperties + { + Name = "N2", + MolarHeatCapacityAtConstantVolume = 20.786157f, + BoilingPoint = 77.34f, + CondensationEnabled = true, + MolarEnthalpyOfVaporization = 5600f, + LiquidId = 1, + DiffusionCoefficient = 0.08f + }); + using var simulation = new AtmosSimulation(config, size, size, 1); + var chunk = SimTestHelpers.CreateOpenChunk(simulation, default); + simulation.AddGasToVoxel(chunk, 15, 0, 0, 0, initialMolesPerSource, initialTemperature); + simulation.AddGasToVoxel(chunk, 0, 15, 0, 0, initialMolesPerSource, initialTemperature); + simulation.AddGasToVoxel(chunk, 7, 7, 0, 1, initialMolesPerSource, initialTemperature); + simulation.AddGasToVoxel(chunk, 8, 8, 0, 1, initialMolesPerSource, initialTemperature); + var initial = simulation.GetChunkSnapshot(chunk); + double initialEnergy = SimTestHelpers.TotalThermalEnergyPrecise(config, initial); + + var final = RunUntilSleeping(simulation, chunk, 500); + float[] oxygen = ReadMoles(final, 0); + float[] nitrogen = ReadMoles(final, 1); + float pressureSpread = final.TotalPressure.Max() - final.TotalPressure.Min(); + float temperatureSpread = final.Temperature.Max() - final.Temperature.Min(); + double finalEnergy = SimTestHelpers.TotalThermalEnergyPrecise(config, final); + + Assert.Multiple(() => + { + Assert.That(final.IsAwake, Is.False); + Assert.That(final.SleepTimer, Is.GreaterThan(config.SleepThreshold)); + Assert.That(simulation.TickCount, Is.LessThanOrEqualTo(500)); + Assert.That(pressureSpread, + Is.LessThanOrEqualTo(8f * Ulp(final.TotalPressure.Max()))); + Assert.That(temperatureSpread, + Is.LessThanOrEqualTo(8f * Ulp(final.Temperature.Max()))); + Assert.That(oxygen.Max() - oxygen.Min(), + Is.LessThanOrEqualTo(8f * Ulp(oxygen.Max()))); + Assert.That(nitrogen.Max() - nitrogen.Min(), + Is.LessThanOrEqualTo(8f * Ulp(nitrogen.Max()))); + Assert.That(SpeciesTotal(final, 0), + Is.EqualTo(SpeciesTotal(initial, 0)) + .Within(FloatSumTolerance(SpeciesTotal(initial, 0), size * size))); + Assert.That(SpeciesTotal(final, 1), + Is.EqualTo(SpeciesTotal(initial, 1)) + .Within(FloatSumTolerance(SpeciesTotal(initial, 1), size * size))); + Assert.That(finalEnergy, + Is.EqualTo(initialEnergy).Within(FloatEnergyTolerance(initialEnergy, size * size))); + }); + } + private static AtmosConfig CreateForcedSnappingConfig() { var config = SimTestHelpers.CreateDeterministicConfig(); diff --git a/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs b/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs index c99ebe6..e09d775 100644 --- a/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosConfigTests.cs @@ -59,10 +59,11 @@ public void Constructor_UsesProgressiveVoxelSnappingDefaults() Assert.Multiple(() => { Assert.That(config.VoxelSnappingEnabled, Is.True); + Assert.That(config.SleepThreshold, Is.EqualTo(15)); Assert.That(config.SleepEpsilon, Is.EqualTo(0.5f)); Assert.That(config.VoxelSnapPressureRelativeEpsilon, Is.EqualTo(0.001f)); Assert.That(config.VoxelSnapTemperatureEpsilon, Is.EqualTo(0.01f)); - Assert.That(config.VoxelSnapMoleFractionEpsilon, Is.EqualTo(0.001f)); + Assert.That(config.VoxelSnapMoleFractionEpsilon, Is.EqualTo(0.005f)); }); } diff --git a/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs b/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs index 5a6d21d..441ee00 100644 --- a/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs +++ b/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs @@ -388,7 +388,8 @@ public async Task CheckedInEquilibriumScript_Sample900IsSleepingAndNearUniform() Assert.That(global.GetProperty("awakeChunkCount").GetInt32(), Is.Zero); Assert.That(global.GetProperty("sleepingChunkCount").GetInt32(), Is.EqualTo(1)); Assert.That(chunk.GetProperty("isAwake").GetBoolean(), Is.False); - Assert.That(chunk.GetProperty("sleepTimer").GetInt32(), Is.GreaterThan(100)); + Assert.That(chunk.GetProperty("sleepTimer").GetInt32(), + Is.GreaterThan(observation.GetProperty("config").GetProperty("sleepThreshold").GetInt32())); Assert.That(global.GetProperty("gasBearingVoxelCount").GetInt32(), Is.EqualTo(256)); Assert.That(pressureSpread, Is.LessThanOrEqualTo(observation.GetProperty("config") From 1e9154a9c8ea5dfc77265d8bac93149cdc353b76 Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:11:15 -0500 Subject: [PATCH 13/14] Add voxel snapping visualization, state markers, and SnapGroup management --- docs/atmospherics_technical_documentation.md | 10 + docs/headless_runner.md | 8 +- src/Numos.CoreSim/AggregateVoxels.cs | 42 +++- src/Numos.CoreSim/AtmosChunk.cs | 6 + src/Numos.CoreSim/AtmosKernel.cs | 4 +- .../Datatypes/Snapshots/AtmosChunkSnapshot.cs | 9 + .../Snapshots/AtmosChunkSnapshotFields.cs | 5 +- .../Diagnostics/SimulationStateAnalyzer.cs | 3 + .../Diagnostics/SimulationStateReports.cs | 2 + src/Numos.SimDrawer/DrawableData.cs | 15 +- src/Numos.SimDrawer/SimulationFrameBuilder.cs | 74 ++++++- .../Rendering/SimulationRenderer.cs | 129 ++++++++++- src/Numos.Viewer/Rendering/SliceRenderer.cs | 49 ++++- src/Numos.Viewer/SimulationViewer.RenderUi.cs | 4 + src/Numos.Viewer/SimulationViewer.cs | 3 +- .../Numos.API.Tests/AtmosChunkVersionTests.cs | 97 +++++++++ .../AtmosChunkSnapshotTests.cs | 5 +- .../HeadlessApplicationTests.cs | 33 +++ .../SimulationFrameBuilderTests.cs | 203 +++++++++++++++++- .../SliceProjectionTests.cs | 6 +- 20 files changed, 677 insertions(+), 30 deletions(-) diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index 1128592..d689f5e 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -704,6 +704,16 @@ processed normally before the terminal coordinator splits or revalidates the gro threshold is `max(SleepThreshold, ThermodynamicsTickInterval)` (currently two ticks), ensuring at least one complete lower-frequency thermal/phase cadence is observed. Sleep occurs only when the timer grows beyond that threshold. +`AtmosChunkSnapshotFields.VoxelSnapping` requests the detached `VoxelSnapGroupMap`. A nonnegative entry identifies +an established aggregate containing at least two voxels; its deterministic, chunk-local ID is the lowest local flat +voxel index in that group. Singleton, inactive, and reset entries are `-1`. This is authoritative solver topology +rather than a pressure-similarity inference, and IDs can change when groups merge, split, or reset. While a chunk is +awake, the viewer draws `/` in one deterministic color for every member of a group and assigns every different group +within that chunk a distinct display color. The color key includes chunk identity and group ID, so it remains stable +across unchanged frames. +An asleep chunk instead displays a red `X` on every voxel, including voxels that retain a nonnegative group ID, so +the sleeping marker and color always take precedence. + Snap aggregates never span chunks. Registered neighboring chunks continue to exchange gas through the normal boundary-flow stage, and a transfer keeps the source awake and wakes the target as applicable. Missing chunks remain reflecting boundaries. This behavior should not be interpreted as an atomic cross-chunk equilibrium projection. diff --git a/docs/headless_runner.md b/docs/headless_runner.md index cd5e174..430b5bb 100644 --- a/docs/headless_runner.md +++ b/docs/headless_runner.md @@ -256,8 +256,12 @@ Dense voxel details are deliberately opt-in because they can dominate output for cells. An optional chunk `position` limits the report to one chunk. Supplying both `position` and a local `voxel` returns that exact cell even if `includeVoxels` is false; `voxel` is invalid without `position`. `maxIssueLocations` caps the coordinate samples attached to invalid-value diagnostics (default `32`, maximum `1024`). Each emitted voxel has a stable -local index and local coordinates, classification, gas-capable/gas-bearing flags, raw pressure and temperature, total -moles, estimated sensible energy, and per-gas moles. +local index and local coordinates, classification, gas-capable/gas-bearing flags, authoritative `isSnapped` aggregate +membership and optional `snapGroupId` (omitted when the voxel is ungrouped), raw pressure and temperature, total +moles, estimated sensible energy, and per-gas moles. A group ID is the group's lowest local voxel index, is local to +its chunk, and can change when groups merge, split, or reset. Combine it with the containing chunk's `isAwake`: +the viewer displays a color-coded `/` for a snapped voxel only while awake and displays `X` for every voxel when the +chunk is asleep. `pressurePa` is the simulation's cached pressure field. Supported mutations and live-configuration refreshes keep it coherent, but an inactive voxel modified through unchecked dangerous solver storage can retain a stale cached value. diff --git a/src/Numos.CoreSim/AggregateVoxels.cs b/src/Numos.CoreSim/AggregateVoxels.cs index 46a1cf9..e50199e 100644 --- a/src/Numos.CoreSim/AggregateVoxels.cs +++ b/src/Numos.CoreSim/AggregateVoxels.cs @@ -40,10 +40,27 @@ internal sealed class AggregateVoxels /// /// Invalidates every progressive aggregate and the stable-state verification window. /// - internal void Reset() + /// Whether the externally observable snap-group map contained a multi-voxel group. + internal bool Reset() { + bool snapGroupMapChanged = HasMultiVoxelAggregate(); _isInitialized = false; _hasPreviousFingerprint = false; + return snapGroupMapChanged; + } + + private bool HasMultiVoxelAggregate() + { + if (!_isInitialized) + return false; + + for (var voxelIndex = 0; voxelIndex < _parent.Length; voxelIndex++) + { + if (_parent[voxelIndex] == voxelIndex && _next[voxelIndex] >= 0) + return true; + } + + return false; } /// @@ -128,6 +145,29 @@ internal bool AreAggregatedTogether(ushort firstVoxel, ushort secondVoxel) return firstRoot >= 0 && firstRoot == _parent[secondVoxel]; } + /// + /// Copies the canonical group ID for each voxel in an established multi-voxel aggregate. + /// + /// + /// The result describes solver-owned aggregate topology, not similarity inferred from the materialized + /// pressure or composition arrays. A group ID is its lowest local flat voxel index. Inactive voxels, + /// singleton roots, and reset topology are reported as -1. + /// + internal void CopySnapGroupMap(Span destination) + { + destination.Fill(-1); + if (!_isInitialized) + return; + + Debug.Assert(destination.Length == _parent.Length); + for (var voxelIndex = 0; voxelIndex < destination.Length; voxelIndex++) + { + int root = _parent[voxelIndex]; + if (root >= 0 && _next[root] >= 0) + destination[voxelIndex] = root; + } + } + /// /// Returns whether the live materialized state still matches the last finalized aggregate state. /// diff --git a/src/Numos.CoreSim/AtmosChunk.cs b/src/Numos.CoreSim/AtmosChunk.cs index 83bacea..8632910 100644 --- a/src/Numos.CoreSim/AtmosChunk.cs +++ b/src/Numos.CoreSim/AtmosChunk.cs @@ -828,12 +828,18 @@ public AtmosChunkSnapshot GetNetworkSnapshot( VoxelRoomMap = fields.HasFlag(AtmosChunkSnapshotFields.VoxelClassification) ? VoxelRoomMap.ToArray() : [], + VoxelSnapGroupMap = fields.HasFlag(AtmosChunkSnapshotFields.VoxelSnapping) + ? new int[VoxelCount] + : [], ActiveAirCount = ActiveAirCount, ActiveGasCount = ActiveGasCount, IsAwake = IsAwake, SleepTimer = SleepTimer }; + if (fields.HasFlag(AtmosChunkSnapshotFields.VoxelSnapping)) + VoxelAggregates.CopySnapGroupMap(snapshot.VoxelSnapGroupMap); + if (fields.HasFlag(AtmosChunkSnapshotFields.Gases)) { for (var g = 0; g < ActiveGasCount; g++) diff --git a/src/Numos.CoreSim/AtmosKernel.cs b/src/Numos.CoreSim/AtmosKernel.cs index a20c3af..5dd6e75 100644 --- a/src/Numos.CoreSim/AtmosKernel.cs +++ b/src/Numos.CoreSim/AtmosKernel.cs @@ -101,7 +101,9 @@ private void TickSimulation(AtmosChunk[] chunks) } else { - chunk.VoxelAggregates.Reset(); + bool snapGroupMapChanged = chunk.VoxelAggregates.Reset(); + if (snapGroupMapChanged && !chunk.IsAwake) + chunk.MarkChanged(); } } } diff --git a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs index 867bbf5..674ff8d 100644 --- a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs +++ b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshot.cs @@ -14,6 +14,13 @@ public struct AtmosChunkSnapshot public float[] Temperature; public GasSnapshot[] Gases; public int[] VoxelRoomMap; + + /// + /// Detached per-voxel aggregate IDs. A nonnegative entry is the lowest local flat voxel index in an + /// established multi-voxel aggregate; -1 identifies an ungrouped voxel. + /// + public int[] VoxelSnapGroupMap; + public int ActiveAirCount; public int ActiveGasCount; public bool IsAwake; @@ -52,6 +59,8 @@ public readonly bool HasFields(AtmosChunkSnapshotFields fields) available |= AtmosChunkSnapshotFields.Gases; if (VoxelRoomMap is { Length: > 0 }) available |= AtmosChunkSnapshotFields.VoxelClassification; + if (VoxelSnapGroupMap is { Length: > 0 }) + available |= AtmosChunkSnapshotFields.VoxelSnapping; } return (available & fields) == fields; diff --git a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshotFields.cs b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshotFields.cs index 7b08caa..1735f69 100644 --- a/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshotFields.cs +++ b/src/Numos.CoreSim/Datatypes/Snapshots/AtmosChunkSnapshotFields.cs @@ -11,5 +11,6 @@ public enum AtmosChunkSnapshotFields Temperature = 1 << 1, Gases = 1 << 2, VoxelClassification = 1 << 3, - All = Pressure | Temperature | Gases | VoxelClassification -} \ No newline at end of file + VoxelSnapping = 1 << 4, + All = Pressure | Temperature | Gases | VoxelClassification | VoxelSnapping +} diff --git a/src/Numos.Headless/Diagnostics/SimulationStateAnalyzer.cs b/src/Numos.Headless/Diagnostics/SimulationStateAnalyzer.cs index 6276ded..88c951f 100644 --- a/src/Numos.Headless/Diagnostics/SimulationStateAnalyzer.cs +++ b/src/Numos.Headless/Diagnostics/SimulationStateAnalyzer.cs @@ -222,6 +222,7 @@ private static VoxelStateReport CreateVoxelReport( VoxelAnalysis voxel, AtmosConfig config) { + int snapGroupId = snapshot.VoxelSnapGroupMap[localIndex]; VoxelGasReport[] gases = snapshot.Gases .OrderBy(static gas => gas.GasId) .Select(gas => new VoxelGasReport( @@ -235,6 +236,8 @@ private static VoxelStateReport CreateVoxelReport( snapshot.VoxelRoomMap[localIndex], voxel.IsGasCapable, voxel.IsGasBearing, + snapGroupId >= 0, + snapGroupId >= 0 ? snapGroupId : null, snapshot.TotalPressure[localIndex], snapshot.Temperature[localIndex], voxel.TotalMoles, diff --git a/src/Numos.Headless/Diagnostics/SimulationStateReports.cs b/src/Numos.Headless/Diagnostics/SimulationStateReports.cs index 0175517..8b3efa3 100644 --- a/src/Numos.Headless/Diagnostics/SimulationStateReports.cs +++ b/src/Numos.Headless/Diagnostics/SimulationStateReports.cs @@ -161,6 +161,8 @@ public sealed record VoxelStateReport( int RoomId, bool IsGasCapable, bool IsGasBearing, + bool IsSnapped, + int? SnapGroupId, float PressurePa, float TemperatureK, double TotalMoles, diff --git a/src/Numos.SimDrawer/DrawableData.cs b/src/Numos.SimDrawer/DrawableData.cs index ad18ec5..8984680 100644 --- a/src/Numos.SimDrawer/DrawableData.cs +++ b/src/Numos.SimDrawer/DrawableData.cs @@ -55,6 +55,16 @@ public enum VoxelFaceMask : byte All = NegativeX | PositiveX | NegativeY | PositiveY | NegativeZ | PositiveZ } +/// +/// Diagnostic symbol drawn over a voxel independently of the selected visualization. +/// +public enum VoxelStateMarker : byte +{ + None, + Snapped, + Sleeping +} + /// /// Immutable presentation values for one voxel. It contains no API-specific mesh data. /// @@ -69,7 +79,10 @@ public readonly record struct VoxelDrawData( float TotalMoles, int PrimaryGasId, int RoomId, - ColorRgba Color); + ColorRgba Color, + VoxelStateMarker StateMarker = VoxelStateMarker.None, + int SnapGroupId = -1, + ColorRgba StateMarkerColor = default); /// /// Immutable presentation data and invalidation keys for one chunk. diff --git a/src/Numos.SimDrawer/SimulationFrameBuilder.cs b/src/Numos.SimDrawer/SimulationFrameBuilder.cs index 58b3aaa..95d5412 100644 --- a/src/Numos.SimDrawer/SimulationFrameBuilder.cs +++ b/src/Numos.SimDrawer/SimulationFrameBuilder.cs @@ -31,7 +31,8 @@ public SimulationFrameBuilder(AtmosConfig config, VisualizationRegistry? visuali public AtmosChunkSnapshotFields GetRequiredSnapshotFields(string visualizationId) { var visualization = Visualizations.GetRequired(visualizationId); - var fields = AtmosChunkSnapshotFields.VoxelClassification; + var fields = AtmosChunkSnapshotFields.VoxelClassification | + AtmosChunkSnapshotFields.VoxelSnapping; if ((visualization.RequiredData & VisualizationDataRequirements.Temperature) != 0) fields |= AtmosChunkSnapshotFields.Temperature; if ((visualization.RequiredData & VisualizationDataRequirements.Pressure) != 0) @@ -179,7 +180,7 @@ public SimulationSliceDrawData BuildChunkSlice( (int x, int y, int z) = MapSliceToLocal(axis, clampedIndex, u, v); ushort localIndex = chunk.GetLocalIndex(x, y, z); ref readonly var voxel = ref chunk.GetCell(localIndex); - if (!voxel.IsVisible) + if (!voxel.IsVisible && voxel.StateMarker == VoxelStateMarker.None) continue; var sliceCell = new SliceCellDrawData( @@ -254,6 +255,7 @@ private static ChunkDrawData BuildChunk( var visibleCount = 0; bool summarizeGases = (visualization.RequiredData & VisualizationDataRequirements.Gases) != 0; + bool hasVoxelSnapGroupMap = snapshot.VoxelSnapGroupMap is { Length: > 0 }; for (var index = 0; index < voxelCount; index++) { var localIndex = checked((ushort)index); @@ -286,6 +288,20 @@ private static ChunkDrawData BuildChunk( bool visible = isInVisualizationDomain && visualization.TryGetColor(sample, out color); if (visible) color = ToOpaqueFiniteColor(color); + int snapGroupId = hasVoxelSnapGroupMap + ? snapshot.VoxelSnapGroupMap[index] + : -1; + VoxelStateMarker stateMarker = !snapshot.IsAwake + ? VoxelStateMarker.Sleeping + : snapGroupId >= 0 + ? VoxelStateMarker.Snapped + : VoxelStateMarker.None; + ColorRgba stateMarkerColor = stateMarker switch + { + VoxelStateMarker.Snapped => GetSnapGroupColor(identity, snapGroupId), + VoxelStateMarker.Sleeping => new ColorRgba(1f, 0.25f, 0.2f), + _ => default + }; cells[index] = new VoxelDrawData( visible, VoxelFaceMask.None, @@ -294,9 +310,16 @@ private static ChunkDrawData BuildChunk( sample.TotalMoles, sample.PrimaryGasId, sample.RoomId, - visible ? color : default); + visible ? color : default, + stateMarker, + snapGroupId, + stateMarkerColor); topologyHash.Add(visible); + topologyHash.Add(stateMarker != VoxelStateMarker.None); + styleHash.Add((byte)stateMarker); + styleHash.Add(snapGroupId); + styleHash.Add(stateMarkerColor); if (visible) { visibleCount++; @@ -439,6 +462,13 @@ private static int ValidateSnapshot( throw new ArgumentException("Snapshot classifications do not match its dimensions.", nameof(snapshot)); } + if (snapshot.VoxelSnapGroupMap is { Length: > 0 } && + snapshot.VoxelSnapGroupMap.Length != voxelCount) + { + throw new ArgumentException("Snapshot voxel-snap markers do not match its dimensions.", + nameof(snapshot)); + } + if ((requirements & VisualizationDataRequirements.Pressure) != 0 && snapshot.TotalPressure.Length != voxelCount) throw new ArgumentException("The visualization requires a complete pressure field.", nameof(snapshot)); @@ -461,6 +491,42 @@ private static int ValidateSnapshot( return voxelCount; } + private static ColorRgba GetSnapGroupColor(ChunkIdentity identity, int groupId) + { + uint chunkHash = 2166136261u; + Mix(ref chunkHash, unchecked((uint)identity.Position.X)); + Mix(ref chunkHash, unchecked((uint)identity.Position.Y)); + Mix(ref chunkHash, unchecked((uint)identity.Position.Z)); + Mix(ref chunkHash, unchecked((uint)identity.Generation)); + Mix(ref chunkHash, unchecked((uint)(identity.Generation >> 32))); + + // Local voxel indices are ushort-backed. Multiplication by an odd number and addition are a + // permutation modulo 2^16, so every possible group root receives a different pair of display bytes. + // Pinning the remaining channel at full intensity keeps every marker bright, while the chunk hash + // rotates the RGB face and palette offset without compromising within-chunk uniqueness. + ushort paletteCode = unchecked((ushort)((uint)groupId * 0x9E37u + (chunkHash & 0xFFFFu))); + byte first = (byte)(paletteCode >> 8); + byte second = (byte)paletteCode; + return (chunkHash % 3u) switch + { + 0u => FromDisplayBytes(byte.MaxValue, first, second), + 1u => FromDisplayBytes(first, byte.MaxValue, second), + _ => FromDisplayBytes(first, second, byte.MaxValue) + }; + } + + private static void Mix(ref uint hash, uint value) + { + hash ^= value; + hash *= 16777619u; + } + + private static ColorRgba FromDisplayBytes(byte red, byte green, byte blue) + { + const float byteScale = 1f / byte.MaxValue; + return new ColorRgba(red * byteScale, green * byteScale, blue * byteScale); + } + private static void AddGasIds(AtmosChunkSnapshot snapshot, ISet destination) { if (snapshot.Gases == null) @@ -610,4 +676,4 @@ private void EnsureInitialized() _value = OffsetBasis; } } -} \ No newline at end of file +} diff --git a/src/Numos.Viewer/Rendering/SimulationRenderer.cs b/src/Numos.Viewer/Rendering/SimulationRenderer.cs index d13eb59..f40cd31 100644 --- a/src/Numos.Viewer/Rendering/SimulationRenderer.cs +++ b/src/Numos.Viewer/Rendering/SimulationRenderer.cs @@ -18,8 +18,10 @@ public static void Draw( SimulationDrawData frame, ChunkIdentity? focusedChunk, IReadOnlyList highlights, + Camera3D camera, Render3DStyleOptions options = default) { + GetCameraPlane(camera, out Vector3 cameraRight, out Vector3 cameraUp); foreach (var chunk in frame.Chunks.Values) { if (!frame.HasCurrentVisualizationMapping(chunk) || @@ -28,7 +30,7 @@ public static void Draw( continue; } - DrawChunk(chunk, options); + DrawChunk(chunk, options, cameraRight, cameraUp); if (options.ShowChunkOutlines) DrawChunkOutline(chunk); @@ -37,14 +39,21 @@ public static void Draw( DrawHighlights(frame, focusedChunk, highlights); } - private static void DrawChunk(ChunkDrawData chunk, Render3DStyleOptions options) + private static void DrawChunk( + ChunkDrawData chunk, + Render3DStyleOptions options, + Vector3 cameraRight, + Vector3 cameraUp) { var cells = chunk.Cells; for (var localIndex = 0; localIndex < cells.Length; localIndex++) { ref readonly var cell = ref cells[localIndex]; - if (!cell.IsVisible || cell.VisibleFaces == VoxelFaceMask.None) + if ((!cell.IsVisible || cell.VisibleFaces == VoxelFaceMask.None) && + cell.StateMarker == VoxelStateMarker.None) + { continue; + } int x = localIndex % chunk.Dimensions.X; int yz = localIndex / chunk.Dimensions.X; @@ -55,6 +64,17 @@ private static void DrawChunk(ChunkDrawData chunk, Render3DStyleOptions options) chunk.ChunkPosition.Y * chunk.Dimensions.Y + y + 0.5f, chunk.ChunkPosition.Z * chunk.Dimensions.Z + z + 0.5f); + if (!cell.IsVisible || cell.VisibleFaces == VoxelFaceMask.None) + { + DrawFloatingStateMarker( + center, + cell.StateMarker, + ToRaylibColor(cell.StateMarkerColor), + cameraRight, + cameraUp); + continue; + } + var color = ToRaylibColor(cell.Color); if (options.TransparentVoxels) color.A = 89; @@ -63,6 +83,107 @@ private static void DrawChunk(ChunkDrawData chunk, Render3DStyleOptions options) if (options.ShowVoxelOutlines) Raylib.DrawCubeWiresV(center, VoxelSize, new Color(0f, 0f, 0f, 0.55f)); + + DrawStateMarker( + center, + cell.VisibleFaces, + cell.StateMarker, + ToRaylibColor(cell.StateMarkerColor)); + } + } + + private static void DrawFloatingStateMarker( + Vector3 center, + VoxelStateMarker marker, + Color color, + Vector3 cameraRight, + Vector3 cameraUp) + { + if (marker == VoxelStateMarker.None) + return; + + const float radius = 0.27f; + Raylib.DrawLine3D( + center - cameraRight * radius - cameraUp * radius, + center + cameraRight * radius + cameraUp * radius, + color); + if (marker == VoxelStateMarker.Sleeping) + { + Raylib.DrawLine3D( + center - cameraRight * radius + cameraUp * radius, + center + cameraRight * radius - cameraUp * radius, + color); + } + } + + private static void GetCameraPlane(Camera3D camera, out Vector3 right, out Vector3 up) + { + Vector3 forward = camera.Target - camera.Position; + forward = forward.LengthSquared() > 0.000001f + ? Vector3.Normalize(forward) + : -Vector3.UnitZ; + right = Vector3.Cross(forward, camera.Up); + if (right.LengthSquared() <= 0.000001f) + right = Vector3.Cross(forward, Vector3.UnitX); + if (right.LengthSquared() <= 0.000001f) + right = Vector3.UnitX; + else + right = Vector3.Normalize(right); + up = Vector3.Normalize(Vector3.Cross(right, forward)); + } + + private static void DrawStateMarker( + Vector3 center, + VoxelFaceMask visibleFaces, + VoxelStateMarker marker, + Color color) + { + if (marker == VoxelStateMarker.None) + return; + + DrawMarkerOnFace(center, visibleFaces, VoxelFaceMask.NegativeX, Vector3.UnitY, Vector3.UnitZ, color, marker); + DrawMarkerOnFace(center, visibleFaces, VoxelFaceMask.PositiveX, Vector3.UnitY, Vector3.UnitZ, color, marker); + DrawMarkerOnFace(center, visibleFaces, VoxelFaceMask.NegativeY, Vector3.UnitX, Vector3.UnitZ, color, marker); + DrawMarkerOnFace(center, visibleFaces, VoxelFaceMask.PositiveY, Vector3.UnitX, Vector3.UnitZ, color, marker); + DrawMarkerOnFace(center, visibleFaces, VoxelFaceMask.NegativeZ, Vector3.UnitX, Vector3.UnitY, color, marker); + DrawMarkerOnFace(center, visibleFaces, VoxelFaceMask.PositiveZ, Vector3.UnitX, Vector3.UnitY, color, marker); + } + + private static void DrawMarkerOnFace( + Vector3 center, + VoxelFaceMask visibleFaces, + VoxelFaceMask face, + Vector3 horizontal, + Vector3 vertical, + Color color, + VoxelStateMarker marker) + { + if ((visibleFaces & face) == 0) + return; + + Vector3 normal = face switch + { + VoxelFaceMask.NegativeX => -Vector3.UnitX, + VoxelFaceMask.PositiveX => Vector3.UnitX, + VoxelFaceMask.NegativeY => -Vector3.UnitY, + VoxelFaceMask.PositiveY => Vector3.UnitY, + VoxelFaceMask.NegativeZ => -Vector3.UnitZ, + VoxelFaceMask.PositiveZ => Vector3.UnitZ, + _ => Vector3.Zero + }; + Vector3 faceCenter = center + normal * 0.501f; + const float radius = 0.27f; + Raylib.DrawLine3D( + faceCenter - horizontal * radius - vertical * radius, + faceCenter + horizontal * radius + vertical * radius, + color); + + if (marker == VoxelStateMarker.Sleeping) + { + Raylib.DrawLine3D( + faceCenter - horizontal * radius + vertical * radius, + faceCenter + horizontal * radius - vertical * radius, + color); } } @@ -122,4 +243,4 @@ internal static Color ToRaylibColor(ColorRgba color) Math.Clamp(color.B, 0f, 1f), Math.Clamp(color.A, 0f, 1f)); } -} \ No newline at end of file +} diff --git a/src/Numos.Viewer/Rendering/SliceRenderer.cs b/src/Numos.Viewer/Rendering/SliceRenderer.cs index b8d7ccf..e82e2cf 100644 --- a/src/Numos.Viewer/Rendering/SliceRenderer.cs +++ b/src/Numos.Viewer/Rendering/SliceRenderer.cs @@ -15,6 +15,7 @@ public readonly record struct SliceRenderOptions( public static class SliceRenderer { private readonly static Color CellBorder = new(0f, 0f, 0f, 0.5f); + private readonly static Color MarkerShadow = new(0f, 0f, 0f, 0.8f); public static void Draw( SimulationSliceDrawData slice, @@ -30,13 +31,21 @@ public static void Draw( foreach (var cell in slice.Cells) { var rectangle = GetCellRectangle(slice, cell.U, cell.V); - var color = SimulationRenderer.ToRaylibColor(cell.Voxel.Color); - if (style.TransparentVoxels) - color.A = 89; + if (cell.Voxel.IsVisible) + { + var color = SimulationRenderer.ToRaylibColor(cell.Voxel.Color); + if (style.TransparentVoxels) + color.A = 89; - Raylib.DrawRectangleRec(rectangle, color); - if (style.ShowVoxelOutlines) - Raylib.DrawRectangleLinesEx(rectangle, 0.025f, CellBorder); + Raylib.DrawRectangleRec(rectangle, color); + if (style.ShowVoxelOutlines) + Raylib.DrawRectangleLinesEx(rectangle, 0.025f, CellBorder); + } + + DrawStateMarker( + rectangle, + cell.Voxel.StateMarker, + SimulationRenderer.ToRaylibColor(cell.Voxel.StateMarkerColor)); } if (style.ShowChunkOutlines) @@ -51,6 +60,32 @@ public static void Draw( } } + private static void DrawStateMarker(Rectangle rectangle, VoxelStateMarker marker, Color color) + { + if (marker == VoxelStateMarker.None) + return; + + var lowerLeft = new System.Numerics.Vector2(rectangle.X + 0.25f, rectangle.Y + 0.75f); + var upperRight = new System.Numerics.Vector2(rectangle.X + 0.75f, rectangle.Y + 0.25f); + DrawMarkerStroke(lowerLeft, upperRight, color); + + if (marker != VoxelStateMarker.Sleeping) + return; + + var upperLeft = new System.Numerics.Vector2(rectangle.X + 0.25f, rectangle.Y + 0.25f); + var lowerRight = new System.Numerics.Vector2(rectangle.X + 0.75f, rectangle.Y + 0.75f); + DrawMarkerStroke(upperLeft, lowerRight, color); + } + + private static void DrawMarkerStroke( + System.Numerics.Vector2 start, + System.Numerics.Vector2 end, + Color color) + { + Raylib.DrawLineEx(start, end, 0.13f, MarkerShadow); + Raylib.DrawLineEx(start, end, 0.08f, color); + } + private static void DrawHighlight( SimulationSliceDrawData slice, int u, @@ -70,4 +105,4 @@ private static Rectangle GetCellRectangle(SimulationSliceDrawData slice, int u, // conventional bottom-left-origin plot when its render texture is shown in ImGui. return new Rectangle(u, slice.Height - v - 1, 1f, 1f); } -} \ No newline at end of file +} diff --git a/src/Numos.Viewer/SimulationViewer.RenderUi.cs b/src/Numos.Viewer/SimulationViewer.RenderUi.cs index 9f8a54e..ca2a923 100644 --- a/src/Numos.Viewer/SimulationViewer.RenderUi.cs +++ b/src/Numos.Viewer/SimulationViewer.RenderUi.cs @@ -395,6 +395,10 @@ private void RenderViewPanel() RenderVisualizationLegend(); + ImGui.Text("Voxel State"); + ImGui.Text("/ Snapped group (color-coded)"); + ImGui.TextColored(new Vector4(1f, 0.25f, 0.2f, 1f), "X Sleeping chunk"); + ImGui.Separator(); RenderRenderingStyleTable(); diff --git a/src/Numos.Viewer/SimulationViewer.cs b/src/Numos.Viewer/SimulationViewer.cs index 81e5c79..3bec34f 100644 --- a/src/Numos.Viewer/SimulationViewer.cs +++ b/src/Numos.Viewer/SimulationViewer.cs @@ -533,6 +533,7 @@ private void RenderSimulationScene() _drawData, _focusedChunk, _highlights, + _camera3D, Get3DRenderStyleOptions()); } finally @@ -659,4 +660,4 @@ private void RebuildHighlights() if (_hoveredSliceCell.HasValue && _hoveredSliceCell.Value.Address != _selectedCell) _highlights.Add(new VoxelHighlight(_hoveredSliceCell.Value.Address, new ColorRgba(1f, 1f, 1f))); } -} \ No newline at end of file +} diff --git a/tests/Numos.API.Tests/AtmosChunkVersionTests.cs b/tests/Numos.API.Tests/AtmosChunkVersionTests.cs index 648ad10..5088d93 100644 --- a/tests/Numos.API.Tests/AtmosChunkVersionTests.cs +++ b/tests/Numos.API.Tests/AtmosChunkVersionTests.cs @@ -42,6 +42,7 @@ public void TryGetChunkSnapshot_SelectedFields_DoesNotCopyUnusedVoxelArrays() Assert.That(snapshot.VoxelRoomMap, Has.Length.EqualTo(2)); Assert.That(snapshot.TotalPressure, Is.Empty); Assert.That(snapshot.Gases, Is.Empty); + Assert.That(snapshot.VoxelSnapGroupMap, Is.Empty); Assert.That(snapshot.HasFields(fields), Is.True); Assert.That(snapshot.HasFields(AtmosChunkSnapshotFields.All), Is.False); }); @@ -66,6 +67,102 @@ public void TryGetChunkSnapshot_NoFields_DoesNotClaimAnyDetachedField() Assert.That(snapshot.HasFields(AtmosChunkSnapshotFields.Temperature), Is.False); Assert.That(snapshot.HasFields(AtmosChunkSnapshotFields.Gases), Is.False); Assert.That(snapshot.HasFields(AtmosChunkSnapshotFields.VoxelClassification), Is.False); + Assert.That(snapshot.HasFields(AtmosChunkSnapshotFields.VoxelSnapping), Is.False); + }); + } + + [Test] + public void TryGetChunkSnapshot_VoxelSnappingFieldReportsAuthoritativeAggregateMembership() + { + using var simulation = new AtmosSimulation(4, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.WakeRoom(chunk, 1); + + simulation.TryGetChunkSnapshot( + chunk, + default, + AtmosChunkSnapshotFields.VoxelSnapping, + out var before); + simulation.Tick(); + simulation.TryGetChunkSnapshot( + chunk, + before.Version, + AtmosChunkSnapshotFields.VoxelSnapping, + out var firstRound); + simulation.Tick(); + simulation.TryGetChunkSnapshot( + chunk, + firstRound.Version, + AtmosChunkSnapshotFields.VoxelSnapping, + out var merged); + simulation.SleepChunk(chunk); + simulation.TryGetChunkSnapshot( + chunk, + merged.Version, + AtmosChunkSnapshotFields.VoxelSnapping, + out var sleeping); + simulation.WakeRoom(chunk, 1); + simulation.TryGetChunkSnapshot( + chunk, + sleeping.Version, + AtmosChunkSnapshotFields.VoxelSnapping, + out var reset); + + Assert.Multiple(() => + { + Assert.That(before.VoxelSnapGroupMap, Is.All.EqualTo(-1)); + Assert.That(firstRound.VoxelSnapGroupMap, Is.EqualTo(new[] { 0, 0, 2, 2 }), + "Each deterministic merge round must expose distinct canonical roots."); + Assert.That(merged.VoxelSnapGroupMap, Is.All.EqualTo(0)); + Assert.That(sleeping.IsAwake, Is.False); + Assert.That(sleeping.VoxelSnapGroupMap, Is.All.EqualTo(0), + "Sleeping retains aggregate provenance; presentation gives the sleeping marker precedence."); + Assert.That(reset.VoxelSnapGroupMap, Is.All.EqualTo(-1)); + Assert.That(firstRound.HasFields(AtmosChunkSnapshotFields.VoxelSnapping), Is.True); + Assert.That(firstRound.TotalPressure, Is.Empty); + Assert.That(firstRound.Temperature, Is.Empty); + Assert.That(firstRound.Gases, Is.Empty); + Assert.That(firstRound.VoxelRoomMap, Is.Empty); + }); + } + + [Test] + public void DisablingVoxelSnapping_PublishesOneRetainedSleepingGroupMapReset() + { + var config = new AtmosConfig(); + using var simulation = new AtmosSimulation(config, 2, 1, 1); + var chunk = simulation.CreateAndRegisterChunk(default); + simulation.SetChunkClassification(chunk, new VoxelClassification(1)); + simulation.WakeRoom(chunk, 1); + simulation.Tick(); + simulation.SleepChunk(chunk); + var grouped = simulation.GetChunkSnapshot(chunk); + + config.VoxelSnappingEnabled = false; + simulation.Tick(); + bool resetPublished = simulation.TryGetChunkSnapshot( + chunk, + grouped.Version, + AtmosChunkSnapshotFields.VoxelSnapping, + out var reset); + simulation.Tick(); + bool unchangedPublished = simulation.TryGetChunkSnapshot( + chunk, + reset.Version, + AtmosChunkSnapshotFields.VoxelSnapping, + out _); + + Assert.Multiple(() => + { + Assert.That(grouped.IsAwake, Is.False); + Assert.That(grouped.VoxelSnapGroupMap, Is.All.EqualTo(0)); + Assert.That(resetPublished, Is.True); + Assert.That(reset.IsAwake, Is.False); + Assert.That(reset.VoxelSnapGroupMap, Is.All.EqualTo(-1)); + Assert.That(reset.Version.Revision, Is.GreaterThan(grouped.Version.Revision)); + Assert.That(unchangedPublished, Is.False, + "An already-reset sleeping map must not spuriously advance its chunk revision."); }); } diff --git a/tests/Numos.CoreSim.Tests/AtmosChunkSnapshotTests.cs b/tests/Numos.CoreSim.Tests/AtmosChunkSnapshotTests.cs index edf5d5a..f0bef73 100644 --- a/tests/Numos.CoreSim.Tests/AtmosChunkSnapshotTests.cs +++ b/tests/Numos.CoreSim.Tests/AtmosChunkSnapshotTests.cs @@ -32,6 +32,7 @@ public void GetNetworkSnapshot_WithoutGasHasExactVoxelSizedCopies() Assert.That(snapshot.Temperature, Is.EqualTo(new[] { 0f, 0f, 0f, 275f })); Assert.That(snapshot.TotalPressure, Is.EqualTo(new[] { 0f, 0f, 0f, 25f })); Assert.That(snapshot.Gases, Is.Empty); + Assert.That(snapshot.VoxelSnapGroupMap, Is.EqualTo(new[] { -1, -1, -1, -1 })); }); } @@ -64,6 +65,7 @@ public void GetNetworkSnapshot_DeepCopiesChunkAndGasStorage() snapshot.TotalPressure[0] = -1f; snapshot.Temperature[0] = -1f; snapshot.VoxelRoomMap[0] = VoxelClassification.RoomSolid; + snapshot.VoxelSnapGroupMap[0] = 9; snapshot.Gases[0].Moles[0] = -1f; snapshot.Gases[0].GasId = 99; var freshSnapshot = chunk.GetNetworkSnapshot(); @@ -73,6 +75,7 @@ public void GetNetworkSnapshot_DeepCopiesChunkAndGasStorage() Assert.That(freshSnapshot.TotalPressure[0], Is.EqualTo(600f)); Assert.That(freshSnapshot.Temperature[0], Is.EqualTo(300f)); Assert.That(freshSnapshot.VoxelRoomMap[0], Is.EqualTo(7)); + Assert.That(freshSnapshot.VoxelSnapGroupMap[0], Is.EqualTo(-1)); Assert.That(freshSnapshot.Gases[0].GasId, Is.EqualTo(3)); Assert.That(freshSnapshot.Gases[0].Moles[0], Is.EqualTo(2f)); }); @@ -82,4 +85,4 @@ public void GetNetworkSnapshot_DeepCopiesChunkAndGasStorage() chunk.Release(); } } -} \ No newline at end of file +} diff --git a/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs b/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs index 441ee00..8e87cab 100644 --- a/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs +++ b/tests/Numos.Headless.Tests/HeadlessApplicationTests.cs @@ -79,6 +79,39 @@ public async Task UpdateConfig_VoxelSnappingFields_RoundTripThroughObservation() }); } + [Test] + public async Task Observe_VoxelSnapMembershipIsMachineReadable() + { + var run = await RunAsync( + Request("create", "createSimulation", + "\"dimensions\":{\"x\":2,\"y\":1,\"z\":1}"), + AddChunkRequest("chunk", 0, 0, 0), + Request("wake", "wakeRoom", + "\"position\":{\"x\":0,\"y\":0,\"z\":0},\"roomId\":1"), + Request("tick", "tick", "\"count\":1"), + Request("observe", "observe", "\"includeVoxels\":true"), + Request("exit", "exit")); + + JsonElement observation = FindResponse(run.Responses, "observe") + .GetProperty("observation"); + JsonElement chunk = observation.GetProperty("chunks").EnumerateArray().Single(); + JsonElement[] voxels = chunk.GetProperty("voxels").EnumerateArray().ToArray(); + bool[] snapped = voxels + .Select(voxel => voxel.GetProperty("isSnapped").GetBoolean()) + .ToArray(); + int[] groupIds = voxels + .Select(voxel => voxel.GetProperty("snapGroupId").GetInt32()) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(run.ExitCode, Is.Zero); + Assert.That(chunk.GetProperty("isAwake").GetBoolean(), Is.True); + Assert.That(snapped, Is.EqualTo(new[] { true, true })); + Assert.That(groupIds, Is.EqualTo(new[] { 0, 0 })); + }); + } + [Test] public async Task JsonlSession_MalformedJson_ReturnsStructuredErrorAndProcessesNextLine() { diff --git a/tests/Numos.SimDrawer.Tests/SimulationFrameBuilderTests.cs b/tests/Numos.SimDrawer.Tests/SimulationFrameBuilderTests.cs index ce66158..07b0af7 100644 --- a/tests/Numos.SimDrawer.Tests/SimulationFrameBuilderTests.cs +++ b/tests/Numos.SimDrawer.Tests/SimulationFrameBuilderTests.cs @@ -37,7 +37,9 @@ public void TemperatureVisualization_MinimalSnapshot_DoesNotRequirePressureOrGas var builder = CreateBuilder(); var snapshot = CreateSnapshot(new Int3(0, 0, 0), new Int3(1, 1, 1)); snapshot.Fields = - AtmosChunkSnapshotFields.Temperature | AtmosChunkSnapshotFields.VoxelClassification; + AtmosChunkSnapshotFields.Temperature | + AtmosChunkSnapshotFields.VoxelClassification | + AtmosChunkSnapshotFields.VoxelSnapping; snapshot.TotalPressure = []; snapshot.Gases = []; @@ -80,6 +82,186 @@ public void BuildSimulation_TwoAdjacentCells_OmitsTheirSharedFaces() }); } + [Test] + public void BuildSimulation_AwakeChunk_MapsDistinctAggregateGroupsToStableDistinctColors() + { + var builder = CreateBuilder(); + var snapshot = CreateSnapshot( + new Int3(0, 0, 0), + new Int3(5, 1, 1), + voxelSnapGroupMap: [0, 0, 2, 2, -1]); + + var firstChunk = builder.BuildSimulation( + [snapshot], + BuiltInVisualizationIds.Temperature, + 1).Chunks[snapshot.GridPosition]; + var secondChunk = builder.BuildSimulation( + [snapshot], + BuiltInVisualizationIds.Temperature, + 2).Chunks[snapshot.GridPosition]; + + Assert.Multiple(() => + { + Assert.That(firstChunk.GetCell(0).StateMarker, Is.EqualTo(VoxelStateMarker.Snapped)); + Assert.That(firstChunk.GetCell(1).StateMarker, Is.EqualTo(VoxelStateMarker.Snapped)); + Assert.That(firstChunk.GetCell(2).StateMarker, Is.EqualTo(VoxelStateMarker.Snapped)); + Assert.That(firstChunk.GetCell(3).StateMarker, Is.EqualTo(VoxelStateMarker.Snapped)); + Assert.That(firstChunk.GetCell(4).StateMarker, Is.EqualTo(VoxelStateMarker.None)); + Assert.That(firstChunk.GetCell(0).SnapGroupId, Is.EqualTo(0)); + Assert.That(firstChunk.GetCell(2).SnapGroupId, Is.EqualTo(2)); + Assert.That(firstChunk.GetCell(4).SnapGroupId, Is.EqualTo(-1)); + Assert.That(firstChunk.GetCell(1).StateMarkerColor, + Is.EqualTo(firstChunk.GetCell(0).StateMarkerColor)); + Assert.That(firstChunk.GetCell(3).StateMarkerColor, + Is.EqualTo(firstChunk.GetCell(2).StateMarkerColor)); + Assert.That(firstChunk.GetCell(2).StateMarkerColor, + Is.Not.EqualTo(firstChunk.GetCell(0).StateMarkerColor)); + Assert.That(secondChunk.GetCell(0).StateMarkerColor, + Is.EqualTo(firstChunk.GetCell(0).StateMarkerColor)); + Assert.That(secondChunk.GetCell(2).StateMarkerColor, + Is.EqualTo(firstChunk.GetCell(2).StateMarkerColor)); + }); + } + + [Test] + public void BuildSimulation_DistantAggregateRootsRemainDistinctAfterDisplayQuantization() + { + const int voxelCount = 622; + int[] groupMap = Enumerable.Repeat(-1, voxelCount).ToArray(); + groupMap[10] = 10; + groupMap[11] = 10; + groupMap[620] = 620; + groupMap[621] = 620; + var snapshot = CreateSnapshot( + new Int3(0, 0, 0), + new Int3(voxelCount, 1, 1), + voxelSnapGroupMap: groupMap); + + var chunk = CreateBuilder().BuildSimulation( + [snapshot], + BuiltInVisualizationIds.Temperature, + 1).Chunks[snapshot.GridPosition]; + + Assert.That( + QuantizeDisplayColor(chunk.GetCell(10).StateMarkerColor), + Is.Not.EqualTo(QuantizeDisplayColor(chunk.GetCell(620).StateMarkerColor)), + "Every local aggregate group must retain its own color after the viewer converts it to RGB bytes."); + } + + [Test] + public void BuildSimulation_SleepingChunk_OverridesSnapMapAndMarksEveryVoxel() + { + var builder = CreateBuilder(); + var snapshot = CreateSnapshot( + new Int3(0, 0, 0), + new Int3(3, 1, 1), + rooms: + [ + 1, + VoxelClassification.RoomSolid, + VoxelClassification.RoomVoid + ], + voxelSnapGroupMap: [0, -1, 0], + isAwake: false); + + var frame = builder.BuildSimulation( + [snapshot], + BuiltInVisualizationIds.Temperature, + 1); + var chunk = frame.Chunks[snapshot.GridPosition]; + var slice = builder.BuildChunkSlice(frame, chunk.Identity, SliceAxis.Z, 0); + + Assert.Multiple(() => + { + Assert.That(chunk.Cells.ToArray().Select(cell => cell.StateMarker), + Is.All.EqualTo(VoxelStateMarker.Sleeping)); + Assert.That(chunk.Cells.ToArray().Select(cell => cell.StateMarkerColor), + Is.All.EqualTo(new ColorRgba(1f, 0.25f, 0.2f))); + Assert.That(chunk.GetCell(0).SnapGroupId, Is.EqualTo(0), + "Sleeping presentation must retain diagnostic group identity while overriding its marker color."); + Assert.That(chunk.VisibleCellCount, Is.EqualTo(1)); + Assert.That(slice.Cells.Length, Is.EqualTo(3), + "Sleeping markers must remain present in a slice even where its visualization hides the voxel."); + Assert.That(slice.TryGetCell(1, 0, out var solid), Is.True); + Assert.That(solid.Voxel.IsVisible, Is.False); + Assert.That(solid.Voxel.StateMarker, Is.EqualTo(VoxelStateMarker.Sleeping)); + Assert.That(slice.TryGetCell(2, 0, out var voidCell), Is.True); + Assert.That(voidCell.Voxel.IsVisible, Is.False); + Assert.That(voidCell.Voxel.StateMarker, Is.EqualTo(VoxelStateMarker.Sleeping)); + }); + } + + [Test] + public void BuildSimulation_SnappedToSleepingMarker_ChangesStyleAndSliceRenderVersion() + { + var builder = CreateBuilder(); + var snappedSnapshot = CreateSnapshot( + new Int3(0, 0, 0), + new Int3(1, 1, 1), + voxelSnapGroupMap: [0]); + var sleepingSnapshot = snappedSnapshot; + sleepingSnapshot.IsAwake = false; + + var snappedFrame = builder.BuildSimulation( + [snappedSnapshot], + BuiltInVisualizationIds.Temperature, + 1); + var sleepingFrame = builder.BuildSimulation( + [sleepingSnapshot], + BuiltInVisualizationIds.Temperature, + 2); + var snappedChunk = snappedFrame.Chunks.Values.Single(); + var sleepingChunk = sleepingFrame.Chunks.Values.Single(); + var snappedSlice = builder.BuildChunkSlice(snappedFrame, snappedChunk.Identity, SliceAxis.Z, 0); + var sleepingSlice = builder.BuildChunkSlice(sleepingFrame, sleepingChunk.Identity, SliceAxis.Z, 0); + + Assert.Multiple(() => + { + Assert.That(snappedChunk.GetCell(0).StateMarker, Is.EqualTo(VoxelStateMarker.Snapped)); + Assert.That(sleepingChunk.GetCell(0).StateMarker, Is.EqualTo(VoxelStateMarker.Sleeping)); + Assert.That(sleepingChunk.TopologyVersion, Is.EqualTo(snappedChunk.TopologyVersion)); + Assert.That(sleepingChunk.StyleVersion, Is.Not.EqualTo(snappedChunk.StyleVersion)); + Assert.That(sleepingSlice.RenderVersion, Is.Not.EqualTo(snappedSlice.RenderVersion)); + }); + } + + [Test] + public void BuildSimulation_SnapGroupsMerge_ChangesStyleAndSliceRenderVersion() + { + var builder = CreateBuilder(); + var separateSnapshot = CreateSnapshot( + new Int3(0, 0, 0), + new Int3(4, 1, 1), + voxelSnapGroupMap: [0, 0, 2, 2]); + var mergedSnapshot = separateSnapshot; + mergedSnapshot.VoxelSnapGroupMap = [0, 0, 0, 0]; + + var separateFrame = builder.BuildSimulation( + [separateSnapshot], + BuiltInVisualizationIds.Temperature, + 1); + var mergedFrame = builder.BuildSimulation( + [mergedSnapshot], + BuiltInVisualizationIds.Temperature, + 2); + var separateChunk = separateFrame.Chunks.Values.Single(); + var mergedChunk = mergedFrame.Chunks.Values.Single(); + var separateSlice = builder.BuildChunkSlice(separateFrame, separateChunk.Identity, SliceAxis.Z, 0); + var mergedSlice = builder.BuildChunkSlice(mergedFrame, mergedChunk.Identity, SliceAxis.Z, 0); + + Assert.Multiple(() => + { + Assert.That(separateChunk.GetCell(2).StateMarkerColor, + Is.Not.EqualTo(separateChunk.GetCell(0).StateMarkerColor)); + Assert.That(mergedChunk.GetCell(2).StateMarkerColor, + Is.EqualTo(mergedChunk.GetCell(0).StateMarkerColor)); + Assert.That(mergedChunk.TopologyVersion, Is.EqualTo(separateChunk.TopologyVersion)); + Assert.That(mergedChunk.StyleVersion, Is.Not.EqualTo(separateChunk.StyleVersion), + "A group-only color change must invalidate the cached slice style."); + Assert.That(mergedSlice.RenderVersion, Is.Not.EqualTo(separateSlice.RenderVersion)); + }); + } + [Test] public void BuildSimulation_AdjacentChunks_RetainsSelfContainedBoundaryFacesForFocus() { @@ -466,6 +648,15 @@ private static SimulationFrameBuilder CreateBuilder() return new SimulationFrameBuilder(new AtmosConfig()); } + private static int QuantizeDisplayColor(ColorRgba color) + { + static byte ToByte(float value) => (byte)(Math.Clamp(value, 0f, 1f) * byte.MaxValue); + + return ToByte(color.R) << 16 | + ToByte(color.G) << 8 | + ToByte(color.B); + } + private static AtmosChunkSnapshot CreateSnapshot( Int3 position, Int3 dimensions, @@ -473,7 +664,9 @@ private static AtmosChunkSnapshot CreateSnapshot( float[]? temperature = null, int[]? rooms = null, GasSnapshot[]? gases = null, - AtmosChunkVersion version = default) + AtmosChunkVersion version = default, + int[]? voxelSnapGroupMap = null, + bool isAwake = true) { int count = dimensions.X * dimensions.Y * dimensions.Z; return new AtmosChunkSnapshot @@ -484,9 +677,11 @@ private static AtmosChunkSnapshot CreateSnapshot( TotalPressure = pressure ?? Enumerable.Repeat(100f, count).ToArray(), Temperature = temperature ?? Enumerable.Repeat(293.15f, count).ToArray(), VoxelRoomMap = rooms ?? Enumerable.Repeat(1, count).ToArray(), + VoxelSnapGroupMap = voxelSnapGroupMap ?? Enumerable.Repeat(-1, count).ToArray(), Gases = gases ?? [], ActiveAirCount = count, - ActiveGasCount = gases?.Length ?? 0 + ActiveGasCount = gases?.Length ?? 0, + IsAwake = isAwake }; } @@ -560,4 +755,4 @@ public VisualizationLegend CreateLegend(IReadOnlyCollection activeGasIds) return new VisualizationLegend("Topology", "", VisualizationLegendKind.Categories, []); } } -} \ No newline at end of file +} diff --git a/tests/Numos.SimDrawer.Tests/SliceProjectionTests.cs b/tests/Numos.SimDrawer.Tests/SliceProjectionTests.cs index 9741496..85327ee 100644 --- a/tests/Numos.SimDrawer.Tests/SliceProjectionTests.cs +++ b/tests/Numos.SimDrawer.Tests/SliceProjectionTests.cs @@ -130,7 +130,9 @@ private static AtmosChunkSnapshot CreateSnapshot(Int3 position, Int3 dimensions) TotalPressure = Enumerable.Repeat(100f, count).ToArray(), Temperature = Enumerable.Repeat(293.15f, count).ToArray(), VoxelRoomMap = Enumerable.Repeat(1, count).ToArray(), - Gases = [] + VoxelSnapGroupMap = Enumerable.Repeat(-1, count).ToArray(), + Gases = [], + IsAwake = true }; } -} \ No newline at end of file +} From b717b3b52b01c959de85aba991ddec2715a1cf52 Mon Sep 17 00:00:00 2001 From: VeritableCalamity <34698192+Veritable-Calamity@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:22:45 -0500 Subject: [PATCH 14/14] Add documentation for known cross-chunk transfer issues (KI-001), including analysis, reproduction steps, and proposed resolutions. Update references in README and technical documentation. --- README.md | 1 + docs/atmospherics_technical_documentation.md | 5 + docs/known_issues.md | 307 +++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 docs/known_issues.md diff --git a/README.md b/README.md index 8d138e2..14dd89d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ See `CONTRIBUTING.md` before contributing. ## Documentation Documentation for APIs and the project itself is available under `/docs`. +Tracked numerical and lifecycle limitations are documented in [Known Issues](docs/known_issues.md). ### Headless debugging diff --git a/docs/atmospherics_technical_documentation.md b/docs/atmospherics_technical_documentation.md index d689f5e..b404f4a 100644 --- a/docs/atmospherics_technical_documentation.md +++ b/docs/atmospherics_technical_documentation.md @@ -938,6 +938,11 @@ does not independently wake an explicitly slept chunk, and `SleepChunk` delibera snapshot for its batch of up to five fixed steps. Frequent direct ticks or updates with large chunk counts therefore generate array-allocation pressure. +5. **Cross-chunk bulk transfer has a long, non-terminating tail.** Local pressure propagation scales approximately +with distance squared, while every positive boundary diffusion attempt resets snap/sleep progress. Sub-ULP attempts +can continue revising both chunks indefinitely. The reproduction, analysis, and ranked resolution options are tracked +in [Known Issues: KI-001](known_issues.md#ki-001-cross-chunk-bulk-transfer-converges-pathologically-slowly). + --- ## 11. Porting Guidance diff --git a/docs/known_issues.md b/docs/known_issues.md new file mode 100644 index 0000000..cf21d7c --- /dev/null +++ b/docs/known_issues.md @@ -0,0 +1,307 @@ +# Known Issues + +This document tracks simulation defects and design limitations that need more investigation than a short code +comment can provide. Measurements are diagnostic baselines, not compatibility promises. + +## KI-001: Cross-chunk bulk transfer converges pathologically slowly + +**Status:** Open +**Impact:** High for gameplay responsiveness and simulation lifecycle +**Affected areas:** `AdvectionSolver`, `BoundaryFlowSolver`, progressive voxel snapping, automatic sleep + +Primary implementation references: + +- [`AtmosSolverMath.CalculateBulkPressureTransfer`](../src/Numos.CoreSim/Solvers/AtmosSolverMath.cs#L109) +- [`AdvectionSolver.CheckNeighbor`](../src/Numos.CoreSim/Solvers/AdvectionSolver.cs#L149) +- [`BoundaryFlowSolver.TransferSpecies`](../src/Numos.CoreSim/Solvers/BoundaryFlowSolver.cs#L218) +- [`AggregateVoxels`](../src/Numos.CoreSim/AggregateVoxels.cs) + +### Symptom + +Opening an equilibrated, pressurized chunk into an adjacent vacuum chunk can leave both chunks transferring gas for +thousands of atmospheric ticks. This is especially visible when the gas contains multiple species, because pressure +equalization and composition diffusion both remain active across the boundary. + +The current reproducible case is: + +- two adjacent `16 x 16 x 1` chunks with a fully open shared face; +- the source starts equilibrated at approximately `190.4 kPa` with `10,000 mol` O2 and `10,000 mol` N2; +- the target starts at `0 Pa`; +- production flow, diffusion, snapping, and sleep settings are used. + +Selected results from the current implementation are: + +| Transfer tick | Source mean pressure | Target mean pressure | Mean pressure gap | +|---:|---:|---:|---:| +| 100 | `158,187.64 Pa` | `32,232.55 Pa` | `125,955.09 Pa` | +| 500 | `121,557.36 Pa` | `68,862.81 Pa` | `52,694.55 Pa` | +| 1,000 | `104,349.19 Pa` | `86,070.97 Pa` | `18,278.22 Pa` | +| 2,000 | `96,212.35 Pa` | `94,207.79 Pa` | `2,004.56 Pa` | +| 3,200 | `95,227.20 Pa` | `95,192.95 Pa` | `34.26 Pa` | + +The difference between chunk mole totals is still about `5,248 mol` at tick 500, `1,718 mol` at tick 1,000, +`134 mol` at tick 2,000, `15.2 mol` at tick 2,500, and `0.57 mol` at tick 3,200. + +The pressure range eventually plateaus near float resolution, but the chunks still do not sleep: their sleep timers +remain at zero and their revisions continue increasing through at least tick `20,000`. + +This is not primarily extra resistance at the chunk seam. A `32 x 16 x 1` single chunk and two adjacent +`16 x 16 x 1` chunks follow nearly the same early curve: + +| Tick | Single-chunk half-to-half mole difference | Two-chunk mole difference | +|---:|---:|---:| +| 500 | about `5,480 mol` | about `5,248 mol` | +| 1,000 | about `1,851 mol` | about `1,718 mol` | +| 2,000 | about `177 mol` | about `134 mol` | + +The single chunk can then snap across the former midpoint and is asleep/uniform by tick 2,500. The two-chunk case +cannot form a cross-chunk snap aggregate and remains awake indefinitely. + +At the intended game integration rate of 15 atmospheric ticks per second, 3,215 ticks take about 214 seconds. The +library currently reports `AtmosSolverConstants.SimulationRate = 20 Hz`, at which the same tick count is about 161 +seconds. This rate mismatch must be resolved before calibrating any new per-tick coefficient. + +### Why it happens + +#### 1. Pressure propagation is a local diffusive relaxation + +For a large pressure difference, the current bulk request is: + +```text +requested pressure transfer = pressure delta * BulkFlowCoefficient * BulkFlowDamping + = pressure delta * 0.25 * 0.5 + = pressure delta * 0.125 +``` + +It is then capped by: + +```text +source pressure * MaxPressureTransferFractionPerNeighbor +``` + +which is `source pressure * 0.16` by default. Species diffusion is added separately. Every operation moves gas only +between immediately adjacent voxels, including the boundary operation. Filling a new chunk therefore requires gas to +travel from the old chunk's interior to the interface and then from the interface through the new chunk. + +This behaves like an explicit diffusion equation: relaxation time grows approximately with the square of the voxel +distance. Joining two 16-voxel-wide chunks creates a 32-voxel transport span, so a thousands-of-ticks tail is an +expected consequence of the present algorithm rather than just a slow boundary-face coefficient. + +#### 2. A boundary multiplier only accelerates the interface pair + +Increasing `BulkFlowCoefficient` alone has limited effect because the `0.16` per-neighbor cap soon dominates. +Increasing the shared per-neighbor cap also changes intra-chunk stability and can over-schedule a source with several +lower-pressure neighbors. Even an instantaneously equalized boundary pair is quickly starved unless the surrounding +interior voxels refill it faster. + +In the reproduction, the mean pressure delta at the literal boundary falls to about `6.28 kPa` by tick 100, +`2.18 kPa` by tick 500, `715 Pa` by tick 1,000, and `222 Pa` by tick 2,000. At those same samples the full-domain +pressure delta is still approximately `183 kPa`, `79 kPa`, `25.8 kPa`, and `1.92 kPa`. A mode triggered only by the +current boundary-face delta therefore switches off long before the interiors finish exchanging gas. + +The theoretical no-overshoot first-tick pair equalizer can move at most about `625 mol` through the full face, versus +about `281.25 mol` under the current combined bulk/diffusion request: only a `2.2x` opening-burst improvement. A sweep +from the effective `0.125` bulk fraction to `0.14` modestly improved the tick-1,000 mole difference from about +`1,718 mol` to `1,472 mol`, but it did not fix the sleep tail. An effective fraction of `0.16`, a `0.25` per-neighbor +cap, or very large diffusion coefficients produced severe oscillation in the pre-equilibration scenario. + +#### 3. Boundary diffusion has no terminal cutoff + +`MinimumPressureTransfer` applies to the bulk-pressure request, but not to species diffusion. A positive species +imbalance can therefore schedule a boundary transfer indefinitely. At float resolution, the requested move may no +longer produce a representable primary-state change, yet the boundary path can still treat the attempt as activity. + +After tick 6,000 in the reproduction, one tick still changes exactly 6 of 512 voxel mole values by a combined six +float ULPs, along with roughly 22 temperatures and 26 pressures. The interface requests are only about +`1.3e-6`–`2.0e-6 mol`, while one mole-value ULP near `39 mol` is about `3.81e-6 mol`. Sequential reverse events and +snap projection turn those rounded moves into a permanent quantized limit cycle: the summary state no longer +converges, but 128 snap-group IDs flip and both timers reset on every tick. + +This is a lifecycle defect as well as a performance issue: an asymptotic, non-material transfer should not keep a +chunk awake forever. + +#### 4. Every boundary transfer discards progressive snap progress + +A successful target wake resets its aggregate topology and sleep timer. The source is also kept awake for the next +boundary event. With mixed gases, counter-diffusion can make both chunks receive some species on every tick, so both +chunks reset continuously. + +At transfer tick 3,200 in the reproduction, each chunk reconstructs 128 two-voxel snap groups during finalization. +The next boundary pass resets them before they can merge further. Progressive snapping is consequently prevented +from ever spanning either chunk while any positive cross-boundary species imbalance remains. + +### Required behavior for a fix + +Any acceleration must retain the following properties: + +- conserve every gas species and sensible energy across non-void boundaries; +- remain finite and nonnegative in the float-backed materialized state; +- be deterministic across chunk registration and event order; +- avoid pressure overshoot and multi-neighbor source overdraw; +- respect inactive-room capacity backpressure without partially committing an edge; +- wake a sleeping neighbor when a real transfer becomes actionable; +- stop revising/resetting chunks when a request cannot change represented state; +- scale with the number of open boundary faces, so a full wall opening is faster than a one-voxel aperture; +- behave similarly when the same physical volume is represented as one chunk or several chunks; +- define coefficients against the intended 15 TPS atmospheric cadence, or express them in per-second terms. + +### Potential resolutions + +#### Resolution A: Make the boundary tail terminate correctly (required first) + +Treat a boundary edge as active only when the complete planned transfer produces a committed, representable change. +Do not reset aggregates, sleep timers, or revisions for a sub-ULP no-op. + +Add an explicit cross-boundary settled test using the same hybrid criteria as voxel snapping: + +- absolute/relative pressure correction; +- temperature correction; +- per-species mole-fraction correction; +- finite-state and phase-stability requirements. + +Once both sides satisfy those criteria, suppress the asymptotic tail and allow the chunks' verification windows to +advance. This should not be implemented by deleting trace gas or by applying a large `MinimumPressureTransfer`, since +that setting does not cover species diffusion and a large value creates pressure stiction elsewhere. + +This resolution fixes the never-sleep defect, but it does not make the initial decompression substantially faster. + +#### Resolution B: Preserve and use settled aggregates at the boundary (recommended foundation) + +When the source and target regions are already internally uniform or represented by established snap groups, treat +them as conservative reservoirs instead of injecting into one boundary voxel. Reduce each participating component to +species totals, sensible energy, and represented volume; transfer toward their joint pressure equilibrium; then +materialize the result uniformly within each component. + +The relaxation rate should depend on open interface area relative to component volume. That preserves the important +difference between opening an entire 16-voxel wall and opening a single door. + +This changes convergence from repeated local diffusion across the chunk diameter to a small number of aggregate +updates. It also aligns with the planned room/chunk macro-state work. It is the strongest solution for large settled +volumes, but requires careful handling of topology splits, partial openings, wake/materialization, phase stability, +trace species, and cross-chunk atomic commits. + +A practical implementation would: + +1. call `WakeVoxel` only when the receiving voxel/component is actually inactive; +2. mark an affected active aggregate dirty and let fingerprint validation split it, rather than globally resetting + every aggregate before validation; +3. gather boundary edges from one immutable state, pair them by source/target aggregate, and count open faces; +4. sum the existing per-face requests, then withdraw from and deposit into the complete aggregate reservoirs; +5. preflight and apply all species/energy changes as one deterministic two-phase transaction; +6. materialize with the same residual reconciliation used by `AggregateVoxels`, rather than independently rounding + `total / memberCount` into every voxel; +7. allow bit-identical vacuum voxels to establish an aggregate before their first incoming transfer. + +Using the current conductance with aggregate reservoirs is estimated to bring the reproduction to the current snap +scale in roughly 250–300 ticks, rather than more than 3,200. + +#### Resolution C: Add an optional high-delta aggregate relaxation mode + +Once Resolution B is conservative and deterministic, add a faster aggregate-to-aggregate mode with a smooth +threshold rather than a hard branch. An initial tuning target to test is: + +```text +Begin transition: 5,000 Pa component pressure gap +Full acceleration: 10,000 Pa component pressure gap +Full-face pressure half-life: 0.5 seconds +Maximum equilibrium fraction: 0.5 per atmospheric tick +``` + +The trigger must track the participating component pressure gap, not only the current boundary-voxel delta. Otherwise +it deactivates while most gas is still trapped in the far interiors. + +For two aggregate reservoirs, find the maximum conservative transfer `q*` that equalizes their post-transfer +pressures without crossing. Then apply: + +```text +q = relaxationFraction * q* +``` + +For equal-temperature reservoirs: + +```text +q* = (sourcePressure - targetPressure) * sourceCapacity * targetCapacity + / (sourceCapacity + targetCapacity) + +pressureCapacity = representedVolume / (R * temperature) +``` + +For a non-vacuum ideal-gas reservoir this is also `representedMoles / pressure`; the volume/temperature form remains +defined for an empty target. + +For mixed temperatures and heat capacities, remove gas in source mole proportions, carry source sensible energy, and +solve the post-mixing pressure equality with a deterministic bounded solve. Express the relaxation in physical time: + +```text +relaxationFraction = 1 - 2^(-deltaTime / halfLife) +``` + +A 0.5-second half-life gives approximately `0.0883` per tick at 15 TPS and `0.0670` per tick at 20 TPS. Scale the +rate by open-face area and aggregate capacity so a one-voxel aperture is slower than a fully open face. + +#### Resolution D: Use a boundary-specific voxel-pair equalizer as a limited interim measure + +Above a configured threshold, a two-voxel edge may move a fraction of its exact no-overshoot equalizing correction: + +```text +equalizing pressure transfer = 0.5 * (source pressure - target pressure) +``` + +Incoming gas must carry source composition and sensible energy. Diffusion must be included inside the same equilibrium +budget or suspended in this mode; adding it on top can overshoot. This requires boundary-specific limits, since +raising the shared per-neighbor cap changes every intra-chunk edge. + +This is cheap but is bounded to roughly the `2.2x` initial improvement measured above and leaves the `O(distance^2)` +interior tail intact. + +#### Resolution E: Run adaptive full-transport substeps as a diagnostic/interim option + +Several extra advection and boundary microsteps can accelerate both the interface and its interior refill path, but +CPU work rises approximately with the substep count. Boundary-only substeps mostly drain an already-starved boundary +layer and are not useful. Full substeps also need explicit semantics for thermodynamics cadence, custom callbacks, +revisions, deterministic chunk selection, and per-second rates. This exchanges game ticks for proportional work; it +should not be the preferred long-term optimization. + +#### Resolution F: Introduce a pressure-wave/global pressure solver + +The most physically complete redesign is to represent momentum/face velocity or solve pressure on the connected +domain with an accelerated method such as multigrid. This changes propagation from a purely diffusive process toward +a pressure-wave or projection model and removes the `O(distance^2)` relaxation characteristic. + +This is substantially more invasive than the other options and should be justified by gameplay and profiling needs. + +### Recommended implementation order + +1. Fix no-op transfer detection and add cross-boundary settled/sleep eligibility. +2. Add metrics for component and boundary pressure gaps, committed moles, aggregate resets, and time to sleep. +3. Preserve active aggregates during boundary work and batch existing per-face fluxes by aggregate pair. +4. Benchmark the exact two-chunk reproduction, full-face and one-voxel apertures, and the single-chunk control. +5. Add the physical-time high-delta aggregate relaxation only after the aggregate baseline is stable. +6. Use full transport substeps only as an interim experiment; retain the global/pressure-wave solve as a later redesign. + +Do not attempt to solve this solely by raising the global diffusion coefficient, the global per-neighbor transfer +fraction, or `MinimumPressureTransfer`. Those knobs affect unrelated scenarios, can introduce oscillation/overdraw, +and do not address the aggregate-reset and non-material-transfer lifecycle defects. + +### Acceptance and regression matrix + +A completed fix should include at least: + +- the exact `16 x 16 x 1` mixed-gas source-to-vacuum reproduction, sampled through equilibrium and sleep; +- one full-face opening and one single-voxel aperture, proving area-dependent throughput; +- one equivalent `32 x 16 x 1` single-chunk domain to measure partition sensitivity; +- pressure differences immediately below, at, and above the rapid-transfer threshold; +- equal and unequal temperature, unequal gas heat capacities, and opposing composition gradients; +- per-species mass and sensible-energy conservation using double-precision references; +- no negative/non-finite primary state and no float-overflow partial commits; +- deterministic results under reversed chunk registration and all six boundary directions; +- inactive-room capacity backpressure and later retry; +- topology changes while transfer is active; +- manual sleep preservation and automatic wake/resleep behavior; +- trace gas below the old per-voxel tracking threshold; +- proof that a settled boundary stops changing revisions and permits sleep; +- performance results reported in ticks and seconds at the intended atmospheric TPS. + +Proposed performance budgets are sleep within 300 transfer ticks for aggregate-aware flow using the existing +conductance, and within 150 ticks when the optional high-delta mode is enabled. These are design targets to validate, +not current guarantees.