@@ -2,6 +2,107 @@ import { initModule, MICM, SolverState } from '@ncar/musica';
22import { parseBoxModelOptions , parseConditions , parseCsvToBlock } from './config_parser.js' ;
33import { 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
0 commit comments