Skip to content

Commit 32558ff

Browse files
ceeedricK20shores
andauthored
Add LambdaRateConstant support to music-box and fix reaction naming issues (#449)
* Add LambdaRate to music-box javascript * No longer convert cpp to js * Fix name mismatch causing incorrect reaction calculations * Remove special treatment of photolysis reactions * Reinstate package-lock.json and change musica version * addressing pr comments --------- Co-authored-by: Kyle Shores <kyle.shores44@gmail.com>
1 parent 602a79d commit 32558ff

4 files changed

Lines changed: 248 additions & 31 deletions

File tree

javascript/src/music_box.js

Lines changed: 126 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,107 @@ import { initModule, MICM, SolverState } from '@ncar/musica';
22
import { parseBoxModelOptions, parseConditions, parseCsvToBlock } from './config_parser.js';
33
import { ConditionsManager } from './conditions_manager.js';
44

5+
function evaluateJsLambda(source, reactionName) {
6+
const trimmed = (source || '').trim();
7+
if (!trimmed) {
8+
throw new Error(`Lambda reaction "${reactionName}" is missing a \"lambda function\" value`);
9+
}
10+
11+
let fn;
12+
try {
13+
// NOTE: new Function() executes a string from the mechanism config.
14+
// Treat any mechanism config loaded from an untrusted source (e.g. browser uploads)
15+
// as equivalent to executing arbitrary code.
16+
fn = new Function(`return (${trimmed});`)();
17+
} catch (err) {
18+
const details = err instanceof Error ? err.message : String(err);
19+
throw new Error(
20+
`Lambda reaction "${reactionName}" must be a valid JavaScript function, e.g. (T, P, airDensity) => 1.0e-12. ${details}`,
21+
{ cause: err }
22+
);
23+
}
24+
25+
if (typeof fn !== 'function') {
26+
throw new Error(
27+
`Lambda reaction "${reactionName}" must evaluate to a function, e.g. (T, P, airDensity) => 1.0e-12`
28+
);
29+
}
30+
31+
return (T, P, airDensity) => {
32+
const value = fn(T, P, airDensity);
33+
if (typeof value !== 'number' || Number.isNaN(value)) {
34+
throw new Error(`Lambda reaction "${reactionName}" returned a non-numeric value`);
35+
}
36+
return value;
37+
};
38+
}
39+
40+
function defaultLambdaReactionName(reaction, index) {
41+
const lhs = Array.isArray(reaction.reactants)
42+
? reaction.reactants
43+
.map((component) => component?.['species name'] || component?.name)
44+
.filter(Boolean)
45+
.join('_')
46+
: '';
47+
const rhs = Array.isArray(reaction.products)
48+
? reaction.products
49+
.map((component) => component?.['species name'] || component?.name)
50+
.filter(Boolean)
51+
.join('_')
52+
: '';
53+
54+
if (lhs || rhs) {
55+
return `${lhs || 'reactants'}_to_${rhs || 'products'}`;
56+
}
57+
58+
return `lambda_reaction_${index + 1}`;
59+
}
60+
61+
function registerLambdaCallbacks(micm, mechanism) {
62+
const reactions = Array.isArray(mechanism?.reactions) ? mechanism.reactions : [];
63+
const lambdaReactions = reactions.filter((reaction) => reaction?.type === 'LAMBDA_RATE_CONSTANT');
64+
65+
for (const [index, reaction] of lambdaReactions.entries()) {
66+
const reactionName = (reaction.name || '').trim() || defaultLambdaReactionName(reaction, index);
67+
reaction.name = reactionName;
68+
const callback = evaluateJsLambda(reaction['lambda function'], reactionName);
69+
micm.setReactionRateCallback(`Lambda.${reactionName}`, callback);
70+
}
71+
}
72+
73+
function pruneUnknownRateParams(normalizedRateParams, acceptedRateParamKeys, warnedUnknownRateParams) {
74+
const filtered = { ...(normalizedRateParams || {}) };
75+
76+
if (!(acceptedRateParamKeys && acceptedRateParamKeys.size > 0)) {
77+
return filtered;
78+
}
79+
80+
for (const key of Object.keys(filtered)) {
81+
if (!acceptedRateParamKeys.has(key)) {
82+
if (!warnedUnknownRateParams.has(key)) {
83+
warnedUnknownRateParams.add(key);
84+
console.warn(
85+
`Ignoring unknown rate parameter key "${key}". ` +
86+
'Verify condition headers match mechanism-defined user parameters.'
87+
);
88+
}
89+
delete filtered[key];
90+
}
91+
}
92+
93+
return filtered;
94+
}
95+
96+
function normalizeRateParamsForSolver(rateParams, normalizerState) {
97+
if (!rateParams || typeof rateParams !== 'object') return {};
98+
99+
return pruneUnknownRateParams(
100+
rateParams,
101+
normalizerState.acceptedRateParamKeys,
102+
normalizerState.warnedUnknownRateParams
103+
);
104+
}
105+
5106
/**
6107
* JavaScript implementation of the music-box atmospheric chemistry box model.
7108
*
@@ -75,10 +176,15 @@ export class MusicBox {
75176
parseBoxModelOptions(this._config);
76177
const micm = MICM.fromMechanism({ getJSON: () => this._config.mechanism });
77178
const state = micm.createState(1);
179+
const normalizerState = {
180+
acceptedRateParamKeys: new Set(Object.keys(state.getUserDefinedRateParameters())),
181+
warnedUnknownRateParams: new Set(),
182+
};
78183

79184
try {
80-
const condsMgr = new ConditionsManager(parseConditions(this._config.conditions));
185+
registerLambdaCallbacks(micm, this._config.mechanism);
81186

187+
const condsMgr = new ConditionsManager(parseConditions(this._config.conditions));
82188
// Build sorted list of concentration event times (mirrors Python's sorted_event_times)
83189
const concentrationEvents = condsMgr.concentrationEvents;
84190
const sortedEventTimes = Object.keys(concentrationEvents)
@@ -96,9 +202,9 @@ export class MusicBox {
96202
nextEventIdx++;
97203
}
98204

99-
if (Object.keys(t0.rateParams).length > 0) {
100-
state.setUserDefinedRateParameters(t0.rateParams);
101-
}
205+
state.setUserDefinedRateParameters(
206+
normalizeRateParamsForSolver(t0.rateParams || {}, normalizerState)
207+
);
102208

103209
// Collect output as column arrays for efficient DataFrame construction
104210
const columns = { 'time.s': [] };
@@ -113,11 +219,21 @@ export class MusicBox {
113219
}
114220
}
115221

116-
appendOutput(0);
117222
let currTime = 0;
118-
let nextOutputTime = outputTimeStep;
223+
let nextOutputTime = 0;
224+
225+
outer: while (currTime <= simulationLength) {
226+
// Collect output at all configured output times that have been reached
227+
while (nextOutputTime <= currTime) {
228+
appendOutput(currTime);
229+
nextOutputTime += outputTimeStep;
230+
231+
// Bail out once we've emitted the last requested output timestamp.
232+
if (nextOutputTime > simulationLength) {
233+
break outer;
234+
}
235+
}
119236

120-
while (currTime < simulationLength) {
121237
// Apply any concentration events at or before current time
122238
while (
123239
nextEventIdx < sortedEventTimes.length &&
@@ -130,9 +246,9 @@ export class MusicBox {
130246
// Update environment and rate parameters at current time
131247
const conds = condsMgr.getConditionsAtTime(currTime);
132248
state.setConditions({ temperatures: conds.temperature, pressures: conds.pressure });
133-
if (Object.keys(conds.rateParams).length > 0) {
134-
state.setUserDefinedRateParameters(conds.rateParams);
135-
}
249+
state.setUserDefinedRateParameters(
250+
normalizeRateParamsForSolver(conds.rateParams || {}, normalizerState)
251+
);
136252

137253
// Integrate one chemistry step (may require multiple sub-steps)
138254
let elapsed = 0;
@@ -155,10 +271,6 @@ export class MusicBox {
155271
elapsed += result.stats.final_time;
156272
currTime += result.stats.final_time;
157273

158-
if (currTime >= nextOutputTime && nextOutputTime <= simulationLength) {
159-
appendOutput(currTime);
160-
nextOutputTime += outputTimeStep;
161-
}
162274
}
163275
}
164276

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it, before } from 'node:test';
3+
import { initModule } from '@ncar/musica';
4+
import { MusicBox } from '../../src/music_box.js';
5+
6+
before(async () => {
7+
await initModule();
8+
});
9+
10+
describe('MusicBox lambda reactions', () => {
11+
it('registers lambda callbacks and solves from v1 JSON', async () => {
12+
const config = {
13+
'box model options': {
14+
grid: 'box',
15+
'chemistry time step [sec]': 10,
16+
'output time step [sec]': 10,
17+
'simulation length [sec]': 60,
18+
},
19+
conditions: {
20+
data: [
21+
{
22+
headers: [
23+
'time.s',
24+
'ENV.temperature.K',
25+
'ENV.pressure.Pa',
26+
'CONC.A.mol m-3',
27+
'CONC.B.mol m-3',
28+
],
29+
rows: [[0, 298.15, 101325.0, 1.0, 0.0]],
30+
},
31+
],
32+
},
33+
mechanism: {
34+
version: '1.0.0',
35+
name: 'lambda-music-box-test',
36+
species: [{ name: 'A' }, { name: 'B' }],
37+
phases: [{ name: 'gas', species: ['A', 'B'] }],
38+
reactions: [
39+
{
40+
type: 'LAMBDA_RATE_CONSTANT',
41+
name: 'A_to_B',
42+
'gas phase': 'gas',
43+
'lambda function': '(T, P, airDensity) => 1.0e-3',
44+
reactants: [{ 'species name': 'A', coefficient: 1 }],
45+
products: [{ 'species name': 'B', coefficient: 1 }],
46+
},
47+
],
48+
},
49+
};
50+
51+
const box = MusicBox.fromJson(config);
52+
const results = await box.solve();
53+
54+
assert.ok(results.height > 0);
55+
assert.ok(results.columns.includes('CONC.A.mol m-3'));
56+
assert.ok(results.columns.includes('CONC.B.mol m-3'));
57+
58+
const aValues = results.data['CONC.A.mol m-3'];
59+
const bValues = results.data['CONC.B.mol m-3'];
60+
assert.ok(aValues[aValues.length - 1] < aValues[0], 'A should decrease over time');
61+
assert.ok(bValues[bValues.length - 1] > bValues[0], 'B should increase over time');
62+
});
63+
});

package-lock.json

Lines changed: 58 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)