@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.
Files changed (142) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +194 -0
  4. package/dist/conservation.d.ts +9 -0
  5. package/dist/conservation.js +38 -0
  6. package/dist/element/elements.d.ts +31 -0
  7. package/dist/element/elements.js +73 -0
  8. package/dist/element/matrix.d.ts +27 -0
  9. package/dist/element/matrix.js +80 -0
  10. package/dist/field/ambient.d.ts +49 -0
  11. package/dist/field/ambient.js +82 -0
  12. package/dist/field/atmosphere.d.ts +308 -0
  13. package/dist/field/atmosphere.js +1033 -0
  14. package/dist/ignition/criteria.d.ts +97 -0
  15. package/dist/ignition/criteria.js +135 -0
  16. package/dist/ignition/events.d.ts +56 -0
  17. package/dist/ignition/events.js +90 -0
  18. package/dist/index.d.ts +84 -0
  19. package/dist/index.js +66 -0
  20. package/dist/library/biological.d.ts +6 -0
  21. package/dist/library/biological.js +229 -0
  22. package/dist/library/common.d.ts +26 -0
  23. package/dist/library/common.js +208 -0
  24. package/dist/library/food.d.ts +9 -0
  25. package/dist/library/food.js +272 -0
  26. package/dist/library/fuel.d.ts +19 -0
  27. package/dist/library/fuel.js +290 -0
  28. package/dist/library/library.d.ts +45 -0
  29. package/dist/library/library.js +29 -0
  30. package/dist/library/metal.d.ts +6 -0
  31. package/dist/library/metal.js +181 -0
  32. package/dist/library/mineral.d.ts +4 -0
  33. package/dist/library/mineral.js +177 -0
  34. package/dist/library/organic.d.ts +12 -0
  35. package/dist/library/organic.js +489 -0
  36. package/dist/library/polymer.d.ts +4 -0
  37. package/dist/library/polymer.js +237 -0
  38. package/dist/parcel/conduction.d.ts +20 -0
  39. package/dist/parcel/conduction.js +160 -0
  40. package/dist/parcel/shells.d.ts +49 -0
  41. package/dist/parcel/shells.js +73 -0
  42. package/dist/parcel/store.d.ts +395 -0
  43. package/dist/parcel/store.js +1152 -0
  44. package/dist/parcel/tier.d.ts +48 -0
  45. package/dist/parcel/tier.js +51 -0
  46. package/dist/present/audio.d.ts +41 -0
  47. package/dist/present/audio.js +50 -0
  48. package/dist/present/blackbody.d.ts +55 -0
  49. package/dist/present/blackbody.js +112 -0
  50. package/dist/present/flame.d.ts +34 -0
  51. package/dist/present/flame.js +103 -0
  52. package/dist/present/index.d.ts +25 -0
  53. package/dist/present/index.js +22 -0
  54. package/dist/present/install.d.ts +87 -0
  55. package/dist/present/install.js +157 -0
  56. package/dist/present/smoke.d.ts +63 -0
  57. package/dist/present/smoke.js +85 -0
  58. package/dist/present/surface.d.ts +40 -0
  59. package/dist/present/surface.js +76 -0
  60. package/dist/reaction/localise.d.ts +16 -0
  61. package/dist/reaction/localise.js +95 -0
  62. package/dist/reaction/rates.d.ts +59 -0
  63. package/dist/reaction/rates.js +101 -0
  64. package/dist/reaction/reaction.d.ts +178 -0
  65. package/dist/reaction/reaction.js +104 -0
  66. package/dist/reaction/registry.d.ts +47 -0
  67. package/dist/reaction/registry.js +215 -0
  68. package/dist/reaction/solver.d.ts +51 -0
  69. package/dist/reaction/solver.js +277 -0
  70. package/dist/species/registry.d.ts +26 -0
  71. package/dist/species/registry.js +97 -0
  72. package/dist/species/species.d.ts +48 -0
  73. package/dist/species/species.js +20 -0
  74. package/dist/species/standard.d.ts +30 -0
  75. package/dist/species/standard.js +436 -0
  76. package/dist/species/thermal.d.ts +18 -0
  77. package/dist/species/thermal.js +72 -0
  78. package/dist/substance/boiling.d.ts +29 -0
  79. package/dist/substance/boiling.js +54 -0
  80. package/dist/substance/composition.d.ts +34 -0
  81. package/dist/substance/composition.js +53 -0
  82. package/dist/substance/registry.d.ts +86 -0
  83. package/dist/substance/registry.js +187 -0
  84. package/dist/substance/substance.d.ts +94 -0
  85. package/dist/substance/substance.js +48 -0
  86. package/dist/testing/drift.d.ts +15 -0
  87. package/dist/testing/drift.js +29 -0
  88. package/dist/transport/contacts.d.ts +22 -0
  89. package/dist/transport/contacts.js +50 -0
  90. package/dist/transport/radiation.d.ts +101 -0
  91. package/dist/transport/radiation.js +199 -0
  92. package/dist/transport/world.d.ts +92 -0
  93. package/dist/transport/world.js +381 -0
  94. package/dist/world/fingerprint.d.ts +31 -0
  95. package/dist/world/fingerprint.js +70 -0
  96. package/package.json +100 -0
  97. package/src/conservation.ts +44 -0
  98. package/src/element/elements.ts +80 -0
  99. package/src/element/matrix.ts +89 -0
  100. package/src/field/ambient.ts +111 -0
  101. package/src/field/atmosphere.ts +1159 -0
  102. package/src/ignition/criteria.ts +177 -0
  103. package/src/ignition/events.ts +100 -0
  104. package/src/index.ts +213 -0
  105. package/src/library/biological.ts +252 -0
  106. package/src/library/common.ts +224 -0
  107. package/src/library/food.ts +302 -0
  108. package/src/library/fuel.ts +299 -0
  109. package/src/library/library.ts +62 -0
  110. package/src/library/metal.ts +186 -0
  111. package/src/library/mineral.ts +194 -0
  112. package/src/library/organic.ts +545 -0
  113. package/src/library/polymer.ts +243 -0
  114. package/src/parcel/conduction.ts +188 -0
  115. package/src/parcel/shells.ts +100 -0
  116. package/src/parcel/store.ts +1270 -0
  117. package/src/parcel/tier.ts +60 -0
  118. package/src/present/audio.ts +54 -0
  119. package/src/present/blackbody.ts +118 -0
  120. package/src/present/flame.ts +111 -0
  121. package/src/present/index.ts +25 -0
  122. package/src/present/install.ts +209 -0
  123. package/src/present/smoke.ts +139 -0
  124. package/src/present/surface.ts +107 -0
  125. package/src/reaction/localise.ts +126 -0
  126. package/src/reaction/rates.ts +153 -0
  127. package/src/reaction/reaction.ts +235 -0
  128. package/src/reaction/registry.ts +257 -0
  129. package/src/reaction/solver.ts +326 -0
  130. package/src/species/registry.ts +117 -0
  131. package/src/species/species.ts +53 -0
  132. package/src/species/standard.ts +449 -0
  133. package/src/species/thermal.ts +74 -0
  134. package/src/substance/boiling.ts +64 -0
  135. package/src/substance/composition.ts +81 -0
  136. package/src/substance/registry.ts +223 -0
  137. package/src/substance/substance.ts +148 -0
  138. package/src/testing/drift.ts +28 -0
  139. package/src/transport/contacts.ts +56 -0
  140. package/src/transport/radiation.ts +238 -0
  141. package/src/transport/world.ts +437 -0
  142. package/src/world/fingerprint.ts +106 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The substep-and-clamp solver: how a reaction network runs without exploding.
