@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,101 @@
1
+ /**
2
+ * Reaction rates as tables, because `exp` may not run on a tick.
3
+ *
4
+ * **The reasoning is `§7`'s and it is a hard rule rather than a preference.** `AGENTS.md` already
5
+ * made this call once, for the tyre curve: Pacejka is `sin(atan(…))` and both were banned from the
6
+ * tick, so the curve became a table and the API came out better than the closed form. The same
7
+ * applies here with more force. `exp(−Ea/RT)` would be evaluated once per reaction per shell per
8
+ * parcel per substep — a hundred times the tyre call count — and it varies by twenty orders of
9
+ * magnitude over the range of interest, which makes it the single easiest place in the engine to
10
+ * lose determinism to one platform's `exp` differing in the last bit.
11
+ *
12
+ * **Knots are uniform in `1/T`, and that is a departure from the design worth recording.** `§7`
13
+ * says "512 logarithmically-spaced knots, log-`k` stored so the interpolation is in the variable
14
+ * that is nearly linear" — and `ln k = ln A + n·ln T − Ea/(R·T)` is not nearly linear in `ln T`, it
15
+ * is *exactly* linear in `1/T` for the `n = 0` form that almost every rate in the library uses. So
16
+ * the knots are uniform in `1/T`, which puts the interpolation error where the design wanted it.
17
+ *
18
+ * **But `k` is stored rather than `ln k`, and that is the other half of the correction.** The
19
+ * design's own sentence would need an `exp` at every lookup, which is the thing the paragraph above
20
+ * it forbids — the two halves of `§7` contradict each other, and this is the resolution. Storing
21
+ * `k` and interpolating linearly costs a real interpolation error, bounded by about `δ²/8` where
22
+ * `δ` is the change in `ln k` across one knot. At 1,024 knots over 200–3500 K that is under half a
23
+ * percent for every activation energy in the library, **which is comfortably inside the uncertainty
24
+ * in the activation energies themselves** — literature values for cellulose pyrolysis differ by
25
+ * several percent between studies. `rates.test.ts` measures it rather than trusting this paragraph.
26
+ */
27
+ /** J/(mol·K). */
28
+ export const GAS_CONSTANT = 8.314462618;
29
+ const DEFAULT_MIN = 200;
30
+ const DEFAULT_MAX = 3500;
31
+ const DEFAULT_KNOTS = 1024;
32
+ function tabulate(at, min, max, knots) {
33
+ if (!(max > min) || !(min > 0)) {
34
+ throw new Error(`a rate table spans ${min}..${max} K, which is not an ascending positive range`);
35
+ }
36
+ const inverseMin = 1 / max;
37
+ const inverseMax = 1 / min;
38
+ const rate = new Float64Array(knots);
39
+ for (let i = 0; i < knots; i++) {
40
+ const inverse = inverseMin + ((inverseMax - inverseMin) * i) / (knots - 1);
41
+ rate[i] = at(1 / inverse);
42
+ }
43
+ return { minTemperature: min, maxTemperature: max, inverseMin, inverseMax, knots, rate };
44
+ }
45
+ /** `k(T) = A · T^n · exp(−Ea / R·T)`, sampled once so the tick never evaluates it. */
46
+ export function tabulateArrhenius(A, activationEnergy, n = 0, min = DEFAULT_MIN, max = DEFAULT_MAX, knots = DEFAULT_KNOTS) {
47
+ return tabulate(
48
+ // determinism: build-time — the Arrhenius table is built once so exp never runs on a tick
49
+ (t) => A * t ** n * Math.exp(-activationEnergy / (GAS_CONSTANT * t)), min, max, knots);
50
+ }
51
+ /**
52
+ * The peaked curve an enzyme-catalysed rate actually follows, normalised to one at the optimum.
53
+ *
54
+ * Zero at both cardinal ends, one at `optimum`, and **asymmetric in the right direction**: a slow
55
+ * climb and a sharp fall, because an enzyme denatures far faster than it warms up. Modelling decay
56
+ * or fermentation with Arrhenius instead gives compost that gets hotter forever, which is the most
57
+ * common mistake in this area and is refused here structurally rather than by remembering.
58
+ */
59
+ export function cardinalRate(temperature, min, optimum, max) {
60
+ if (temperature <= min || temperature >= max)
61
+ return 0;
62
+ const above = temperature - min;
63
+ const numerator = above * above * (temperature - max);
64
+ const denominator = (optimum - min) *
65
+ ((optimum - min) * (temperature - optimum) -
66
+ (optimum - max) * (optimum + min - 2 * temperature));
67
+ if (denominator === 0)
68
+ return 0;
69
+ const value = numerator / denominator;
70
+ return value > 0 ? value : 0;
71
+ }
72
+ /** A cardinal curve scaled to a peak rate, tabulated the same way an Arrhenius one is. */
73
+ export function tabulateCardinal(peak, min, optimum, max, low = DEFAULT_MIN, high = DEFAULT_MAX, knots = DEFAULT_KNOTS) {
74
+ if (!(min < optimum)) {
75
+ throw new Error(`cardinal temperatures are out of order: minimum ${min} is not below optimum ${optimum}`);
76
+ }
77
+ if (!(optimum < max)) {
78
+ throw new Error(`cardinal temperatures are out of order: maximum ${max} is not above optimum ${optimum}`);
79
+ }
80
+ return tabulate((t) => peak * cardinalRate(t, min, optimum, max), low, high, knots);
81
+ }
82
+ /**
83
+ * The rate at a temperature.
84
+ *
85
+ * Two multiplications, a floor and a lerp. Clamped at both ends rather than extrapolated: linearly
86
+ * extrapolating an exponential is how a rate table produces a number with nothing behind it.
87
+ */
88
+ export function rateAt(table, temperature) {
89
+ if (temperature <= table.minTemperature)
90
+ return table.rate[table.knots - 1];
91
+ if (temperature >= table.maxTemperature)
92
+ return table.rate[0];
93
+ const position = ((1 / temperature - table.inverseMin) / (table.inverseMax - table.inverseMin)) *
94
+ (table.knots - 1);
95
+ const low = Math.floor(position);
96
+ const high = low + 1;
97
+ if (high >= table.knots)
98
+ return table.rate[table.knots - 1];
99
+ const at = position - low;
100
+ return table.rate[low] * (1 - at) + table.rate[high] * at;
101
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * What a reaction is: stoichiometry, a rate law, and what kind of change it represents.
3
+ *
4
+ * **No enthalpy field**, and that is the same decision `SpeciesDefinition` makes about molar mass.
5
+ * A reaction's `ΔH` is derived from its species' formation enthalpies by Hess's law at
6
+ * registration, so a number that disagreed with its own species cannot exist. `§5` of the design is
7
+ * the argument, and CH-0's heating-value test is what makes the underlying numbers trustworthy.
8
+ *
9
+ * **No gates yet.** The design's `ReactionGates` — a minimum oxygen fraction, a phase requirement,
10
+ * a moisture ceiling — are refused in this phase rather than stubbed: an oxygen gate without a gas
11
+ * field to read is a constant pretending to be physics, and the field is CH-4. The network here
12
+ * runs on mass action alone.
13
+ */
14
+ export declare const KIND_DRYING = 0;
15
+ export declare const KIND_PYROLYSIS = 1;
16
+ export declare const KIND_COMBUSTION = 2;
17
+ export declare const KIND_CHAR_OXIDATION = 3;
18
+ export declare const KIND_CALCINATION = 4;
19
+ export declare const KIND_CORROSION = 5;
20
+ export declare const KIND_DISSOLUTION = 6;
21
+ export declare const KIND_DENATURATION = 7;
22
+ export declare const KIND_BROWNING = 8;
23
+ export declare const KIND_BIOLOGICAL = 9;
24
+ export declare const KIND_PHASE_CHANGE = 10;
25
+ export type ReactionKind = typeof KIND_DRYING | typeof KIND_PYROLYSIS | typeof KIND_COMBUSTION | typeof KIND_CHAR_OXIDATION | typeof KIND_CALCINATION | typeof KIND_CORROSION | typeof KIND_DISSOLUTION | typeof KIND_DENATURATION | typeof KIND_BROWNING | typeof KIND_BIOLOGICAL | typeof KIND_PHASE_CHANGE;
26
+ export interface SpeciesAmount {
27
+ readonly species: string;
28
+ /** Moles per unit of reaction extent. Fractional is allowed and often necessary. */
29
+ readonly moles: number;
30
+ }
31
+ /**
32
+ * `k(T) = A · T^n · exp(−Ea / R·T)`. Monotonic in temperature, which is right for everything a
33
+ * flame does and wrong for everything an enzyme does.
34
+ */
35
+ export interface ArrheniusKinetics {
36
+ readonly type: 'arrhenius';
37
+ readonly A: number;
38
+ /** J/mol. */
39
+ readonly activationEnergy: number;
40
+ readonly n?: number;
41
+ }
42
+ /**
43
+ * A peaked curve: zero below `minimum`, one at `optimum`, zero above `maximum`.
44
+ *
45
+ * **Enzyme-catalysed rates are not Arrhenius**, and using one gives compost that heats forever.
46
+ * `§8.6` is the argument; this is where the shape lives.
47
+ */
48
+ export interface CardinalKinetics {
49
+ readonly type: 'cardinal';
50
+ /** The rate at the optimum. */
51
+ readonly peak: number;
52
+ readonly minimum: number;
53
+ readonly optimum: number;
54
+ readonly maximum: number;
55
+ }
56
+ /**
57
+ * Arrhenius, capped by how fast the reactant can physically reach the surface.
58
+ *
59
+ * **This is what makes char glow for an hour instead of vanishing in seconds, and `§7` says why.**
60
+ * Above about 800 K the chemistry of carbon oxidation outruns oxygen delivery by an order of
61
+ * magnitude, so what you are actually watching is transport. The kinetic rate alone gives char that
62
+ * disappears; the cap gives char that sits there glowing, which is what char does.
63
+ *
64
+ * The two combine as a **series resistance** — `1/k = 1/k_kinetic + 1/k_transport` — which is the
65
+ * same arithmetic a shell boundary uses for the same reason, and it means neither side has to know
66
+ * about the other. And the transport half rises with the gas moving past, so **blowing on embers
67
+ * makes them brighter and stops when you stop**.
68
+ */
69
+ export interface DiffusionKinetics {
70
+ readonly type: 'diffusion';
71
+ readonly A: number;
72
+ /** J/mol. */
73
+ readonly activationEnergy: number;
74
+ readonly n?: number;
75
+ /** The ceiling transport puts on it in still air, per second. */
76
+ readonly transportLimit: number;
77
+ }
78
+ /** As fast as the reactants allow: the clamp in the solver is the only thing limiting it. */
79
+ export interface InstantKinetics {
80
+ readonly type: 'instant';
81
+ }
82
+ export type Kinetics = ArrheniusKinetics | CardinalKinetics | DiffusionKinetics | InstantKinetics;
83
+ /**
84
+ * When a reaction is allowed to run at all.
85
+ *
86
+ * Separate from the rate law because it is a different kind of statement: kinetics say how fast,
87
+ * a gate says whether. Ice does not melt at all below 273.15 K, and expressing that as a very small
88
+ * Arrhenius rate would be a lie that leaks a trickle of water into every freezer.
89
+ */
90
+ export interface ReactionGates {
91
+ /** K. Below this, nothing. */
92
+ readonly minTemperature?: number;
93
+ /** K. Above this, nothing. */
94
+ readonly maxTemperature?: number;
95
+ /**
96
+ * K over which a gate opens rather than snapping: upward from `minTemperature`, and downward from
97
+ * `maxTemperature` where there is one.
98
+ *
99
+ * Zero for a pure substance. A few kelvin for a mixture: butter softens over about three, because
100
+ * it is triglycerides with different melting points, and that band is what makes fat render
101
+ * slowly instead of switching from solid to liquid.
102
+ *
103
+ * **One number for both ends**, because no reaction here wants a sharp ceiling over a soft floor
104
+ * and a second field would be a knob nobody turns. A reaction with both gates set tapers
105
+ * symmetrically and is fully open only between them.
106
+ */
107
+ readonly width?: number;
108
+ }
109
+ export interface Reaction {
110
+ readonly id: string;
111
+ readonly reactants: readonly SpeciesAmount[];
112
+ readonly products: readonly SpeciesAmount[];
113
+ readonly kinetics: Kinetics;
114
+ readonly kind: ReactionKind;
115
+ readonly gates?: ReactionGates;
116
+ }
117
+ /**
118
+ * A phase change, as the reaction it actually is.
119
+ *
120
+ * **Its enthalpy is not given here and must not be**: Hess's law over CH-0's formation enthalpies
121
+ * gives 333,611 J/kg for ice melting against a literature 333,550, and — the part that settles the
122
+ * design — 2,442,631 J/kg for water boiling, which is the value **at 298.15 K** rather than the
123
+ * 2,256,400 quoted at the boiling point. That is right, and it means the latent heat at any
124
+ * temperature stops being stored and becomes a consequence of the heat capacities either side,
125
+ * varying the way the real one does.
126
+ *
127
+ * **A fast finite rate rather than `Instant`, and the difference is the whole plateau.** Instant
128
+ * kinetics would let the clamp convert every gram of water it could reach in a single sub-step —
129
+ * two and a half megajoules absorbed at once, and a temperature that falls hundreds of kelvin below
130
+ * the boiling point rather than sitting on it. A finite rate through an opening gate is a
131
+ * proportional controller instead: the further above the transition the temperature strays, the
132
+ * more converts, the more heat is absorbed, and it settles just above with an overshoot
133
+ * proportional to the flux arriving. That settling *is* the plateau, and it comes out of the
134
+ * substep-and-clamp solver rather than out of a table.
135
+ *
136
+ * `activationEnergy: 0` makes the Arrhenius form a constant, so no second kinetics kind was needed.
137
+ */
138
+ export declare function phaseChange(id: string, from: string, to: string, temperature: number, width?: number, rate?: number): Reaction;
139
+ /**
140
+ * A phase change the other way up: the one that runs as something *cools*.
141
+ *
142
+ * **`phaseChange` is "heat arrives" and this is "heat leaves", and the pair is not symmetric by
143
+ * accident.** Everything the library had until now was the first kind — ice melts, water boils, wax
144
+ * vaporises — because a fire is heat arriving. A vapour condensing is the same mechanism read
145
+ * backwards: it runs *below* a temperature rather than above one, its gate closes as the
146
+ * temperature rises through the transition, and its enthalpy comes out with the opposite sign
147
+ * because Hess's law over the two species already carries it.
148
+ *
149
+ * **The gate closes over `width` rather than snapping**, and `gateOpening` grew the upper taper for
150
+ * this. Without it the pair rings: the temperature falls through the transition, the gate opens
151
+ * fully, latent heat is released, the temperature rises back through it, and the gate slams. That
152
+ * is `PHASE_GATE_WIDTH`'s argument with the signs reversed, so it takes the same answer.
153
+ *
154
+ * **A condensation wants a reverse reaction and its caller must write one.** Nothing here pairs
155
+ * them, because a pair is two entries in a network and the network is data. A condensation with no
156
+ * evaporation is a one-way sink: droplets that drift back into a flame stay droplets, and the smoke
157
+ * over a fire never turns black again.
158
+ */
159
+ export declare function condensation(id: string, from: string, to: string, temperature: number, width?: number, rate?: number): Reaction;
160
+ /**
161
+ * How fast a phase change runs with its gate fully open, per second.
162
+ *
163
+ * **A gain, not a physical constant, and it is bounded from both sides.** Too high and one step
164
+ * converts more than the arriving heat can pay for, so the temperature undershoots and the pair
165
+ * rings. Too low and the phase change cannot keep up with a real flux — at this value a kilogram
166
+ * boils at up to half a gram a second per kelvin of overshoot, which is about 1.2 kW and rises with
167
+ * the overshoot, so a kettle settles a thousandth of a kelvin above its boiling point and a blowtorch
168
+ * settles further above it. That the band widens with the flux is correct rather than a compromise.
169
+ */
170
+ export declare const PHASE_RATE = 0.5;
171
+ /**
172
+ * K over which a phase change's gate opens, for a pure substance.
173
+ *
174
+ * Not zero, and that is what keeps the plateau a plateau rather than a sawtooth: a gate that snapped
175
+ * fully open a hair above the transition would convert far more than the arriving heat can pay for,
176
+ * undershoot, snap shut, and ring. Half a kelvin is the band the temperature is then held within.
177
+ */
178
+ export declare const PHASE_GATE_WIDTH = 0.5;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * What a reaction is: stoichiometry, a rate law, and what kind of change it represents.
3
+ *
4
+ * **No enthalpy field**, and that is the same decision `SpeciesDefinition` makes about molar mass.
5
+ * A reaction's `ΔH` is derived from its species' formation enthalpies by Hess's law at
6
+ * registration, so a number that disagreed with its own species cannot exist. `§5` of the design is
7
+ * the argument, and CH-0's heating-value test is what makes the underlying numbers trustworthy.
8
+ *
9
+ * **No gates yet.** The design's `ReactionGates` — a minimum oxygen fraction, a phase requirement,
10
+ * a moisture ceiling — are refused in this phase rather than stubbed: an oxygen gate without a gas
11
+ * field to read is a constant pretending to be physics, and the field is CH-4. The network here
12
+ * runs on mass action alone.
13
+ */
14
+ export const KIND_DRYING = 0;
15
+ export const KIND_PYROLYSIS = 1;
16
+ export const KIND_COMBUSTION = 2;
17
+ export const KIND_CHAR_OXIDATION = 3;
18
+ export const KIND_CALCINATION = 4;
19
+ export const KIND_CORROSION = 5;
20
+ export const KIND_DISSOLUTION = 6;
21
+ export const KIND_DENATURATION = 7;
22
+ export const KIND_BROWNING = 8;
23
+ export const KIND_BIOLOGICAL = 9;
24
+ export const KIND_PHASE_CHANGE = 10;
25
+ /**
26
+ * A phase change, as the reaction it actually is.
27
+ *
28
+ * **Its enthalpy is not given here and must not be**: Hess's law over CH-0's formation enthalpies
29
+ * gives 333,611 J/kg for ice melting against a literature 333,550, and — the part that settles the
30
+ * design — 2,442,631 J/kg for water boiling, which is the value **at 298.15 K** rather than the
31
+ * 2,256,400 quoted at the boiling point. That is right, and it means the latent heat at any
32
+ * temperature stops being stored and becomes a consequence of the heat capacities either side,
33
+ * varying the way the real one does.
34
+ *
35
+ * **A fast finite rate rather than `Instant`, and the difference is the whole plateau.** Instant
36
+ * kinetics would let the clamp convert every gram of water it could reach in a single sub-step —
37
+ * two and a half megajoules absorbed at once, and a temperature that falls hundreds of kelvin below
38
+ * the boiling point rather than sitting on it. A finite rate through an opening gate is a
39
+ * proportional controller instead: the further above the transition the temperature strays, the
40
+ * more converts, the more heat is absorbed, and it settles just above with an overshoot
41
+ * proportional to the flux arriving. That settling *is* the plateau, and it comes out of the
42
+ * substep-and-clamp solver rather than out of a table.
43
+ *
44
+ * `activationEnergy: 0` makes the Arrhenius form a constant, so no second kinetics kind was needed.
45
+ */
46
+ export function phaseChange(id, from, to, temperature, width = PHASE_GATE_WIDTH, rate = PHASE_RATE) {
47
+ return {
48
+ id,
49
+ reactants: [{ species: from, moles: 1 }],
50
+ products: [{ species: to, moles: 1 }],
51
+ kinetics: { type: 'arrhenius', A: rate, activationEnergy: 0 },
52
+ kind: KIND_PHASE_CHANGE,
53
+ gates: { minTemperature: temperature, width },
54
+ };
55
+ }
56
+ /**
57
+ * A phase change the other way up: the one that runs as something *cools*.
58
+ *
59
+ * **`phaseChange` is "heat arrives" and this is "heat leaves", and the pair is not symmetric by
60
+ * accident.** Everything the library had until now was the first kind — ice melts, water boils, wax
61
+ * vaporises — because a fire is heat arriving. A vapour condensing is the same mechanism read
62
+ * backwards: it runs *below* a temperature rather than above one, its gate closes as the
63
+ * temperature rises through the transition, and its enthalpy comes out with the opposite sign
64
+ * because Hess's law over the two species already carries it.
65
+ *
66
+ * **The gate closes over `width` rather than snapping**, and `gateOpening` grew the upper taper for
67
+ * this. Without it the pair rings: the temperature falls through the transition, the gate opens
68
+ * fully, latent heat is released, the temperature rises back through it, and the gate slams. That
69
+ * is `PHASE_GATE_WIDTH`'s argument with the signs reversed, so it takes the same answer.
70
+ *
71
+ * **A condensation wants a reverse reaction and its caller must write one.** Nothing here pairs
72
+ * them, because a pair is two entries in a network and the network is data. A condensation with no
73
+ * evaporation is a one-way sink: droplets that drift back into a flame stay droplets, and the smoke
74
+ * over a fire never turns black again.
75
+ */
76
+ export function condensation(id, from, to, temperature, width = PHASE_GATE_WIDTH, rate = PHASE_RATE) {
77
+ return {
78
+ id,
79
+ reactants: [{ species: from, moles: 1 }],
80
+ products: [{ species: to, moles: 1 }],
81
+ kinetics: { type: 'arrhenius', A: rate, activationEnergy: 0 },
82
+ kind: KIND_PHASE_CHANGE,
83
+ gates: { maxTemperature: temperature, width },
84
+ };
85
+ }
86
+ /**
87
+ * How fast a phase change runs with its gate fully open, per second.
88
+ *
89
+ * **A gain, not a physical constant, and it is bounded from both sides.** Too high and one step
90
+ * converts more than the arriving heat can pay for, so the temperature undershoots and the pair
91
+ * rings. Too low and the phase change cannot keep up with a real flux — at this value a kilogram
92
+ * boils at up to half a gram a second per kelvin of overshoot, which is about 1.2 kW and rises with
93
+ * the overshoot, so a kettle settles a thousandth of a kelvin above its boiling point and a blowtorch
94
+ * settles further above it. That the band widens with the flux is correct rather than a compromise.
95
+ */
96
+ export const PHASE_RATE = 0.5;
97
+ /**
98
+ * K over which a phase change's gate opens, for a pure substance.
99
+ *
100
+ * Not zero, and that is what keeps the plateau a plateau rather than a sawtooth: a gate that snapped
101
+ * fully open a hair above the transition would convert far more than the arriving heat can pay for,
102
+ * undershoot, snap shut, and ring. Half a kelvin is the band the temperature is then held within.
103
+ */
104
+ export const PHASE_GATE_WIDTH = 0.5;
@@ -0,0 +1,47 @@
1
+ import type { SpeciesRegistry } from '../species/registry.ts';
2
+ import { type RateTable } from './rates.ts';
3
+ import type { Reaction, ReactionKind } from './reaction.ts';
4
+ export * from './reaction.ts';
5
+ export declare class ReactionRegistry {
6
+ private readonly species;
7
+ private readonly ids;
8
+ private readonly byId;
9
+ private readonly kinds;
10
+ private readonly gateMin;
11
+ private readonly gateMax;
12
+ private readonly gateWidth;
13
+ private readonly transport;
14
+ private readonly enthalpies;
15
+ private readonly tables;
16
+ private readonly reactantIndex;
17
+ private readonly reactantAmount;
18
+ private readonly productIndex;
19
+ private readonly productAmount;
20
+ private readonly balance;
21
+ constructor(species: SpeciesRegistry);
22
+ get count(): number;
23
+ register(reaction: Reaction): number;
24
+ indexOf(id: string): number;
25
+ idOf(reaction: number): string;
26
+ kindOf(reaction: number): ReactionKind;
27
+ /** J per unit extent. Negative releases heat; positive absorbs it. */
28
+ enthalpyOf(reaction: number): number;
29
+ rateTableOf(reaction: number): RateTable;
30
+ /** K below which this reaction does not run at all. */
31
+ minTemperatureOf(reaction: number): number;
32
+ /** K above which it does not run at all. */
33
+ maxTemperatureOf(reaction: number): number;
34
+ /** K over which the lower gate opens rather than snapping. */
35
+ gateWidthOf(reaction: number): number;
36
+ /** The ceiling transport puts on this reaction in still air, per second. Zero means none. */
37
+ transportLimitOf(reaction: number): number;
38
+ /** The table, or `null` where the reaction is instant. What the localiser packs. */
39
+ rateTableOrNull(reaction: number): RateTable | null;
40
+ /** The rate constant at a temperature. An instant reaction answers with a very large number. */
41
+ rateOf(reaction: number, temperature: number): number;
42
+ reactantSpecies(reaction: number): Int32Array;
43
+ reactantMoles(reaction: number): Float64Array;
44
+ productSpecies(reaction: number): Int32Array;
45
+ productMoles(reaction: number): Float64Array;
46
+ private check;
47
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The reactions a world knows about, balanced and costed at registration.
3
+ *
4
+ * Two things happen here that cannot happen later, and both are refusals rather than warnings.
5
+ *
6
+ * **Every reaction is checked against the element matrix**, and one that does not balance is
7
+ * refused naming the element and the size of the gap. An unbalanced reaction invents or destroys
8
+ * matter every time it runs, and every symptom of that is subtle — a fire that will not go out, a
9
+ * mass that quietly grows, smoke that never clears. CH-0 built the matrix for exactly this moment.
10
+ *
11
+ * **Every reaction's enthalpy is derived by Hess's law** from the formation enthalpies CH-0 stores,
12
+ * rather than typed in. A `ΔH` that disagreed with its own species would conserve energy in the
13
+ * code and lose it against the world, with nothing to detect it. Derived, the two cannot disagree,
14
+ * and a wrong formation enthalpy shows up in every reaction that touches the species at once —
15
+ * which is a bug that can be found.
16
+ */
17
+ import { ELEMENT_COUNT, ELEMENTS } from '../element/elements.js';
18
+ import { tabulateArrhenius, tabulateCardinal } from './rates.js';
19
+ export * from './reaction.js';
20
+ /** How far a reaction may be from balancing, in moles of an element per unit extent. */
21
+ const BALANCE_TOLERANCE = 1e-9;
22
+ /** `instant` has no table; the solver's clamp is what bounds it. A huge constant stands in. */
23
+ const INSTANT_RATE = 1e12;
24
+ export class ReactionRegistry {
25
+ species;
26
+ ids = [];
27
+ byId = new Map();
28
+ kinds = [];
29
+ gateMin = [];
30
+ gateMax = [];
31
+ gateWidth = [];
32
+ transport = [];
33
+ enthalpies = [];
34
+ tables = [];
35
+ reactantIndex = [];
36
+ reactantAmount = [];
37
+ productIndex = [];
38
+ productAmount = [];
39
+ /* Scratch for the balance check. Registration-time, so this is tidiness rather than a hot path. */
40
+ balance = new Float64Array(ELEMENT_COUNT);
41
+ constructor(species) {
42
+ this.species = species;
43
+ }
44
+ get count() {
45
+ return this.ids.length;
46
+ }
47
+ register(reaction) {
48
+ const { id } = reaction;
49
+ if (this.byId.has(id))
50
+ throw new Error(`reaction "${id}" is already registered`);
51
+ if (reaction.reactants.length === 0)
52
+ throw new Error(`reaction "${id}" has no reactants`);
53
+ if (reaction.products.length === 0)
54
+ throw new Error(`reaction "${id}" has no products`);
55
+ const resolve = (side, what) => {
56
+ const index = new Int32Array(side.length);
57
+ const amount = new Float64Array(side.length);
58
+ for (let i = 0; i < side.length; i++) {
59
+ const entry = side[i];
60
+ const at = this.species.indexOf(entry.species);
61
+ if (at < 0) {
62
+ throw new Error(`reaction "${id}" names ${what} "${entry.species}", which is not a registered species`);
63
+ }
64
+ if (!Number.isFinite(entry.moles) || entry.moles <= 0) {
65
+ throw new Error(`reaction "${id}" gives ${what} "${entry.species}" a coefficient of ${entry.moles}`);
66
+ }
67
+ index[i] = at;
68
+ amount[i] = entry.moles;
69
+ }
70
+ return [index, amount];
71
+ };
72
+ const [reactantIndex, reactantAmount] = resolve(reaction.reactants, 'reactant');
73
+ const [productIndex, productAmount] = resolve(reaction.products, 'product');
74
+ /* Products minus reactants, element by element. Zero everywhere or it does not run. */
75
+ this.balance.fill(0);
76
+ for (let i = 0; i < productIndex.length; i++) {
77
+ this.species.elements.addScaledRow(productIndex[i], productAmount[i], this.balance);
78
+ }
79
+ for (let i = 0; i < reactantIndex.length; i++) {
80
+ this.species.elements.addScaledRow(reactantIndex[i], -reactantAmount[i], this.balance);
81
+ }
82
+ for (let e = 0; e < ELEMENT_COUNT; e++) {
83
+ const gap = this.balance[e];
84
+ if (Math.abs(gap) > BALANCE_TOLERANCE) {
85
+ const side = gap > 0 ? 'more' : 'less';
86
+ throw new Error(`reaction "${id}" does not balance: its products hold ${Math.abs(gap)} mol ${side} ` +
87
+ `${ELEMENTS[e]} than its reactants`);
88
+ }
89
+ }
90
+ /* Hess's law: the products' formation enthalpies less the reactants'. */
91
+ let enthalpy = 0;
92
+ for (let i = 0; i < productIndex.length; i++) {
93
+ enthalpy +=
94
+ productAmount[i] * this.species.formationEnthalpyOf(productIndex[i]);
95
+ }
96
+ for (let i = 0; i < reactantIndex.length; i++) {
97
+ enthalpy -=
98
+ reactantAmount[i] *
99
+ this.species.formationEnthalpyOf(reactantIndex[i]);
100
+ }
101
+ const kinetics = reaction.kinetics;
102
+ let table;
103
+ let transportLimit = 0;
104
+ switch (kinetics.type) {
105
+ case 'arrhenius':
106
+ table = tabulateArrhenius(kinetics.A, kinetics.activationEnergy, kinetics.n ?? 0);
107
+ break;
108
+ case 'diffusion':
109
+ table = tabulateArrhenius(kinetics.A, kinetics.activationEnergy, kinetics.n ?? 0);
110
+ transportLimit = kinetics.transportLimit;
111
+ if (!(transportLimit > 0)) {
112
+ throw new Error(`reaction "${id}" is transport-limited at ${transportLimit} per second`);
113
+ }
114
+ break;
115
+ case 'cardinal':
116
+ table = tabulateCardinal(kinetics.peak, kinetics.minimum, kinetics.optimum, kinetics.maximum);
117
+ break;
118
+ case 'instant':
119
+ table = null;
120
+ break;
121
+ }
122
+ const index = this.ids.length;
123
+ this.ids.push(id);
124
+ this.byId.set(id, index);
125
+ this.kinds.push(reaction.kind);
126
+ const gates = reaction.gates;
127
+ this.gateMin.push(gates?.minTemperature ?? Number.NEGATIVE_INFINITY);
128
+ this.gateMax.push(gates?.maxTemperature ?? Number.POSITIVE_INFINITY);
129
+ this.gateWidth.push(gates?.width ?? 0);
130
+ this.enthalpies.push(enthalpy);
131
+ this.tables.push(table);
132
+ this.transport.push(transportLimit);
133
+ this.reactantIndex.push(reactantIndex);
134
+ this.reactantAmount.push(reactantAmount);
135
+ this.productIndex.push(productIndex);
136
+ this.productAmount.push(productAmount);
137
+ return index;
138
+ }
139
+ indexOf(id) {
140
+ return this.byId.get(id) ?? -1;
141
+ }
142
+ idOf(reaction) {
143
+ return this.ids[this.check(reaction)];
144
+ }
145
+ kindOf(reaction) {
146
+ return this.kinds[this.check(reaction)];
147
+ }
148
+ /** J per unit extent. Negative releases heat; positive absorbs it. */
149
+ enthalpyOf(reaction) {
150
+ return this.enthalpies[this.check(reaction)];
151
+ }
152
+ rateTableOf(reaction) {
153
+ const table = this.tables[this.check(reaction)];
154
+ if (table === null) {
155
+ throw new Error(`reaction "${this.ids[reaction]}" is instant and has no rate table`);
156
+ }
157
+ return table;
158
+ }
159
+ /** K below which this reaction does not run at all. */
160
+ minTemperatureOf(reaction) {
161
+ return this.gateMin[this.check(reaction)];
162
+ }
163
+ /** K above which it does not run at all. */
164
+ maxTemperatureOf(reaction) {
165
+ return this.gateMax[this.check(reaction)];
166
+ }
167
+ /** K over which the lower gate opens rather than snapping. */
168
+ gateWidthOf(reaction) {
169
+ return this.gateWidth[this.check(reaction)];
170
+ }
171
+ /** The ceiling transport puts on this reaction in still air, per second. Zero means none. */
172
+ transportLimitOf(reaction) {
173
+ return this.transport[this.check(reaction)];
174
+ }
175
+ /** The table, or `null` where the reaction is instant. What the localiser packs. */
176
+ rateTableOrNull(reaction) {
177
+ return this.tables[this.check(reaction)];
178
+ }
179
+ /** The rate constant at a temperature. An instant reaction answers with a very large number. */
180
+ rateOf(reaction, temperature) {
181
+ const table = this.tables[this.check(reaction)];
182
+ if (table === null)
183
+ return INSTANT_RATE;
184
+ if (temperature <= table.minTemperature)
185
+ return table.rate[table.knots - 1];
186
+ if (temperature >= table.maxTemperature)
187
+ return table.rate[0];
188
+ const position = ((1 / temperature - table.inverseMin) / (table.inverseMax - table.inverseMin)) *
189
+ (table.knots - 1);
190
+ const low = Math.floor(position);
191
+ const high = low + 1;
192
+ if (high >= table.knots)
193
+ return table.rate[table.knots - 1];
194
+ const at = position - low;
195
+ return table.rate[low] * (1 - at) + table.rate[high] * at;
196
+ }
197
+ reactantSpecies(reaction) {
198
+ return this.reactantIndex[this.check(reaction)];
199
+ }
200
+ reactantMoles(reaction) {
201
+ return this.reactantAmount[this.check(reaction)];
202
+ }
203
+ productSpecies(reaction) {
204
+ return this.productIndex[this.check(reaction)];
205
+ }
206
+ productMoles(reaction) {
207
+ return this.productAmount[this.check(reaction)];
208
+ }
209
+ check(reaction) {
210
+ if (!Number.isInteger(reaction) || reaction < 0 || reaction >= this.ids.length) {
211
+ throw new Error(`reaction ${reaction} is outside a registry of ${this.ids.length}`);
212
+ }
213
+ return reaction;
214
+ }
215
+ }