@driftengine/chemistry 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +194 -0
- package/dist/conservation.d.ts +9 -0
- package/dist/conservation.js +38 -0
- package/dist/element/elements.d.ts +31 -0
- package/dist/element/elements.js +73 -0
- package/dist/element/matrix.d.ts +27 -0
- package/dist/element/matrix.js +80 -0
- package/dist/field/ambient.d.ts +49 -0
- package/dist/field/ambient.js +82 -0
- package/dist/field/atmosphere.d.ts +308 -0
- package/dist/field/atmosphere.js +1033 -0
- package/dist/ignition/criteria.d.ts +97 -0
- package/dist/ignition/criteria.js +135 -0
- package/dist/ignition/events.d.ts +56 -0
- package/dist/ignition/events.js +90 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +66 -0
- package/dist/library/biological.d.ts +6 -0
- package/dist/library/biological.js +229 -0
- package/dist/library/common.d.ts +26 -0
- package/dist/library/common.js +208 -0
- package/dist/library/food.d.ts +9 -0
- package/dist/library/food.js +272 -0
- package/dist/library/fuel.d.ts +19 -0
- package/dist/library/fuel.js +290 -0
- package/dist/library/library.d.ts +45 -0
- package/dist/library/library.js +29 -0
- package/dist/library/metal.d.ts +6 -0
- package/dist/library/metal.js +181 -0
- package/dist/library/mineral.d.ts +4 -0
- package/dist/library/mineral.js +177 -0
- package/dist/library/organic.d.ts +12 -0
- package/dist/library/organic.js +489 -0
- package/dist/library/polymer.d.ts +4 -0
- package/dist/library/polymer.js +237 -0
- package/dist/parcel/conduction.d.ts +20 -0
- package/dist/parcel/conduction.js +160 -0
- package/dist/parcel/shells.d.ts +49 -0
- package/dist/parcel/shells.js +73 -0
- package/dist/parcel/store.d.ts +395 -0
- package/dist/parcel/store.js +1152 -0
- package/dist/parcel/tier.d.ts +48 -0
- package/dist/parcel/tier.js +51 -0
- package/dist/present/audio.d.ts +41 -0
- package/dist/present/audio.js +50 -0
- package/dist/present/blackbody.d.ts +55 -0
- package/dist/present/blackbody.js +112 -0
- package/dist/present/flame.d.ts +34 -0
- package/dist/present/flame.js +103 -0
- package/dist/present/index.d.ts +25 -0
- package/dist/present/index.js +22 -0
- package/dist/present/install.d.ts +87 -0
- package/dist/present/install.js +157 -0
- package/dist/present/smoke.d.ts +63 -0
- package/dist/present/smoke.js +85 -0
- package/dist/present/surface.d.ts +40 -0
- package/dist/present/surface.js +76 -0
- package/dist/reaction/localise.d.ts +16 -0
- package/dist/reaction/localise.js +95 -0
- package/dist/reaction/rates.d.ts +59 -0
- package/dist/reaction/rates.js +101 -0
- package/dist/reaction/reaction.d.ts +178 -0
- package/dist/reaction/reaction.js +104 -0
- package/dist/reaction/registry.d.ts +47 -0
- package/dist/reaction/registry.js +215 -0
- package/dist/reaction/solver.d.ts +51 -0
- package/dist/reaction/solver.js +277 -0
- package/dist/species/registry.d.ts +26 -0
- package/dist/species/registry.js +97 -0
- package/dist/species/species.d.ts +48 -0
- package/dist/species/species.js +20 -0
- package/dist/species/standard.d.ts +30 -0
- package/dist/species/standard.js +436 -0
- package/dist/species/thermal.d.ts +18 -0
- package/dist/species/thermal.js +72 -0
- package/dist/substance/boiling.d.ts +29 -0
- package/dist/substance/boiling.js +54 -0
- package/dist/substance/composition.d.ts +34 -0
- package/dist/substance/composition.js +53 -0
- package/dist/substance/registry.d.ts +86 -0
- package/dist/substance/registry.js +187 -0
- package/dist/substance/substance.d.ts +94 -0
- package/dist/substance/substance.js +48 -0
- package/dist/testing/drift.d.ts +15 -0
- package/dist/testing/drift.js +29 -0
- package/dist/transport/contacts.d.ts +22 -0
- package/dist/transport/contacts.js +50 -0
- package/dist/transport/radiation.d.ts +101 -0
- package/dist/transport/radiation.js +199 -0
- package/dist/transport/world.d.ts +92 -0
- package/dist/transport/world.js +381 -0
- package/dist/world/fingerprint.d.ts +31 -0
- package/dist/world/fingerprint.js +70 -0
- package/package.json +100 -0
- package/src/conservation.ts +44 -0
- package/src/element/elements.ts +80 -0
- package/src/element/matrix.ts +89 -0
- package/src/field/ambient.ts +111 -0
- package/src/field/atmosphere.ts +1159 -0
- package/src/ignition/criteria.ts +177 -0
- package/src/ignition/events.ts +100 -0
- package/src/index.ts +213 -0
- package/src/library/biological.ts +252 -0
- package/src/library/common.ts +224 -0
- package/src/library/food.ts +302 -0
- package/src/library/fuel.ts +299 -0
- package/src/library/library.ts +62 -0
- package/src/library/metal.ts +186 -0
- package/src/library/mineral.ts +194 -0
- package/src/library/organic.ts +545 -0
- package/src/library/polymer.ts +243 -0
- package/src/parcel/conduction.ts +188 -0
- package/src/parcel/shells.ts +100 -0
- package/src/parcel/store.ts +1270 -0
- package/src/parcel/tier.ts +60 -0
- package/src/present/audio.ts +54 -0
- package/src/present/blackbody.ts +118 -0
- package/src/present/flame.ts +111 -0
- package/src/present/index.ts +25 -0
- package/src/present/install.ts +209 -0
- package/src/present/smoke.ts +139 -0
- package/src/present/surface.ts +107 -0
- package/src/reaction/localise.ts +126 -0
- package/src/reaction/rates.ts +153 -0
- package/src/reaction/reaction.ts +235 -0
- package/src/reaction/registry.ts +257 -0
- package/src/reaction/solver.ts +326 -0
- package/src/species/registry.ts +117 -0
- package/src/species/species.ts +53 -0
- package/src/species/standard.ts +449 -0
- package/src/species/thermal.ts +74 -0
- package/src/substance/boiling.ts +64 -0
- package/src/substance/composition.ts +81 -0
- package/src/substance/registry.ts +223 -0
- package/src/substance/substance.ts +148 -0
- package/src/testing/drift.ts +28 -0
- package/src/transport/contacts.ts +56 -0
- package/src/transport/radiation.ts +238 -0
- package/src/transport/world.ts +437 -0
- package/src/world/fingerprint.ts +106 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One atmosphere, sampled by everything that breathes, burns or smokes.
|
|
3
|
+
*
|
|
4
|
+
* `AGENTS.md` has a section called *One wind, sampled once*, whose argument is that a second wind
|
|
5
|
+
* is a scene that does not cohere. **The same argument applies with more force to the air**, which
|
|
6
|
+
* is what carries oxygen to a fire and smoke away from it: two disagreeing airs give a fire that is
|
|
7
|
+
* starved in one system and rich in another, and nobody can see why.
|
|
8
|
+
*
|
|
9
|
+
* **Sparse chunks of 8³ cells, and empty space costs nothing.** A chunk exists only where something
|
|
10
|
+
* has put matter into it; everywhere else reads from the far field, which is a constant. A read of
|
|
11
|
+
* empty air creates nothing, so a world with one campfire in it holds one chunk.
|
|
12
|
+
*
|
|
13
|
+
* **Masses rather than moles**, which is the same choice a parcel makes and for a better reason
|
|
14
|
+
* than consistency: it lets the gas-phase network run through **the same solver a shell does**.
|
|
15
|
+
* `§8.4`'s combustion is not a second implementation of `§7`'s substep-and-clamp — it is that one,
|
|
16
|
+
* given a cell instead of a shell.
|
|
17
|
+
*
|
|
18
|
+
* **What this is not, and `§1` says it in writing:** not computational fluid dynamics. There is no
|
|
19
|
+
* pressure projection and no incompressibility constraint. A plume rises, bends and spreads, and it
|
|
20
|
+
* does not curl into the vortex rings a real one makes.
|
|
21
|
+
*/
|
|
22
|
+
import { ELEMENT_COUNT } from '../element/elements.js';
|
|
23
|
+
import { PHASE_LIQUID } from '../species/species.js';
|
|
24
|
+
import { localiseReactions } from '../reaction/localise.js';
|
|
25
|
+
import { reactShell } from '../reaction/solver.js';
|
|
26
|
+
import { GAS_CONSTANT, STANDARD_AIR, STANDARD_FIELD_SPECIES, ambientCellMass, saturationVapourPressure, } from './ambient.js';
|
|
27
|
+
/** Cells along one edge of a chunk. */
|
|
28
|
+
export const CHUNK_CELLS = 8;
|
|
29
|
+
/** Metres along one edge of a chunk, at the default cell size. */
|
|
30
|
+
export const CHUNK_SIZE = 8;
|
|
31
|
+
/**
|
|
32
|
+
* Extinction per kilogram of soot per cubic metre, m²/kg.
|
|
33
|
+
*
|
|
34
|
+
* Smoke's mass-specific extinction coefficient, which is what turns a soot concentration into a
|
|
35
|
+
* visibility. The value is the one measured for flaming-fire smoke; smouldering smoke scatters
|
|
36
|
+
* rather than absorbs and runs lower.
|
|
37
|
+
*/
|
|
38
|
+
const SOOT_EXTINCTION = 8700;
|
|
39
|
+
/** Koschmieder's constant: the contrast threshold that defines "can no longer see it". */
|
|
40
|
+
const VISIBILITY_CONSTANT = 3.912;
|
|
41
|
+
/**
|
|
42
|
+
* The saturation vapour pressure table: 256 knots over 200-500 K.
|
|
43
|
+
*
|
|
44
|
+
* Below 200 K there is no liquid water to be in equilibrium with and above 500 K saturation is well
|
|
45
|
+
* past any pressure this model runs at, so both ends clamp rather than extrapolate. Linear over a
|
|
46
|
+
* curve whose logarithm changes by 6% per kelvin costs under a tenth of a percent at this spacing,
|
|
47
|
+
* which is inside the spread of the Magnus coefficients themselves.
|
|
48
|
+
*/
|
|
49
|
+
const SATURATION_MIN = 200;
|
|
50
|
+
const SATURATION_MAX = 500;
|
|
51
|
+
const SATURATION_KNOTS = 256;
|
|
52
|
+
const CELLS_PER_CHUNK = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
|
|
53
|
+
/** m/s². */
|
|
54
|
+
const GRAVITY = 9.80665;
|
|
55
|
+
/**
|
|
56
|
+
* How long a buoyant parcel accelerates before drag holds it, seconds.
|
|
57
|
+
*
|
|
58
|
+
* Half a second gives a cell at 800 K a rise of about 3 m/s, which is what a real fire plume does.
|
|
59
|
+
* A proper answer would come from an entrainment model; this is the one number standing in for one.
|
|
60
|
+
*/
|
|
61
|
+
const BUOYANT_TIME = 0.5;
|
|
62
|
+
/** m/s. Past this a plume is moving faster than anything this model resolves. */
|
|
63
|
+
const MAX_BUOYANT_SPEED = 12;
|
|
64
|
+
/** How fast a pressure difference across a face equalises, per second. */
|
|
65
|
+
const PRESSURE_RELAX = 8;
|
|
66
|
+
/**
|
|
67
|
+
* The most of a cell one face may move in one sub-step.
|
|
68
|
+
*
|
|
69
|
+
* **A tenth, and the number is a conservation constraint rather than a taste.** Faces are computed
|
|
70
|
+
* independently and summed into the delta, so a cell has three faces that can drain it by bulk flow
|
|
71
|
+
* and six that can drain it by diffusion. At the 0.4 this started at, those together could take
|
|
72
|
+
* more than the cell held — and the non-negativity clamp in the apply pass would quietly absorb the
|
|
73
|
+
* difference, which is mass created from nothing.
|
|
74
|
+
*
|
|
75
|
+
* It was found by an element-conservation assertion sitting at 8.6e-9 instead of at zero, which is
|
|
76
|
+
* exactly the class of bug that assertion exists for: too small to see, and not going away.
|
|
77
|
+
* 3 × 0.1 by bulk plus 6 × 0.1 by diffusion is 0.9, so the clamp is now a backstop that never fires.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_FACE_FRACTION = 0.1;
|
|
80
|
+
const MAX_TRANSPORT_SUBSTEPS = 16;
|
|
81
|
+
/** kg. Below this a cell is the outdoors as far as spreading is concerned. */
|
|
82
|
+
const AMBIENT_EPSILON = 1e-9;
|
|
83
|
+
/** J. The same idea for heat: a tenth of a joule in a cubic metre of air is not a plume. */
|
|
84
|
+
const ENTHALPY_EPSILON = 0.1;
|
|
85
|
+
export class AtmosphereField {
|
|
86
|
+
registry;
|
|
87
|
+
cellSize;
|
|
88
|
+
cellVolume;
|
|
89
|
+
ambient;
|
|
90
|
+
/** Global species indices this field tracks, in storage order. */
|
|
91
|
+
species;
|
|
92
|
+
/** kg per species per cell, then one enthalpy per cell, then soot and aerosol. */
|
|
93
|
+
chunks = [];
|
|
94
|
+
blocked = [];
|
|
95
|
+
coords = [];
|
|
96
|
+
byKey = new Map();
|
|
97
|
+
/** Each chunk's packed `key(cx, cy, cz)`, parallel to `chunks`. */
|
|
98
|
+
keys = [];
|
|
99
|
+
/**
|
|
100
|
+
* Chunk indices in **key order**, which is the order every sweep and every reduction walks.
|
|
101
|
+
*
|
|
102
|
+
* **`chunks` is in creation order and creation order is history.** Two runs of one world that
|
|
103
|
+
* realise their chunks in a different sequence — a consumer spawning two logs the other way round
|
|
104
|
+
* is enough — hold identical per-cell state in differently-ordered arrays, and every `+=` over
|
|
105
|
+
* them then lands in a different order. Floating-point addition is not associative, so the answer
|
|
106
|
+
* differs in the last bit, and `fingerprintChemistry` reports a divergence that did not happen.
|
|
107
|
+
*
|
|
108
|
+
* Worse than the report: the sweep accumulates into shared `deltas`, so the *state* diverges too
|
|
109
|
+
* and a stiff exponential rate law grows the difference. Measured before this existed: two logs
|
|
110
|
+
* eight metres apart, spawned in the other order, left **1,202 cells** differing after sixty
|
|
111
|
+
* ticks.
|
|
112
|
+
*
|
|
113
|
+
* `key` is a pure function of the chunk's coordinates, so this order is a fact about geometry and
|
|
114
|
+
* nothing else. Maintained by insertion at creation, which is rare; the sweeps pay one indirection
|
|
115
|
+
* per chunk, not per cell.
|
|
116
|
+
*/
|
|
117
|
+
order = [];
|
|
118
|
+
/** Scratch for one sweep round, so the walk does not observe chunks created during it. */
|
|
119
|
+
round;
|
|
120
|
+
/** Which sweep last visited each chunk, so a chunk created mid-sweep is swept exactly once. */
|
|
121
|
+
sweptAt = [];
|
|
122
|
+
pass = 0;
|
|
123
|
+
stride;
|
|
124
|
+
enthalpySlot;
|
|
125
|
+
sootSlot;
|
|
126
|
+
aerosolSlot;
|
|
127
|
+
/** Where `C(soot)` sits among the tracked species, or -1 where this field does not carry it. */
|
|
128
|
+
sootSpecies;
|
|
129
|
+
/** Slots of every carried species in the liquid phase: droplets, which is what an aerosol is. */
|
|
130
|
+
aerosolSpecies;
|
|
131
|
+
/** One cell of untouched air, filled once and copied into every new cell. */
|
|
132
|
+
maxChunks;
|
|
133
|
+
openness;
|
|
134
|
+
diffusivity;
|
|
135
|
+
/** Deltas for one step, so no cell sees a neighbour a previous cell already changed. */
|
|
136
|
+
deltas = [];
|
|
137
|
+
/** What has left through an open boundary: per species, then enthalpy, soot and aerosol. */
|
|
138
|
+
vented;
|
|
139
|
+
ventedEnthalpyTotal = 0;
|
|
140
|
+
ambientCell;
|
|
141
|
+
ambientEnthalpy;
|
|
142
|
+
ambientMass;
|
|
143
|
+
/**
|
|
144
|
+
* The ambient temperature **as `cellTemperature` computes it**, not as it was authored.
|
|
145
|
+
*
|
|
146
|
+
* The same arithmetic over the same doubles in the same order, so a cell that has been realised
|
|
147
|
+
* and one that has not report the identical number. See `temperatureAt`.
|
|
148
|
+
*/
|
|
149
|
+
ambientTemperatureExact;
|
|
150
|
+
/** `saturationVapourPressure` sampled once, so `exp` never runs on a query. */
|
|
151
|
+
saturationTable = new Float64Array(SATURATION_KNOTS);
|
|
152
|
+
network;
|
|
153
|
+
combustible;
|
|
154
|
+
molarMass;
|
|
155
|
+
/** J/(kg·K) at the standard state, one per tracked species. Resolved once. */
|
|
156
|
+
heatCapacity;
|
|
157
|
+
localOf;
|
|
158
|
+
constructor(registry, options = {}) {
|
|
159
|
+
this.registry = registry;
|
|
160
|
+
this.cellSize = options.cellSize ?? 1;
|
|
161
|
+
if (!(this.cellSize > 0))
|
|
162
|
+
throw new Error(`a cell is ${this.cellSize} m across`);
|
|
163
|
+
this.cellVolume = this.cellSize * this.cellSize * this.cellSize;
|
|
164
|
+
this.ambient = options.ambient ?? STANDARD_AIR;
|
|
165
|
+
const ids = options.species ?? STANDARD_FIELD_SPECIES;
|
|
166
|
+
this.species = new Int32Array(ids.length);
|
|
167
|
+
for (let i = 0; i < ids.length; i++) {
|
|
168
|
+
const at = registry.indexOf(ids[i]);
|
|
169
|
+
if (at < 0)
|
|
170
|
+
throw new Error(`the field names "${ids[i]}", which is not a registered species`);
|
|
171
|
+
this.species[i] = at;
|
|
172
|
+
}
|
|
173
|
+
this.localOf = new Int32Array(registry.count).fill(-1);
|
|
174
|
+
for (let i = 0; i < this.species.length; i++)
|
|
175
|
+
this.localOf[this.species[i]] = i;
|
|
176
|
+
this.molarMass = new Float64Array(this.species.length);
|
|
177
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
178
|
+
this.molarMass[i] = registry.molarMass(this.species[i]);
|
|
179
|
+
}
|
|
180
|
+
this.heatCapacity = new Float64Array(this.species.length);
|
|
181
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
182
|
+
this.heatCapacity[i] = registry.heatCapacityOf(this.species[i], 298.15);
|
|
183
|
+
}
|
|
184
|
+
this.enthalpySlot = this.species.length;
|
|
185
|
+
this.sootSlot = this.enthalpySlot + 1;
|
|
186
|
+
this.aerosolSlot = this.sootSlot + 1;
|
|
187
|
+
/* `localOf` is the species-index-to-slot map built just above, and `-1` where absent. */
|
|
188
|
+
const soot = registry.indexOf('C(soot)');
|
|
189
|
+
this.sootSpecies = soot >= 0 ? this.localOf[soot] : -1;
|
|
190
|
+
this.stride = this.aerosolSlot + 1;
|
|
191
|
+
/*
|
|
192
|
+
* **A liquid in a gas cell is a droplet, and a cloud of droplets is an aerosol.** Resolved from
|
|
193
|
+
* the phase rather than from a list of ids, because the field would otherwise have to name
|
|
194
|
+
* `tar(l)` — a species `library/organic` registers and every other consumer has never heard of —
|
|
195
|
+
* and a core file naming a library's species is the direction this package does not import in.
|
|
196
|
+
*
|
|
197
|
+
* So the rule is the physical one and it costs nothing to state: any carried species whose phase
|
|
198
|
+
* is liquid is suspended matter. Wood tar condensing out of a smouldering plume is what this was
|
|
199
|
+
* built for; a consumer whose own library condenses something else gets the same treatment with
|
|
200
|
+
* no entry added anywhere.
|
|
201
|
+
*
|
|
202
|
+
* **Soot is not here and must not be**, though it is equally suspended: it is the *black* half
|
|
203
|
+
* of smoke and this is the pale half, and `§17` colours a plume from the ratio between them.
|
|
204
|
+
*/
|
|
205
|
+
const aerosol = [];
|
|
206
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
207
|
+
if (registry.phaseOf(this.species[i]) === PHASE_LIQUID)
|
|
208
|
+
aerosol.push(i);
|
|
209
|
+
}
|
|
210
|
+
this.aerosolSpecies = Int32Array.from(aerosol);
|
|
211
|
+
this.maxChunks = options.maxChunks ?? 512;
|
|
212
|
+
this.openness = options.openness ?? 1.5;
|
|
213
|
+
this.diffusivity = options.diffusivity ?? 0.02;
|
|
214
|
+
this.vented = new Float64Array(this.species.length + 3);
|
|
215
|
+
this.round = new Int32Array(this.maxChunks);
|
|
216
|
+
/* The tracked species that can burn, resolved once. */
|
|
217
|
+
const combustible = [];
|
|
218
|
+
for (const id of ['CH4', 'CO', 'H2', 'C2H4', 'C2H6', 'NH3', 'HCN']) {
|
|
219
|
+
const at = registry.indexOf(id);
|
|
220
|
+
if (at >= 0 && this.slotOf(at) >= 0)
|
|
221
|
+
combustible.push(at);
|
|
222
|
+
}
|
|
223
|
+
this.combustible = Int32Array.from(combustible);
|
|
224
|
+
this.ambientCell = new Float64Array(this.species.length);
|
|
225
|
+
ambientCellMass(registry, this.species, this.ambient, this.cellVolume, this.ambientCell);
|
|
226
|
+
/*
|
|
227
|
+
* Enthalpy is measured from the standard state, so ambient air at 293.15 K sits a little below
|
|
228
|
+
* zero. Computed once here rather than per cell.
|
|
229
|
+
*
|
|
230
|
+
* **`this.heatCapacity`, which is `cp` at 298.15 — the same array `cellTemperature` divides
|
|
231
|
+
* by — rather than `cp` at the ambient temperature.** The two disagree for any species whose
|
|
232
|
+
* `cp` rises with temperature, and the field's own model is a constant `cp` at the datum: a cell
|
|
233
|
+
* *is* an enthalpy divided by that array. Building the ambient with a different `cp` made a
|
|
234
|
+
* freshly filled cell read back 8e-5 K away from the temperature it was filled at — small, and
|
|
235
|
+
* an inconsistency between how a number is written and how it is read is the kind that grows.
|
|
236
|
+
*
|
|
237
|
+
* The linear-`cp` refinement is the *parcel's* model, where `sensibleEnthalpy` integrates it
|
|
238
|
+
* properly. The field trades that for a cell temperature that is one divide.
|
|
239
|
+
*/
|
|
240
|
+
let enthalpy = 0;
|
|
241
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
242
|
+
enthalpy +=
|
|
243
|
+
this.ambientCell[i] *
|
|
244
|
+
this.heatCapacity[i] *
|
|
245
|
+
(this.ambient.temperature - 298.15);
|
|
246
|
+
}
|
|
247
|
+
this.ambientEnthalpy = enthalpy;
|
|
248
|
+
let ambientMass = 0;
|
|
249
|
+
for (let i = 0; i < this.species.length; i++)
|
|
250
|
+
ambientMass += this.ambientCell[i];
|
|
251
|
+
this.ambientMass = ambientMass;
|
|
252
|
+
let ambientCapacity = 0;
|
|
253
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
254
|
+
ambientCapacity += this.ambientCell[i] * this.heatCapacity[i];
|
|
255
|
+
}
|
|
256
|
+
this.ambientTemperatureExact =
|
|
257
|
+
ambientCapacity > 0
|
|
258
|
+
? 298.15 + this.ambientEnthalpy / ambientCapacity
|
|
259
|
+
: this.ambient.temperature;
|
|
260
|
+
for (let i = 0; i < SATURATION_KNOTS; i++) {
|
|
261
|
+
const temperature = SATURATION_MIN + ((SATURATION_MAX - SATURATION_MIN) * i) / (SATURATION_KNOTS - 1);
|
|
262
|
+
this.saturationTable[i] = saturationVapourPressure(temperature);
|
|
263
|
+
}
|
|
264
|
+
const reactionIds = options.reactionIds ?? [];
|
|
265
|
+
this.network =
|
|
266
|
+
reactionIds.length === 0
|
|
267
|
+
? null
|
|
268
|
+
: localiseReactions(registry, options.reactions, reactionIds, this.species, 'the field');
|
|
269
|
+
}
|
|
270
|
+
get chunkCount() {
|
|
271
|
+
return this.chunks.length;
|
|
272
|
+
}
|
|
273
|
+
/** How many species this field tracks. */
|
|
274
|
+
get speciesCount() {
|
|
275
|
+
return this.species.length;
|
|
276
|
+
}
|
|
277
|
+
/** The local slot a global species occupies, or -1. */
|
|
278
|
+
slotOf(species) {
|
|
279
|
+
return species >= 0 && species < this.localOf.length ? this.localOf[species] : -1;
|
|
280
|
+
}
|
|
281
|
+
key(cx, cy, cz) {
|
|
282
|
+
/* Offset so negatives pack, then base-2048 in three digits. Supports ±1024 chunks, which at
|
|
283
|
+
eight metres is ±8 km — past any scene this engine draws. */
|
|
284
|
+
return ((cx + 1024) * 2048 + (cy + 1024)) * 2048 + (cz + 1024);
|
|
285
|
+
}
|
|
286
|
+
/** The chunk holding a point, creating it if `create`. `-1` where none exists. */
|
|
287
|
+
chunkAt(x, y, z, create) {
|
|
288
|
+
const span = CHUNK_CELLS * this.cellSize;
|
|
289
|
+
const cx = Math.floor(x / span);
|
|
290
|
+
const cy = Math.floor(y / span);
|
|
291
|
+
const cz = Math.floor(z / span);
|
|
292
|
+
const key = this.key(cx, cy, cz);
|
|
293
|
+
const found = this.byKey.get(key);
|
|
294
|
+
if (found !== undefined)
|
|
295
|
+
return found;
|
|
296
|
+
if (!create || this.chunks.length >= this.maxChunks)
|
|
297
|
+
return -1;
|
|
298
|
+
return this.createChunk(cx, cy, cz);
|
|
299
|
+
}
|
|
300
|
+
createChunk(cx, cy, cz) {
|
|
301
|
+
const key = this.key(cx, cy, cz);
|
|
302
|
+
const cells = CELLS_PER_CHUNK;
|
|
303
|
+
const data = new Float64Array(cells * this.stride);
|
|
304
|
+
for (let cell = 0; cell < cells; cell++) {
|
|
305
|
+
const base = cell * this.stride;
|
|
306
|
+
for (let i = 0; i < this.species.length; i++)
|
|
307
|
+
data[base + i] = this.ambientCell[i];
|
|
308
|
+
data[base + this.enthalpySlot] = this.ambientEnthalpy;
|
|
309
|
+
}
|
|
310
|
+
/* A new chunk is air drawn in from the far field, so the ledger records it as having come
|
|
311
|
+
from outside. That is what makes `totalMass() + ventedTotalMass()` invariant under chunk
|
|
312
|
+
creation as well as under transport. */
|
|
313
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
314
|
+
this.vented[i] = this.vented[i] - this.ambientCell[i] * cells;
|
|
315
|
+
}
|
|
316
|
+
this.ventedEnthalpyTotal -= this.ambientEnthalpy * cells;
|
|
317
|
+
const index = this.chunks.length;
|
|
318
|
+
this.chunks.push(data);
|
|
319
|
+
this.deltas.push(new Float64Array(cells * this.stride));
|
|
320
|
+
this.blocked.push(new Uint8Array(cells));
|
|
321
|
+
this.coords.push(Int32Array.from([cx, cy, cz]));
|
|
322
|
+
this.keys.push(key);
|
|
323
|
+
this.sweptAt.push(-1);
|
|
324
|
+
this.byKey.set(key, index);
|
|
325
|
+
/* Into `order` at its sorted position, so the walk is geometry rather than history. See the
|
|
326
|
+
field on the class. A binary search and a splice, on a path that runs once per chunk. */
|
|
327
|
+
let low = 0;
|
|
328
|
+
let high = this.order.length;
|
|
329
|
+
while (low < high) {
|
|
330
|
+
const mid = (low + high) >> 1;
|
|
331
|
+
if (this.keys[this.order[mid]] < key)
|
|
332
|
+
low = mid + 1;
|
|
333
|
+
else
|
|
334
|
+
high = mid;
|
|
335
|
+
}
|
|
336
|
+
this.order.splice(low, 0, index);
|
|
337
|
+
return index;
|
|
338
|
+
}
|
|
339
|
+
/** Index of the cell containing a point within its chunk. */
|
|
340
|
+
cellIn(x, y, z) {
|
|
341
|
+
const lx = ((Math.floor(x / this.cellSize) % CHUNK_CELLS) + CHUNK_CELLS) % CHUNK_CELLS;
|
|
342
|
+
const ly = ((Math.floor(y / this.cellSize) % CHUNK_CELLS) + CHUNK_CELLS) % CHUNK_CELLS;
|
|
343
|
+
const lz = ((Math.floor(z / this.cellSize) % CHUNK_CELLS) + CHUNK_CELLS) % CHUNK_CELLS;
|
|
344
|
+
return (lx * CHUNK_CELLS + ly) * CHUNK_CELLS + lz;
|
|
345
|
+
}
|
|
346
|
+
/** The base offset of the cell containing a point, or `-1` where no chunk exists. */
|
|
347
|
+
baseAt(x, y, z, create) {
|
|
348
|
+
const chunk = this.chunkAt(x, y, z, create);
|
|
349
|
+
if (chunk < 0)
|
|
350
|
+
return -1;
|
|
351
|
+
return this.cellIn(x, y, z) * this.stride;
|
|
352
|
+
}
|
|
353
|
+
dataAt(x, y, z, create) {
|
|
354
|
+
const chunk = this.chunkAt(x, y, z, create);
|
|
355
|
+
return chunk < 0 ? null : this.chunks[chunk];
|
|
356
|
+
}
|
|
357
|
+
/** Add (or with a negative value, remove) mass of one species at a point. */
|
|
358
|
+
addSpecies(x, y, z, species, kilograms) {
|
|
359
|
+
const slot = this.slotOf(species);
|
|
360
|
+
if (slot < 0) {
|
|
361
|
+
throw new Error(`the field does not carry "${this.registry.idOf(species)}", so it cannot hold any`);
|
|
362
|
+
}
|
|
363
|
+
const data = this.dataAt(x, y, z, true);
|
|
364
|
+
if (data === null)
|
|
365
|
+
return;
|
|
366
|
+
const base = this.baseAt(x, y, z, true);
|
|
367
|
+
const next = data[base + slot] + kilograms;
|
|
368
|
+
data[base + slot] = next > 0 ? next : 0;
|
|
369
|
+
}
|
|
370
|
+
addHeat(x, y, z, joules) {
|
|
371
|
+
const data = this.dataAt(x, y, z, true);
|
|
372
|
+
if (data === null)
|
|
373
|
+
return;
|
|
374
|
+
const base = this.baseAt(x, y, z, true);
|
|
375
|
+
data[base + this.enthalpySlot] = data[base + this.enthalpySlot] + joules;
|
|
376
|
+
}
|
|
377
|
+
addSoot(x, y, z, kilograms) {
|
|
378
|
+
const data = this.dataAt(x, y, z, true);
|
|
379
|
+
if (data === null)
|
|
380
|
+
return;
|
|
381
|
+
const base = this.baseAt(x, y, z, true);
|
|
382
|
+
const next = data[base + this.sootSlot] + kilograms;
|
|
383
|
+
data[base + this.sootSlot] = next > 0 ? next : 0;
|
|
384
|
+
}
|
|
385
|
+
addAerosol(x, y, z, kilograms) {
|
|
386
|
+
const data = this.dataAt(x, y, z, true);
|
|
387
|
+
if (data === null)
|
|
388
|
+
return;
|
|
389
|
+
const base = this.baseAt(x, y, z, true);
|
|
390
|
+
const next = data[base + this.aerosolSlot] + kilograms;
|
|
391
|
+
data[base + this.aerosolSlot] = next > 0 ? next : 0;
|
|
392
|
+
}
|
|
393
|
+
/** Mark a cell as occupied by geometry. A blocked face transports nothing. */
|
|
394
|
+
setBlocked(x, y, z, blocked) {
|
|
395
|
+
const chunk = this.chunkAt(x, y, z, true);
|
|
396
|
+
if (chunk < 0)
|
|
397
|
+
return;
|
|
398
|
+
this.blocked[chunk][this.cellIn(x, y, z)] = blocked ? 1 : 0;
|
|
399
|
+
}
|
|
400
|
+
isBlocked(x, y, z) {
|
|
401
|
+
const chunk = this.chunkAt(x, y, z, false);
|
|
402
|
+
if (chunk < 0)
|
|
403
|
+
return false;
|
|
404
|
+
return this.blocked[chunk][this.cellIn(x, y, z)] === 1;
|
|
405
|
+
}
|
|
406
|
+
speciesMassAt(x, y, z, species) {
|
|
407
|
+
const slot = this.slotOf(species);
|
|
408
|
+
if (slot < 0)
|
|
409
|
+
return 0;
|
|
410
|
+
const data = this.dataAt(x, y, z, false);
|
|
411
|
+
if (data === null)
|
|
412
|
+
return this.ambientCell[slot];
|
|
413
|
+
return data[this.cellIn(x, y, z) * this.stride + slot];
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Kilograms of soot in the cell: the `C(soot)` its own chemistry made, plus anything added.
|
|
417
|
+
*
|
|
418
|
+
* **Two sources on purpose.** Soot a fire produced is the `C(soot)` *species*, which keeps its
|
|
419
|
+
* carbon inside `elementTotals` — a channel carrying mass outside the ledger would make the most
|
|
420
|
+
* valuable assertion in the track quietly wrong every time something smoked. The channel is for a
|
|
421
|
+
* consumer who wants smoke without chemistry behind it: a smoke grenade, a chimney, a dust cloud.
|
|
422
|
+
*/
|
|
423
|
+
sootAt(x, y, z) {
|
|
424
|
+
const data = this.dataAt(x, y, z, false);
|
|
425
|
+
if (data === null)
|
|
426
|
+
return 0;
|
|
427
|
+
const cell = this.cellIn(x, y, z) * this.stride;
|
|
428
|
+
const added = data[cell + this.sootSlot];
|
|
429
|
+
const slot = this.sootSpecies;
|
|
430
|
+
return slot < 0 ? added : added + data[cell + slot];
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Kilograms of suspended droplets in the cell — the pale half of smoke.
|
|
434
|
+
*
|
|
435
|
+
* Soot is near-black and a droplet is near-white, so the *ratio* between them is what colours a
|
|
436
|
+
* plume: `§17` reads it, and a wet fire steams white where a rich one smokes black with neither
|
|
437
|
+
* being chosen. Symmetric with `sootAt`, and for the same two reasons.
|
|
438
|
+
*
|
|
439
|
+
* **Two sources, exactly as soot has.** Anything the chemistry condensed is a *species* — wood tar
|
|
440
|
+
* coming out of a cooling plume is the case this was built for — so its mass stays inside
|
|
441
|
+
* `elementTotals` and the most valuable assertion in the track keeps covering it. The channel
|
|
442
|
+
* `addAerosol` writes is for a consumer who wants steam without a boiler behind it: a kettle
|
|
443
|
+
* dressed for a scene, a waterfall's spray, a breath on a cold morning.
|
|
444
|
+
*/
|
|
445
|
+
aerosolAt(x, y, z) {
|
|
446
|
+
const data = this.dataAt(x, y, z, false);
|
|
447
|
+
if (data === null)
|
|
448
|
+
return 0;
|
|
449
|
+
const cell = this.cellIn(x, y, z) * this.stride;
|
|
450
|
+
let total = data[cell + this.aerosolSlot];
|
|
451
|
+
for (let i = 0; i < this.aerosolSpecies.length; i++) {
|
|
452
|
+
total += data[cell + this.aerosolSpecies[i]];
|
|
453
|
+
}
|
|
454
|
+
return total;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The upward speed this cell's own lightness gives it, m/s.
|
|
458
|
+
*
|
|
459
|
+
* **The field's own number rather than a second one.** `§17` gives a smoke particle its velocity
|
|
460
|
+
* from "the cell's advected plus buoyant velocity", and this is the buoyant half — the same
|
|
461
|
+
* function transport uses, so a particle rises at the speed the gas carrying it actually rises at
|
|
462
|
+
* rather than at a rate chosen to look right.
|
|
463
|
+
*/
|
|
464
|
+
riseAt(x, y, z) {
|
|
465
|
+
const data = this.dataAt(x, y, z, false);
|
|
466
|
+
if (data === null)
|
|
467
|
+
return 0;
|
|
468
|
+
return this.buoyantSpeed(data, this.cellIn(x, y, z) * this.stride);
|
|
469
|
+
}
|
|
470
|
+
/** Total moles of gas in the cell containing a point. */
|
|
471
|
+
molesAt(x, y, z) {
|
|
472
|
+
const data = this.dataAt(x, y, z, false);
|
|
473
|
+
let total = 0;
|
|
474
|
+
if (data === null) {
|
|
475
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
476
|
+
total += this.ambientCell[i] / this.molarMass[i];
|
|
477
|
+
}
|
|
478
|
+
return total;
|
|
479
|
+
}
|
|
480
|
+
const base = this.cellIn(x, y, z) * this.stride;
|
|
481
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
482
|
+
total += data[base + i] / this.molarMass[i];
|
|
483
|
+
}
|
|
484
|
+
return total;
|
|
485
|
+
}
|
|
486
|
+
/** Joules held by the cell containing a point. */
|
|
487
|
+
enthalpyAt(x, y, z) {
|
|
488
|
+
const data = this.dataAt(x, y, z, false);
|
|
489
|
+
if (data === null)
|
|
490
|
+
return this.ambientEnthalpy;
|
|
491
|
+
return data[this.cellIn(x, y, z) * this.stride + this.enthalpySlot];
|
|
492
|
+
}
|
|
493
|
+
/** kg of gas in the cell containing a point. */
|
|
494
|
+
massAt(x, y, z) {
|
|
495
|
+
const data = this.dataAt(x, y, z, false);
|
|
496
|
+
let total = 0;
|
|
497
|
+
if (data === null) {
|
|
498
|
+
for (let i = 0; i < this.species.length; i++)
|
|
499
|
+
total += this.ambientCell[i];
|
|
500
|
+
return total;
|
|
501
|
+
}
|
|
502
|
+
const base = this.cellIn(x, y, z) * this.stride;
|
|
503
|
+
for (let i = 0; i < this.species.length; i++)
|
|
504
|
+
total += data[base + i];
|
|
505
|
+
return total;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* K, from the cell's enthalpy and the heat capacity of what is in it.
|
|
509
|
+
*
|
|
510
|
+
* A gas mixture has no phase plateau, so this is a division rather than the curve inversion a
|
|
511
|
+
* parcel needs — and it is evaluated at the standard state's heat capacity, which is where the
|
|
512
|
+
* mixture's `cp` is anchored.
|
|
513
|
+
*/
|
|
514
|
+
/**
|
|
515
|
+
* K of the cell containing a point.
|
|
516
|
+
*
|
|
517
|
+
* **`ambientTemperatureExact` rather than `this.ambient.temperature`, and the difference is a
|
|
518
|
+
* determinism bug rather than a rounding nicety.** A cell nobody has touched is not stored; one a
|
|
519
|
+
* neighbour realised is. Returning the *authored* 293.15 for the first and the *derived*
|
|
520
|
+
* 293.15000000000003 for the second made a cell's reported temperature depend on whether some
|
|
521
|
+
* other parcel had happened to create the chunk first — which put an order dependence into
|
|
522
|
+
* convection, and from there into whether a log ignites on tick N or tick N+1.
|
|
523
|
+
*
|
|
524
|
+
* Found by `world/fingerprint.test.ts`: two logs spawned the other way round diverged in the
|
|
525
|
+
* eleventh digit on their very first tick, and nothing about them was different.
|
|
526
|
+
*/
|
|
527
|
+
temperatureAt(x, y, z) {
|
|
528
|
+
const data = this.dataAt(x, y, z, false);
|
|
529
|
+
if (data === null)
|
|
530
|
+
return this.ambientTemperatureExact;
|
|
531
|
+
return this.cellTemperature(data, this.cellIn(x, y, z) * this.stride);
|
|
532
|
+
}
|
|
533
|
+
cellTemperature(data, base) {
|
|
534
|
+
let capacity = 0;
|
|
535
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
536
|
+
capacity += data[base + i] * this.heatCapacity[i];
|
|
537
|
+
}
|
|
538
|
+
if (!(capacity > 0))
|
|
539
|
+
return this.ambientTemperatureExact;
|
|
540
|
+
return 298.15 + data[base + this.enthalpySlot] / capacity;
|
|
541
|
+
}
|
|
542
|
+
/** Volume fraction of one species, which for a gas is its mole fraction. */
|
|
543
|
+
volumeFractionAt(x, y, z, species) {
|
|
544
|
+
const slot = this.slotOf(species);
|
|
545
|
+
if (slot < 0)
|
|
546
|
+
return 0;
|
|
547
|
+
const total = this.molesAt(x, y, z);
|
|
548
|
+
if (!(total > 0))
|
|
549
|
+
return 0;
|
|
550
|
+
return this.speciesMassAt(x, y, z, species) / this.molarMass[slot] / total;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Volume fraction of the cell that is gas which can burn.
|
|
554
|
+
*
|
|
555
|
+
* What a flammability limit is checked against. Below the lower limit there is not enough fuel to
|
|
556
|
+
* carry a flame; above the upper there is not enough air, which is why a fuel-soaked rag smokes
|
|
557
|
+
* rather than flames until it thins out.
|
|
558
|
+
*/
|
|
559
|
+
fuelFractionAt(x, y, z) {
|
|
560
|
+
let total = 0;
|
|
561
|
+
for (const species of this.combustible)
|
|
562
|
+
total += this.volumeFractionAt(x, y, z, species);
|
|
563
|
+
return total;
|
|
564
|
+
}
|
|
565
|
+
oxygenFractionAt(x, y, z) {
|
|
566
|
+
return this.volumeFractionAt(x, y, z, this.registry.indexOf('O2'));
|
|
567
|
+
}
|
|
568
|
+
/** Parts per million by volume, which is the reading a game acts on. */
|
|
569
|
+
concentrationAt(x, y, z, species) {
|
|
570
|
+
return this.volumeFractionAt(x, y, z, species) * 1e6;
|
|
571
|
+
}
|
|
572
|
+
/** kg/m³. */
|
|
573
|
+
densityAt(x, y, z) {
|
|
574
|
+
return this.massAt(x, y, z) / this.cellVolume;
|
|
575
|
+
}
|
|
576
|
+
/** Pa, by the ideal gas law over what the cell holds at the temperature it is. */
|
|
577
|
+
pressureAt(x, y, z) {
|
|
578
|
+
return (this.molesAt(x, y, z) * GAS_CONSTANT * this.temperatureAt(x, y, z)) / this.cellVolume;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* How thick the smoke is, as an extinction coefficient in inverse metres.
|
|
582
|
+
*
|
|
583
|
+
* **The quantity `visibilityAt` is derived from**, and the one a consumer attenuating a light
|
|
584
|
+
* along a ray actually needs: metres of visibility is one answer to it and `exp(−σ·d)` is another.
|
|
585
|
+
* Exporting only the first would have every consumer inverting Koschmieder to get back here.
|
|
586
|
+
*/
|
|
587
|
+
smokeDensityAt(x, y, z) {
|
|
588
|
+
return (this.sootAt(x, y, z) / this.cellVolume) * SOOT_EXTINCTION;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* How far you can see, metres.
|
|
592
|
+
*
|
|
593
|
+
* Koschmieder over the soot's extinction. Clamped at ten kilometres, past which the answer stops
|
|
594
|
+
* being about the smoke.
|
|
595
|
+
*/
|
|
596
|
+
visibilityAt(x, y, z) {
|
|
597
|
+
const extinction = this.smokeDensityAt(x, y, z);
|
|
598
|
+
if (!(extinction > 0))
|
|
599
|
+
return 10000;
|
|
600
|
+
return Math.min(10000, VISIBILITY_CONSTANT / extinction);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Relative humidity, 0 to 1: the water vapour present against what this air could hold.
|
|
604
|
+
*
|
|
605
|
+
* **The reading that decides whether anything dries.** `§8.1`'s evaporation is driven by the
|
|
606
|
+
* vapour pressure *deficit*, so washing dries in wind and nothing dries in fog — and the same
|
|
607
|
+
* number is why a fire dries a room out while removing no water at all: warm air could hold more,
|
|
608
|
+
* so the same vapour is a smaller fraction of saturation.
|
|
609
|
+
*
|
|
610
|
+
* **Saturation is tabulated**, per `§7`'s rule, because it is `exp` of a rational function of
|
|
611
|
+
* temperature and this is a query a script may make every frame. 256 knots over 200-500 K, where
|
|
612
|
+
* linear interpolation is under a tenth of a percent.
|
|
613
|
+
*/
|
|
614
|
+
humidityAt(x, y, z) {
|
|
615
|
+
const vapour = this.registry.indexOf('H2O(g)');
|
|
616
|
+
if (vapour < 0)
|
|
617
|
+
return 0;
|
|
618
|
+
const partial = this.volumeFractionAt(x, y, z, vapour) * this.pressureAt(x, y, z);
|
|
619
|
+
const saturation = this.saturation(this.temperatureAt(x, y, z));
|
|
620
|
+
return saturation > 0 ? partial / saturation : 0;
|
|
621
|
+
}
|
|
622
|
+
/** Pa, off the table `SATURATION_KNOTS` describes. Clamped at both ends rather than extrapolated. */
|
|
623
|
+
saturation(temperature) {
|
|
624
|
+
if (temperature <= SATURATION_MIN)
|
|
625
|
+
return this.saturationTable[0];
|
|
626
|
+
if (temperature >= SATURATION_MAX)
|
|
627
|
+
return this.saturationTable[SATURATION_KNOTS - 1];
|
|
628
|
+
const position = ((temperature - SATURATION_MIN) / (SATURATION_MAX - SATURATION_MIN)) * (SATURATION_KNOTS - 1);
|
|
629
|
+
const low = Math.floor(position);
|
|
630
|
+
const at = position - low;
|
|
631
|
+
return (this.saturationTable[low] * (1 - at) +
|
|
632
|
+
this.saturationTable[low + 1] * at);
|
|
633
|
+
}
|
|
634
|
+
/** Kilograms of one species above what the ambient would have held. Diagnostics and tests. */
|
|
635
|
+
totalAdded(species) {
|
|
636
|
+
const slot = this.slotOf(species);
|
|
637
|
+
if (slot < 0)
|
|
638
|
+
return 0;
|
|
639
|
+
const cells = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
|
|
640
|
+
let total = 0;
|
|
641
|
+
for (let o = 0; o < this.order.length; o++) {
|
|
642
|
+
const data = this.chunks[this.order[o]];
|
|
643
|
+
for (let cell = 0; cell < cells; cell++) {
|
|
644
|
+
total += data[cell * this.stride + slot] - this.ambientCell[slot];
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return total;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Advance the field one step: expansion, buoyancy, wind, diffusion and the far field.
|
|
651
|
+
*
|
|
652
|
+
* **Every transfer is flux-form and conservative** — computed once for a face and applied to both
|
|
653
|
+
* sides — which is a departure from `§9` worth recording. The design chose semi-Lagrangian
|
|
654
|
+
* advection for unconditional stability; semi-Lagrangian is **not conservative**, and
|
|
655
|
+
* conservation is the invariant this whole track is asserted on. Upwind flux has a CFL limit
|
|
656
|
+
* instead, and a CFL limit is handled here the same way stiffness is handled in the reaction
|
|
657
|
+
* solver: by sub-stepping. That is the trade taken deliberately.
|
|
658
|
+
*
|
|
659
|
+
* **Deltas are accumulated and applied afterwards**, so no cell sees a neighbour that an earlier
|
|
660
|
+
* cell in the sweep already changed. A Gauss-Seidel sweep would give an answer that depended on
|
|
661
|
+
* chunk allocation order, which is a determinism hole with no symptom until two runs allocate
|
|
662
|
+
* differently.
|
|
663
|
+
*
|
|
664
|
+
* `windX` and `windZ` come from the one `WindField`, sampled once per frame by the caller.
|
|
665
|
+
*/
|
|
666
|
+
step(dt, windX = 0, windZ = 0) {
|
|
667
|
+
if (!(dt > 0) || this.chunks.length === 0)
|
|
668
|
+
return;
|
|
669
|
+
/* The CFL limit over wind and the fastest a buoyant cell can rise. */
|
|
670
|
+
const fastest = Math.max(Math.abs(windX), Math.abs(windZ), MAX_BUOYANT_SPEED);
|
|
671
|
+
const substeps = Math.min(MAX_TRANSPORT_SUBSTEPS, Math.max(1, Math.ceil((fastest * dt) / (this.cellSize * MAX_FACE_FRACTION))));
|
|
672
|
+
const step = dt / substeps;
|
|
673
|
+
for (let pass = 0; pass < substeps; pass++)
|
|
674
|
+
this.sweep(step, windX, windZ);
|
|
675
|
+
this.react(dt);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Run the gas-phase network in every live cell.
|
|
679
|
+
*
|
|
680
|
+
* After transport rather than before, so a cell burns the mixture it actually ended the step
|
|
681
|
+
* with — which is what makes a flame follow the fuel rather than lag a step behind it.
|
|
682
|
+
*/
|
|
683
|
+
react(dt) {
|
|
684
|
+
const network = this.network;
|
|
685
|
+
if (network === null)
|
|
686
|
+
return;
|
|
687
|
+
/* In key order, like every other full walk. A cell's reaction reads and writes only itself, so
|
|
688
|
+
this one is order-independent already — but a second convention here is a second thing to get
|
|
689
|
+
wrong, and the cost is one indirection per chunk. */
|
|
690
|
+
for (let o = 0; o < this.order.length; o++) {
|
|
691
|
+
const chunk = this.order[o];
|
|
692
|
+
const data = this.chunks[chunk];
|
|
693
|
+
const blocked = this.blocked[chunk];
|
|
694
|
+
for (let cell = 0; cell < CELLS_PER_CHUNK; cell++) {
|
|
695
|
+
if (blocked[cell] === 1)
|
|
696
|
+
continue;
|
|
697
|
+
const base = cell * this.stride;
|
|
698
|
+
let mass = 0;
|
|
699
|
+
for (let i = 0; i < this.species.length; i++)
|
|
700
|
+
mass += data[base + i];
|
|
701
|
+
if (!(mass > 0))
|
|
702
|
+
continue;
|
|
703
|
+
reactShell(data, base, data, base + this.enthalpySlot, network, mass, dt);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/** The upward speed a cell's own lightness gives it, m/s. */
|
|
708
|
+
buoyantSpeed(data, base) {
|
|
709
|
+
let mass = 0;
|
|
710
|
+
for (let i = 0; i < this.species.length; i++)
|
|
711
|
+
mass += data[base + i];
|
|
712
|
+
const density = mass / this.cellVolume;
|
|
713
|
+
const ambientDensity = this.ambientMass / this.cellVolume;
|
|
714
|
+
if (!(ambientDensity > 0))
|
|
715
|
+
return 0;
|
|
716
|
+
const speed = GRAVITY * ((ambientDensity - density) / ambientDensity) * BUOYANT_TIME;
|
|
717
|
+
return speed > MAX_BUOYANT_SPEED
|
|
718
|
+
? MAX_BUOYANT_SPEED
|
|
719
|
+
: speed < -MAX_BUOYANT_SPEED
|
|
720
|
+
? -MAX_BUOYANT_SPEED
|
|
721
|
+
: speed;
|
|
722
|
+
}
|
|
723
|
+
/** Resolve a world cell to a packed chunk-and-cell, or -1. */
|
|
724
|
+
resolve(wx, wy, wz, create) {
|
|
725
|
+
const cx = Math.floor(wx / CHUNK_CELLS);
|
|
726
|
+
const cy = Math.floor(wy / CHUNK_CELLS);
|
|
727
|
+
const cz = Math.floor(wz / CHUNK_CELLS);
|
|
728
|
+
const key = this.key(cx, cy, cz);
|
|
729
|
+
let chunk = this.byKey.get(key);
|
|
730
|
+
if (chunk === undefined) {
|
|
731
|
+
if (!create || this.chunks.length >= this.maxChunks)
|
|
732
|
+
return -1;
|
|
733
|
+
chunk = this.createChunk(cx, cy, cz);
|
|
734
|
+
}
|
|
735
|
+
const lx = wx - cx * CHUNK_CELLS;
|
|
736
|
+
const ly = wy - cy * CHUNK_CELLS;
|
|
737
|
+
const lz = wz - cz * CHUNK_CELLS;
|
|
738
|
+
return chunk * CELLS_PER_CHUNK + (lx * CHUNK_CELLS + ly) * CHUNK_CELLS + lz;
|
|
739
|
+
}
|
|
740
|
+
sweep(dt, windX, windZ) {
|
|
741
|
+
for (const delta of this.deltas)
|
|
742
|
+
delta.fill(0);
|
|
743
|
+
const diffusion = Math.min(MAX_FACE_FRACTION, (this.diffusivity * dt) / (this.cellSize * this.cellSize));
|
|
744
|
+
const vent = Math.min(0.5, this.openness * dt);
|
|
745
|
+
const expansion = Math.min(MAX_FACE_FRACTION, PRESSURE_RELAX * dt);
|
|
746
|
+
/*
|
|
747
|
+
* **In rounds over `order`, which is key order, rather than along `chunks`, which is creation
|
|
748
|
+
* order.** A face may create the chunk beyond it, and a chunk created this sweep is swept in the
|
|
749
|
+
* same pass — correct, because it starts as ambient and can only receive until it is. Walking
|
|
750
|
+
* `chunks.length` afresh gave that for free and paid for it in determinism: the visit order was
|
|
751
|
+
* then the allocation order, so two runs that realised their chunks differently accumulated into
|
|
752
|
+
* the shared `deltas` in a different sequence and diverged in the last bit. See `order`.
|
|
753
|
+
*
|
|
754
|
+
* A round is snapshotted into `round` before it is walked, so chunks created during it are not
|
|
755
|
+
* observed mid-walk; the loop repeats until a round creates nothing. Both halves are then facts
|
|
756
|
+
* about geometry: which chunks a round contains, and the order it visits them in.
|
|
757
|
+
*/
|
|
758
|
+
this.pass++;
|
|
759
|
+
for (;;) {
|
|
760
|
+
let count = 0;
|
|
761
|
+
for (let o = 0; o < this.order.length; o++) {
|
|
762
|
+
const chunk = this.order[o];
|
|
763
|
+
if (this.sweptAt[chunk] !== this.pass)
|
|
764
|
+
this.round[count++] = chunk;
|
|
765
|
+
}
|
|
766
|
+
if (count === 0)
|
|
767
|
+
break;
|
|
768
|
+
for (let r = 0; r < count; r++) {
|
|
769
|
+
const chunk = this.round[r];
|
|
770
|
+
this.sweptAt[chunk] = this.pass;
|
|
771
|
+
this.sweepChunk(chunk, dt, windX, windZ, diffusion, vent, expansion);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/*
|
|
775
|
+
* The apply pass, per chunk and independent, so its order cannot matter: a cell's next value is
|
|
776
|
+
* its own plus its own delta. Along `chunks` rather than `order` for that reason, and because
|
|
777
|
+
* the whole array is one linear walk.
|
|
778
|
+
*/
|
|
779
|
+
for (let chunk = 0; chunk < this.chunks.length; chunk++) {
|
|
780
|
+
const data = this.chunks[chunk];
|
|
781
|
+
const delta = this.deltas[chunk];
|
|
782
|
+
for (let i = 0; i < data.length; i++) {
|
|
783
|
+
const next = data[i] + delta[i];
|
|
784
|
+
data[i] = next;
|
|
785
|
+
}
|
|
786
|
+
/* Clamp only what cannot be negative; enthalpy legitimately can. */
|
|
787
|
+
for (let cell = 0; cell < CELLS_PER_CHUNK; cell++) {
|
|
788
|
+
const base = cell * this.stride;
|
|
789
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
790
|
+
if (data[base + i] < 0)
|
|
791
|
+
data[base + i] = 0;
|
|
792
|
+
}
|
|
793
|
+
if (data[base + this.sootSlot] < 0)
|
|
794
|
+
data[base + this.sootSlot] = 0;
|
|
795
|
+
if (data[base + this.aerosolSlot] < 0)
|
|
796
|
+
data[base + this.aerosolSlot] = 0;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/** One chunk's faces, for `sweep`. Every transfer lands in `deltas` and is applied afterwards. */
|
|
801
|
+
sweepChunk(chunk, dt, windX, windZ, diffusion, vent, expansion) {
|
|
802
|
+
{
|
|
803
|
+
const data = this.chunks[chunk];
|
|
804
|
+
const blocked = this.blocked[chunk];
|
|
805
|
+
const coords = this.coords[chunk];
|
|
806
|
+
for (let cell = 0; cell < CELLS_PER_CHUNK; cell++) {
|
|
807
|
+
if (blocked[cell] === 1)
|
|
808
|
+
continue;
|
|
809
|
+
const base = cell * this.stride;
|
|
810
|
+
const lx = (cell / (CHUNK_CELLS * CHUNK_CELLS)) | 0;
|
|
811
|
+
const ly = ((cell / CHUNK_CELLS) | 0) % CHUNK_CELLS;
|
|
812
|
+
const lz = cell % CHUNK_CELLS;
|
|
813
|
+
const wx = coords[0] * CHUNK_CELLS + lx;
|
|
814
|
+
const wy = coords[1] * CHUNK_CELLS + ly;
|
|
815
|
+
const wz = coords[2] * CHUNK_CELLS + lz;
|
|
816
|
+
const rise = this.buoyantSpeed(data, base);
|
|
817
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
818
|
+
const velocity = axis === 0 ? windX : axis === 1 ? rise : windZ;
|
|
819
|
+
const other = this.resolve(wx + (axis === 0 ? 1 : 0), wy + (axis === 1 ? 1 : 0), wz + (axis === 2 ? 1 : 0),
|
|
820
|
+
/*
|
|
821
|
+
* New ground is claimed only where something is being pushed onto it — a positive
|
|
822
|
+
* velocity **and** a cell that has something to carry. Without the second half the
|
|
823
|
+
* `+y` face of every boundary cell reaches upward on every sweep, whether or not
|
|
824
|
+
* anything is rising, and the live set grows a tower of ambient air to its cap.
|
|
825
|
+
*/
|
|
826
|
+
velocity > 0 && this.departsFromAmbient(data, base));
|
|
827
|
+
if (other < 0) {
|
|
828
|
+
this.ventFace(chunk, cell, base, vent);
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
const otherChunk = (other / CELLS_PER_CHUNK) | 0;
|
|
832
|
+
const otherCell = other - otherChunk * CELLS_PER_CHUNK;
|
|
833
|
+
if (this.blocked[otherChunk][otherCell] === 1)
|
|
834
|
+
continue;
|
|
835
|
+
this.face(chunk, base, otherChunk, otherCell * this.stride, velocity, dt, diffusion, expansion);
|
|
836
|
+
}
|
|
837
|
+
/* The three negative faces are somebody else's positive face, except where that somebody
|
|
838
|
+
does not exist — in which case this cell is on the boundary and vents. */
|
|
839
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
840
|
+
const back = this.resolve(wx - (axis === 0 ? 1 : 0), wy - (axis === 1 ? 1 : 0), wz - (axis === 2 ? 1 : 0), false);
|
|
841
|
+
if (back < 0)
|
|
842
|
+
this.ventFace(chunk, cell, base, vent);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Whether this cell holds anything the far field would not.
|
|
849
|
+
*
|
|
850
|
+
* What bounds the live set. A cell indistinguishable from the outdoors has nothing to spread, so
|
|
851
|
+
* it never claims a neighbour — which is why a world with one campfire holds the chunks around
|
|
852
|
+
* that fire rather than a column of air to the sky.
|
|
853
|
+
*/
|
|
854
|
+
departsFromAmbient(data, base) {
|
|
855
|
+
if (data[base + this.sootSlot] > AMBIENT_EPSILON)
|
|
856
|
+
return true;
|
|
857
|
+
if (data[base + this.aerosolSlot] > AMBIENT_EPSILON)
|
|
858
|
+
return true;
|
|
859
|
+
if (Math.abs(data[base + this.enthalpySlot] - this.ambientEnthalpy) > ENTHALPY_EPSILON) {
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
863
|
+
if (Math.abs(data[base + i] - this.ambientCell[i]) > AMBIENT_EPSILON) {
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
/** One internal face: bulk transfer by wind, buoyancy and pressure, plus diffusion. */
|
|
870
|
+
face(chunkA, baseA, chunkB, baseB, velocity, dt, diffusion, expansion) {
|
|
871
|
+
const dataA = this.chunks[chunkA];
|
|
872
|
+
const dataB = this.chunks[chunkB];
|
|
873
|
+
const deltaA = this.deltas[chunkA];
|
|
874
|
+
const deltaB = this.deltas[chunkB];
|
|
875
|
+
let molesA = 0;
|
|
876
|
+
let molesB = 0;
|
|
877
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
878
|
+
molesA += dataA[baseA + i] / this.molarMass[i];
|
|
879
|
+
molesB += dataB[baseB + i] / this.molarMass[i];
|
|
880
|
+
}
|
|
881
|
+
/*
|
|
882
|
+
* **Pressure is `n·T`, not `n`**, and getting that wrong is what a first attempt at this did.
|
|
883
|
+
* Heating a cell does not change how many moles it holds, so equalising *molar density* leaves
|
|
884
|
+
* a hot cell exactly where it was: nothing expands, its density never falls, and buoyancy —
|
|
885
|
+
* which reads density — never lifts it. The plume simply does not happen. Pressure is what
|
|
886
|
+
* actually differs across that face, and it is what pushes.
|
|
887
|
+
*/
|
|
888
|
+
const pressureA = molesA * this.cellTemperature(dataA, baseA);
|
|
889
|
+
const pressureB = molesB * this.cellTemperature(dataB, baseB);
|
|
890
|
+
/* Wind and buoyancy as a Courant fraction, plus the share of its excess the higher-pressure
|
|
891
|
+
cell pushes across. Both are "move a fraction of the donor's contents", so they add. */
|
|
892
|
+
const largest = Math.max(pressureA, pressureB);
|
|
893
|
+
const pressure = largest > 0 ? (expansion * (pressureA - pressureB)) / (2 * largest) : 0;
|
|
894
|
+
let fraction = (velocity * dt) / this.cellSize + pressure;
|
|
895
|
+
if (fraction > MAX_FACE_FRACTION)
|
|
896
|
+
fraction = MAX_FACE_FRACTION;
|
|
897
|
+
if (fraction < -MAX_FACE_FRACTION)
|
|
898
|
+
fraction = -MAX_FACE_FRACTION;
|
|
899
|
+
if (fraction !== 0) {
|
|
900
|
+
const fromA = fraction > 0;
|
|
901
|
+
const source = fromA ? dataA : dataB;
|
|
902
|
+
const sourceBase = fromA ? baseA : baseB;
|
|
903
|
+
const share = fromA ? fraction : -fraction;
|
|
904
|
+
for (let i = 0; i < this.stride; i++) {
|
|
905
|
+
const moved = source[sourceBase + i] * share;
|
|
906
|
+
if (moved === 0)
|
|
907
|
+
continue;
|
|
908
|
+
if (fromA) {
|
|
909
|
+
deltaA[baseA + i] = deltaA[baseA + i] - moved;
|
|
910
|
+
deltaB[baseB + i] = deltaB[baseB + i] + moved;
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
deltaB[baseB + i] = deltaB[baseB + i] - moved;
|
|
914
|
+
deltaA[baseA + i] = deltaA[baseA + i] + moved;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (diffusion > 0) {
|
|
919
|
+
for (let i = 0; i < this.stride; i++) {
|
|
920
|
+
const moved = (dataA[baseA + i] - dataB[baseB + i]) * diffusion;
|
|
921
|
+
if (moved === 0)
|
|
922
|
+
continue;
|
|
923
|
+
deltaA[baseA + i] = deltaA[baseA + i] - moved;
|
|
924
|
+
deltaB[baseB + i] = deltaB[baseB + i] + moved;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/** A face onto the far field: relax toward ambient and book what crossed. */
|
|
929
|
+
ventFace(chunk, cell, base, vent) {
|
|
930
|
+
void cell;
|
|
931
|
+
if (!(vent > 0))
|
|
932
|
+
return;
|
|
933
|
+
const data = this.chunks[chunk];
|
|
934
|
+
const delta = this.deltas[chunk];
|
|
935
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
936
|
+
const moved = (data[base + i] - this.ambientCell[i]) * vent;
|
|
937
|
+
delta[base + i] = delta[base + i] - moved;
|
|
938
|
+
this.vented[i] = this.vented[i] + moved;
|
|
939
|
+
}
|
|
940
|
+
const heat = (data[base + this.enthalpySlot] - this.ambientEnthalpy) * vent;
|
|
941
|
+
delta[base + this.enthalpySlot] = delta[base + this.enthalpySlot] - heat;
|
|
942
|
+
this.ventedEnthalpyTotal += heat;
|
|
943
|
+
for (const slot of [this.sootSlot, this.aerosolSlot]) {
|
|
944
|
+
const moved = data[base + slot] * vent;
|
|
945
|
+
delta[base + slot] = delta[base + slot] - moved;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/** Moles of each element the field holds, over every live chunk. Writes into `out`. */
|
|
949
|
+
elementTotals(out) {
|
|
950
|
+
if (out.length !== ELEMENT_COUNT) {
|
|
951
|
+
throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
|
|
952
|
+
}
|
|
953
|
+
out.fill(0);
|
|
954
|
+
const cells = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
|
|
955
|
+
/* **In key order**, because this is a floating-point reduction and a reduction's answer depends
|
|
956
|
+
on the order it adds. See `order`: along `chunks` it depended on which chunk was realised
|
|
957
|
+
first, and `fingerprintChemistry` hashes exactly this vector. */
|
|
958
|
+
for (let o = 0; o < this.order.length; o++) {
|
|
959
|
+
const data = this.chunks[this.order[o]];
|
|
960
|
+
for (let cell = 0; cell < cells; cell++) {
|
|
961
|
+
const base = cell * this.stride;
|
|
962
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
963
|
+
const kilograms = data[base + i];
|
|
964
|
+
if (kilograms === 0)
|
|
965
|
+
continue;
|
|
966
|
+
this.registry.elements.addScaledRow(this.species[i], kilograms / this.molarMass[i], out);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/** Total gas mass across every live chunk, kg. */
|
|
972
|
+
totalMass() {
|
|
973
|
+
const cells = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
|
|
974
|
+
let total = 0;
|
|
975
|
+
for (let o = 0; o < this.order.length; o++) {
|
|
976
|
+
const data = this.chunks[this.order[o]];
|
|
977
|
+
for (let cell = 0; cell < cells; cell++) {
|
|
978
|
+
const base = cell * this.stride;
|
|
979
|
+
for (let i = 0; i < this.species.length; i++)
|
|
980
|
+
total += data[base + i];
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return total;
|
|
984
|
+
}
|
|
985
|
+
/** Kilograms of one species that have left through an open boundary. */
|
|
986
|
+
ventedMass(species) {
|
|
987
|
+
const slot = this.slotOf(species);
|
|
988
|
+
return slot < 0 ? 0 : this.vented[slot];
|
|
989
|
+
}
|
|
990
|
+
/** Joules that have left through an open boundary. */
|
|
991
|
+
ventedEnthalpy() {
|
|
992
|
+
return this.ventedEnthalpyTotal;
|
|
993
|
+
}
|
|
994
|
+
/** Kilograms that have crossed the far-field boundary in total, in either direction. */
|
|
995
|
+
ventedTotalMass() {
|
|
996
|
+
let total = 0;
|
|
997
|
+
for (let i = 0; i < this.species.length; i++)
|
|
998
|
+
total += this.vented[i];
|
|
999
|
+
return total;
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Moles of each element that have crossed the far-field boundary. Writes into `out`.
|
|
1003
|
+
*
|
|
1004
|
+
* Live chunks alone do not balance, and the reason is not transport: **creating a chunk draws a
|
|
1005
|
+
* chunk of air in from outside**, which is matter appearing in the live set. The ledger records
|
|
1006
|
+
* it, so `elementTotals + ventedElementTotals` is invariant under chunk creation as well as under
|
|
1007
|
+
* everything else — and a conservation assertion over a growing plume is possible at all.
|
|
1008
|
+
*/
|
|
1009
|
+
ventedElementTotals(out) {
|
|
1010
|
+
if (out.length !== ELEMENT_COUNT) {
|
|
1011
|
+
throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
|
|
1012
|
+
}
|
|
1013
|
+
out.fill(0);
|
|
1014
|
+
for (let i = 0; i < this.species.length; i++) {
|
|
1015
|
+
const kilograms = this.vented[i];
|
|
1016
|
+
if (kilograms === 0)
|
|
1017
|
+
continue;
|
|
1018
|
+
this.registry.elements.addScaledRow(this.species[i], kilograms / this.molarMass[i], out);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/** Total enthalpy across every live chunk, J. */
|
|
1022
|
+
totalEnthalpy() {
|
|
1023
|
+
const cells = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
|
|
1024
|
+
let total = 0;
|
|
1025
|
+
for (let o = 0; o < this.order.length; o++) {
|
|
1026
|
+
const data = this.chunks[this.order[o]];
|
|
1027
|
+
for (let cell = 0; cell < cells; cell++) {
|
|
1028
|
+
total += data[cell * this.stride + this.enthalpySlot];
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return total;
|
|
1032
|
+
}
|
|
1033
|
+
}
|