3
+ *
4
+ * Combustion kinetics are **stiff**. A rate that changes by orders of magnitude over tens of
5
+ * kelvin, integrated explicitly at a sixtieth of a second, overshoots — and the overshoot's failure
6
+ * mode is negative mass, which in an exponential rate law is where the `NaN`s come from.
7
+ *
8
+ * `§7` answers it with two mechanisms rather than an implicit solver, and the reason that matters
9
+ * is scope: an implicit solve over a stiff nonlinear network is what turns this track from an `XL`
10
+ * into something unshippable.
11
+ *
12
+ * **Sub-stepping** sizes the step to the reaction, from how much of the scarcest reactant one full
13
+ * step would consume. A cold shell takes one pass; a shell at flashover takes thirty-two.
14
+ *
15
+ * **The clamp** is what makes the remaining stiffness survivable. Before anything is applied, the
16
+ * demands on each species are totalled, and where they exceed what is present **every reaction
17
+ * drawing on that species is scaled by the same factor**. Proportional rather than first-come,
18
+ * because first-come makes the answer depend on registration order — a determinism hole with no
19
+ * symptom until two consumers register in a different order. The worst an overshoot can then do is
20
+ * consume everything this substep, which is bounded, physical and self-correcting: with the
21
+ * reactant gone the rate is zero.
22
+ *
23
+ * **Rates are first order in each reactant**, whatever the stoichiometric coefficient. Global rate
24
+ * constants for pyrolysis and combustion are fitted that way, and it keeps the arithmetic to
25
+ * multiplication — a fractional exponent would mean `Math.pow`, whose result ECMAScript does not
26
+ * pin down, on the simulation path.
27
+ */
28
+ import { temperatureFromCapacity } from '../species/thermal.ts';
29
+ import type { RateTable } from './rates.ts';
30
+
31
+ /** Reactions one substance may declare, and species one substance may hold. */
32
+ export const MAX_SUBSTANCE_REACTIONS = 64;
33
+ export const MAX_SUBSTANCE_SPECIES = 64;
34
+
35
+ /** The most passes one call will take. Past this the clamp is what holds it together. */
36
+ const MAX_SUBSTEPS = 32;
37
+
38
+ /** K a single sub-step is allowed to move a shell's temperature before it is split. */
39
+ const TEMPERATURE_BAND = 2;
40
+
41
+ /**
42
+ * A substance's reactions, localised and packed.
43
+ *
44
+ * Species are **local** indices into the substance's own set, so a shell's composition is a dense
45
+ * run of the few species it can actually hold rather than a row over all sixty-eight. Reactants and
46
+ * products are CSR, the layout `physics/faces.ts` uses and for the same reason: traversal allocates
47
+ * nothing.
48
+ */
49
+ export interface SubstanceReactions {
50
+ readonly count: number;
51
+ readonly speciesCount: number;
52
+ /** kg/mol for each local species. */
53
+ readonly molarMass: Float64Array;
54
+ /** J/(kg·K) at the standard state, and its rise per kelvin, for each local species. */
55
+ readonly cpA: Float64Array;
56
+ readonly cpB: Float64Array;
57
+ /** K below and above which each reaction does not run, and the band the lower gate opens over. */
58
+ readonly minTemperature: Float64Array;
59
+ readonly maxTemperature: Float64Array;
60
+ readonly gateWidth: Float64Array;
61
+ /** The ceiling transport puts on each reaction in still air, per second. Zero means none. */
62
+ readonly transportLimit: Float64Array;
63
+ readonly reactantStart: Int32Array;
64
+ readonly reactantLocal: Int32Array;
65
+ readonly reactantMoles: Float64Array;
66
+ readonly productStart: Int32Array;
67
+ readonly productLocal: Int32Array;
68
+ readonly productMoles: Float64Array;
69
+ /** J per unit extent, derived by Hess's law at registration. */
70
+ readonly enthalpy: Float64Array;
71
+ /** `null` for an instant reaction: the clamp is its only limit. */
72
+ readonly table: readonly (RateTable | null)[];
73
+ }
74
+
75
+ /* Module-scope scratch, so a tick allocates nothing. Nothing here nests. */
76
+ const moles = new Float64Array(MAX_SUBSTANCE_SPECIES);
77
+ const demand = new Float64Array(MAX_SUBSTANCE_SPECIES);
78
+ const factor = new Float64Array(MAX_SUBSTANCE_SPECIES);
79
+ const extent = new Float64Array(MAX_SUBSTANCE_REACTIONS);
80
+ const scale = new Float64Array(MAX_SUBSTANCE_REACTIONS);
81
+ const extentThisStep = new Float64Array(MAX_SUBSTANCE_REACTIONS);
82
+
83
+ /** A very large rate, for a reaction whose only limit is what is present. */
84
+ const INSTANT_RATE = 1e12;
85
+
86
+ function rateAtTemperature(table: RateTable | null, temperature: number): number {
87
+ if (table === null) return INSTANT_RATE;
88
+ if (temperature <= table.minTemperature) return table.rate[table.knots - 1] as number;
89
+ if (temperature >= table.maxTemperature) return table.rate[0] as number;
90
+ const position =
91
+ ((1 / temperature - table.inverseMin) / (table.inverseMax - table.inverseMin)) *
92
+ (table.knots - 1);
93
+ const low = Math.floor(position);
94
+ const high = low + 1;
95
+ if (high >= table.knots) return table.rate[table.knots - 1] as number;
96
+ const at = position - low;
97
+ return (table.rate[low] as number) * (1 - at) + (table.rate[high] as number) * at;
98
+ }
99
+
100
+ /**
101
+ * Temperature of the thing being reacted, **from what it is made of right now**.
102
+ *
103
+ * Read off the live moles rather than from a curve built when nothing had reacted yet, which is the
104
+ * whole of CH-6's opening reversal. `A` and `B` are the mass-weighted heat capacities of whatever
105
+ * is present, and inverting `h = A·ΔT + B·ΔT²/2` is a formula.
106
+ */
107
+ function readTemperature(model: SubstanceReactions, joules: number): number {
108
+ let A = 0;
109
+ let B = 0;
110
+ for (let i = 0; i < model.speciesCount; i++) {
111
+ const mass = (moles[i] as number) * (model.molarMass[i] as number);
112
+ if (mass === 0) continue;
113
+ A += mass * (model.cpA[i] as number);
114
+ B += mass * (model.cpB[i] as number);
115
+ }
116
+ return temperatureFromCapacity(A, B, joules);
117
+ }
118
+
119
+ /**
120
+ * How far open a reaction's temperature gate is, 0 to 1.
121
+ *
122
+ * A gate is not a very small rate. Ice does not melt slowly below 273.15 K, it does not melt — and
123
+ * expressing that as an Arrhenius tail would leak a trickle of water into every freezer.
124
+ *
125
+ * **The band tapers both ends, and the upper one had no user until condensation.** `gateWidth` was
126
+ * written for a lower gate — the half-kelvin `PHASE_GATE_WIDTH` that keeps a melting plateau a
127
+ * plateau rather than a sawtooth, by refusing to convert more in one step than the arriving heat can
128
+ * pay for. `maxTemperature` cut hard, and nothing in the library set one, so it was a ceiling with
129
+ * nothing under it.
130
+ *
131
+ * A condensation needs the mirror image and rings the same way without it: the temperature falls
132
+ * through the dew point, the gate snaps open, latent heat comes *out*, the temperature rises back
133
+ * through it, and the gate snaps shut. Same shape as melting with the signs reversed, so the same
134
+ * taper answers it.
135
+ *
136
+ * The cost is one subtract, one divide and one compare per gated reaction per sub-step, on a path
137
+ * `§7` cares about — paid by every reaction rather than only by the ones with an upper gate, because
138
+ * a branch on `maxTemperature < Infinity` is a branch on a value that is uniform in practice and
139
+ * unpredictable to the machine. What would make it wrong is a reaction wanting a sharp ceiling and a
140
+ * soft floor, which would need two widths; none does.
141
+ */
142
+ function gateOpening(model: SubstanceReactions, r: number, temperature: number): number {
143
+ const high = model.maxTemperature[r] as number;
144
+ if (temperature >= high) return 0;
145
+ const low = model.minTemperature[r] as number;
146
+ if (temperature <= low) return 0;
147
+ const width = model.gateWidth[r] as number;
148
+ if (!(width > 0)) return 1;
149
+ let opening = (temperature - low) / width;
150
+ /* `high` is `+Infinity` for every reaction with no ceiling, so this is `Infinity` and loses. */
151
+ const closing = (high - temperature) / width;
152
+ if (closing < opening) opening = closing;
153
+ return opening >= 1 ? 1 : opening;
154
+ }
155
+
156
+ /** Extent per unit time, mol/s, first order in each reactant, zero outside its gate. */
157
+ function extentRate(
158
+ model: SubstanceReactions,
159
+ r: number,
160
+ shellMass: number,
161
+ temperature: number,
162
+ transportFactor: number,
163
+ ): number {
164
+ const opening = gateOpening(model, r, temperature);
165
+ if (opening === 0) return 0;
166
+ let constant = rateAtTemperature(model.table[r] as RateTable | null, temperature);
167
+
168
+ /*
169
+ * A transport ceiling combines with the kinetic rate as a **series resistance**, which is the same
170
+ * arithmetic a shell boundary uses and for the same reason: whichever is slower dominates, and
171
+ * neither side has to know about the other. Above about 800 K carbon oxidation's chemistry
172
+ * outruns oxygen delivery tenfold, so this is what is actually being watched.
173
+ */
174
+ const limit = (model.transportLimit[r] as number) * transportFactor;
175
+ if (limit > 0 && constant > 0) constant = (constant * limit) / (constant + limit);
176
+
177
+ let rate = constant * shellMass * opening;
178
+ const from = model.reactantStart[r] as number;
179
+ const to = model.reactantStart[r + 1] as number;
180
+ for (let i = from; i < to; i++) {
181
+ rate *= (moles[model.reactantLocal[i] as number] as number) / shellMass;
182
+ if (rate === 0) return 0;
183
+ }
184
+ return rate;
185
+ }
186
+
187
+ /**
188
+ * Run one shell's reactions for `dt` seconds.
189
+ *
190
+ * `composition` holds this shell's species masses in kilograms at `offset`; `enthalpy` holds its
191
+ * joules at `enthalpyIndex`. Both are written in place.
192
+ */
193
+ export function reactShell(
194
+ composition: Float64Array,
195
+ offset: number,
196
+ enthalpy: Float64Array,
197
+ enthalpyIndex: number,
198
+ model: SubstanceReactions,
199
+ shellMass: number,
200
+ dt: number,
201
+ /**
202
+ * How much faster than still air the reactants are reaching a surface.
203
+ *
204
+ * One in still air, rising with the gas moving past — which is why blowing on embers makes them
205
+ * brighter, and why it stops when you stop.
206
+ */
207
+ transportFactor = 1,
208
+ ): void {
209
+ if (model.count === 0 || dt <= 0 || !(shellMass > 0)) return;
210
+
211
+ const species = model.speciesCount;
212
+ for (let i = 0; i < species; i++) {
213
+ moles[i] = (composition[offset + i] as number) / (model.molarMass[i] as number);
214
+ }
215
+
216
+ /*
217
+ * How many passes, from how much of the scarcest reactant one whole step would take. Measured
218
+ * once at the temperature the step starts from: a shell that is about to run away is one whose
219
+ * first evaluation already says so.
220
+ */
221
+ let temperature = readTemperature(model, enthalpy[enthalpyIndex] as number);
222
+ demand.fill(0, 0, species);
223
+ for (let r = 0; r < model.count; r++) {
224
+ const rate = extentRate(model, r, shellMass, temperature, transportFactor) * dt;
225
+ extentThisStep[r] = rate;
226
+ const from = model.reactantStart[r] as number;
227
+ const to = model.reactantStart[r + 1] as number;
228
+ for (let i = from; i < to; i++) {
229
+ const local = model.reactantLocal[i] as number;
230
+ demand[local] = (demand[local] as number) + (model.reactantMoles[i] as number) * rate;
231
+ }
232
+ }
233
+ let stiffness = 0;
234
+ for (let i = 0; i < species; i++) {
235
+ const have = moles[i] as number;
236
+ if (!(have > 0)) continue;
237
+ const want = (demand[i] as number) / have;
238
+ if (want > stiffness) stiffness = want;
239
+ }
240
+
241
+ /*
242
+ * And how far one whole step would move the temperature.
243
+ *
244
+ * Reactant availability alone is not enough, and a phase change is what showed it: melting is
245
+ * limited by heat rather than by how much ice is left, so a step that converts a tenth of the
246
+ * ice looks perfectly well-resolved on the mass estimate while absorbing enough latent heat to
247
+ * throw the temperature sixty kelvin the wrong way. Measured at a 16 K undershoot on a boiling
248
+ * kettle before this term existed.
249
+ */
250
+ let heat = 0;
251
+ let capacity = 0;
252
+ for (let r = 0; r < model.count; r++) {
253
+ heat += Math.abs((model.enthalpy[r] as number) * (extentThisStep[r] as number));
254
+ }
255
+ for (let i = 0; i < species; i++) {
256
+ const mass = (moles[i] as number) * (model.molarMass[i] as number);
257
+ if (mass > 0) capacity += mass * (model.cpA[i] as number);
258
+ }
259
+ if (capacity > 0) {
260
+ const swing = heat / (capacity * TEMPERATURE_BAND);
261
+ if (swing > stiffness) stiffness = swing;
262
+ }
263
+ const substeps = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil(stiffness)));
264
+ const step = dt / substeps;
265
+
266
+ for (let pass = 0; pass < substeps; pass++) {
267
+ if (pass > 0) {
268
+ temperature = readTemperature(model, enthalpy[enthalpyIndex] as number);
269
+ }
270
+
271
+ demand.fill(0, 0, species);
272
+ for (let r = 0; r < model.count; r++) {
273
+ const total = extentRate(model, r, shellMass, temperature, transportFactor) * step;
274
+ extent[r] = total;
275
+ if (total === 0) continue;
276
+ const from = model.reactantStart[r] as number;
277
+ const to = model.reactantStart[r + 1] as number;
278
+ for (let i = from; i < to; i++) {
279
+ const local = model.reactantLocal[i] as number;
280
+ demand[local] = (demand[local] as number) + (model.reactantMoles[i] as number) * total;
281
+ }
282
+ }
283
+
284
+ /* One factor per species, so two reactions competing for the same reactant are cut alike. */
285
+ for (let i = 0; i < species; i++) {
286
+ const want = demand[i] as number;
287
+ const have = moles[i] as number;
288
+ factor[i] = want > have ? (have > 0 ? have / want : 0) : 1;
289
+ }
290
+ for (let r = 0; r < model.count; r++) {
291
+ let smallest = 1;
292
+ const from = model.reactantStart[r] as number;
293
+ const to = model.reactantStart[r + 1] as number;
294
+ for (let i = from; i < to; i++) {
295
+ const f = factor[model.reactantLocal[i] as number] as number;
296
+ if (f < smallest) smallest = f;
297
+ }
298
+ scale[r] = smallest;
299
+ }
300
+
301
+ for (let r = 0; r < model.count; r++) {
302
+ const xi = (extent[r] as number) * (scale[r] as number);
303
+ if (xi === 0) continue;
304
+ const rFrom = model.reactantStart[r] as number;
305
+ const rTo = model.reactantStart[r + 1] as number;
306
+ for (let i = rFrom; i < rTo; i++) {
307
+ const local = model.reactantLocal[i] as number;
308
+ moles[local] = (moles[local] as number) - (model.reactantMoles[i] as number) * xi;
309
+ if ((moles[local] as number) < 0) moles[local] = 0;
310
+ }
311
+ const pFrom = model.productStart[r] as number;
312
+ const pTo = model.productStart[r + 1] as number;
313
+ for (let i = pFrom; i < pTo; i++) {
314
+ const local = model.productLocal[i] as number;
315
+ moles[local] = (moles[local] as number) + (model.productMoles[i] as number) * xi;
316
+ }
317
+ /* Exothermic is a negative `ΔH`, and it *adds* to the enthalpy that reads as heat. */
318
+ enthalpy[enthalpyIndex] =
319
+ (enthalpy[enthalpyIndex] as number) - (model.enthalpy[r] as number) * xi;
320
+ }
321
+ }
322
+
323
+ for (let i = 0; i < species; i++) {
324
+ composition[offset + i] = (moles[i] as number) * (model.molarMass[i] as number);
325
+ }
326
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The species a world knows about, and the one place a molar mass comes from.
3
+ *
4
+ * Registration is init-time and fails fast, per `AGENTS.md`: an unknown element, a duplicate id, a
5
+ * subscript that is negative or not a number, an empty formula. Each throws naming the offending
6
+ * value. Nothing here is reachable from a frame, so the cost of checking thoroughly is nothing and
7
+ * the cost of not checking is a `NaN` that appears twenty ticks into a burn.
8
+ *
9
+ * A species is an `i32` index everywhere after registration. That is `drift/physics`'s convention
10
+ * for a body and it is followed rather than improved on.
11
+ */
12
+ import { ATOMIC_MASS, elementIndex } from '../element/elements.ts';
13
+ import { SpeciesElementMatrix } from '../element/matrix.ts';
14
+ import { STANDARD_TEMPERATURE, type Phase, type SpeciesDefinition } from './species.ts';
15
+
16
+ export class SpeciesRegistry {
17
+ /** Moles of each element per mole of each species. Public because conservation reduces it. */
18
+ readonly elements = new SpeciesElementMatrix();
19
+
20
+ private readonly ids: string[] = [];
21
+ private readonly byId = new Map<string, number>();
22
+ private readonly phases: number[] = [];
23
+ private readonly pseudo: boolean[] = [];
24
+ private readonly masses: number[] = [];
25
+ private readonly formation: number[] = [];
26
+ private readonly cpA: number[] = [];
27
+ private readonly cpB: number[] = [];
28
+
29
+ get count(): number {
30
+ return this.ids.length;
31
+ }
32
+
33
+ register(definition: SpeciesDefinition): number {
34
+ const { id, formula } = definition;
35
+ if (this.byId.has(id)) {
36
+ throw new Error(`species "${id}" is already registered`);
37
+ }
38
+
39
+ const symbols = Object.keys(formula);
40
+ if (symbols.length === 0) {
41
+ throw new Error(`species "${id}" has an empty formula`);
42
+ }
43
+
44
+ /* Validated in full before anything is written, so a refusal leaves the registry as it was
45
+ rather than half-holding a species nothing can name. */
46
+ for (const symbol of symbols) {
47
+ if (elementIndex(symbol) < 0) {
48
+ throw new Error(
49
+ `species "${id}" names ${symbol}, which is not one of the fifteen elements`,
50
+ );
51
+ }
52
+ const moles = formula[symbol] as number;
53
+ if (!Number.isFinite(moles) || moles < 0) {
54
+ throw new Error(`species "${id}" has ${moles} moles of ${symbol}`);
55
+ }
56
+ }
57
+
58
+ const species = this.elements.addRow();
59
+ let gramsPerMole = 0;
60
+ for (const symbol of symbols) {
61
+ const element = elementIndex(symbol);
62
+ const moles = formula[symbol] as number;
63
+ this.elements.set(species, element, moles);
64
+ gramsPerMole += moles * (ATOMIC_MASS[element] as number);
65
+ }
66
+
67
+ this.ids.push(id);
68
+ this.byId.set(id, species);
69
+ this.phases.push(definition.phase);
70
+ this.pseudo.push(definition.pseudo === true);
71
+ this.masses.push(gramsPerMole / 1000);
72
+ this.formation.push(definition.formationEnthalpy);
73
+ this.cpA.push(definition.cpA);
74
+ this.cpB.push(definition.cpB ?? 0);
75
+ return species;
76
+ }
77
+
78
+ indexOf(id: string): number {
79
+ return this.byId.get(id) ?? -1;
80
+ }
81
+
82
+ idOf(species: number): string {
83
+ return this.ids[this.check(species)] as string;
84
+ }
85
+
86
+ /** Kilograms per mole, derived from the formula at registration. */
87
+ molarMass(species: number): number {
88
+ return this.masses[this.check(species)] as number;
89
+ }
90
+
91
+ phaseOf(species: number): Phase {
92
+ return this.phases[this.check(species)] as Phase;
93
+ }
94
+
95
+ formationEnthalpyOf(species: number): number {
96
+ return this.formation[this.check(species)] as number;
97
+ }
98
+
99
+ /** Specific heat at a temperature, J/(kg·K), linear about the standard state. */
100
+ heatCapacityOf(species: number, temperature: number): number {
101
+ const at = this.check(species);
102
+ return (
103
+ (this.cpA[at] as number) + (this.cpB[at] as number) * (temperature - STANDARD_TEMPERATURE)
104
+ );
105
+ }
106
+
107
+ isPseudo(species: number): boolean {
108
+ return this.pseudo[this.check(species)] as boolean;
109
+ }
110
+
111
+ private check(species: number): number {
112
+ if (!Number.isInteger(species) || species < 0 || species >= this.ids.length) {
113
+ throw new Error(`species ${species} is outside a registry of ${this.ids.length}`);
114
+ }
115
+ return species;
116
+ }
117
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a species is: a formula, a phase, a formation enthalpy and a heat capacity.
3
+ *
4
+ * A *species* is a chemical entity the model tracks an amount of. Most are real molecules; some are
5
+ * **pseudo-species** — a lumped repeat unit like cellulose's `C6H10O5`, or an average composition
6
+ * like a protein residue — and those are marked, because a reader needs to know which numbers are
7
+ * exact and which are a mean over a population.
8
+ *
9
+ * **No molar mass field, and that is the point.** A species carries a formula and the mass is
10
+ * derived from it against the atomic-weight table. A typed-in mass that disagreed with its own
11
+ * formula would be a stoichiometry error with no symptom: every reaction over the species would
12
+ * conserve moles correctly and lose kilograms quietly.
13
+ */
14
+
15
+ export const PHASE_SOLID = 0;
16
+ export const PHASE_LIQUID = 1;
17
+ export const PHASE_GAS = 2;
18
+
19
+ export type Phase = typeof PHASE_SOLID | typeof PHASE_LIQUID | typeof PHASE_GAS;
20
+
21
+ export interface SpeciesDefinition {
22
+ /** Unique, and it is what a substance and a reaction name. Phase is part of it: `H2O(l)`. */
23
+ readonly id: string;
24
+ /**
25
+ * Moles of each element per mole of species, keyed by symbol.
26
+ *
27
+ * Fractional subscripts are allowed and are not a compromise: an average protein residue is
28
+ * `C4.4H7.7N1.2O1.4S0.04`, and rounding those to integers breaks element balance by percent-scale
29
+ * amounts across a whole body.
30
+ */
31
+ readonly formula: Readonly<Record<string, number>>;
32
+ readonly phase: Phase;
33
+ /**
34
+ * Standard enthalpy of formation at 298.15 K, J/mol.
35
+ *
36
+ * Every reaction's `ΔH` is derived from these by Hess's law rather than typed in, so a wrong
37
+ * value here is wrong in every reaction that touches the species at once — which is a bug that
38
+ * can be found, unlike a single reaction quietly disagreeing with its own species.
39
+ */
40
+ readonly formationEnthalpy: number;
41
+ /** Specific heat at 298.15 K, J/(kg·K). */
42
+ readonly cpA: number;
43
+ /** Linear coefficient, J/(kg·K²). Absent means a constant heat capacity. */
44
+ readonly cpB?: number;
45
+ /** A lumped repeat unit or an average composition rather than a real molecule. */
46
+ readonly pseudo?: boolean;
47
+ }
48
+
49
+ /** The temperature every standard state in this package is anchored at, K. */
50
+ export const STANDARD_TEMPERATURE = 298.15;
51
+
52
+ /** The pressure every standard state in this package is anchored at, Pa. */
53
+ export const STANDARD_PRESSURE = 101325;