From 95a828f6ac1fd8eec60823c39c828f81001e7a3d Mon Sep 17 00:00:00 2001 From: Kyle Shores Date: Sun, 26 Jul 2026 10:48:03 -0500 Subject: [PATCH] Allow photolysis reactions with no reactants Make Photolysis.reactants a vector (at most one element) so a reactant-less photolysis is kept instead of dropped. Music Box Interactive encodes emissions as reactant-less photolysis (to carry an irr product); the v0 parser previously discarded them. The reaction stays a PHOTOLYSIS (keeping its PHOTO. rate-parameter label), so downstream configs that reference it by that label continue to work. - types::Photolysis::reactants: ReactionComponent -> vector (0 or 1) - v0 parser: keep the reaction with its (possibly empty) reactants - v1 parser: parse reactants as a list (schema already allowed 0-or-1) - validate + tests updated; v1 accepts an empty reactants list Addresses NCAR/musica#986. Pairs with a musica update to handle the list-valued photolysis reactants in the Python layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../types/reactions.hpp | 4 ++-- src/v0/photolysis_parser.cpp | 18 +++++++++--------- src/v1/reactions/photolysis.cpp | 2 +- src/validate.cpp | 2 +- test/unit/v0/test_photolysis_config.cpp | 16 +++++++++++++--- .../photolysis/valid/reactions.json | 8 ++++++++ .../photolysis/valid/reactions.yaml | 6 ++++++ .../v1/reactions/test_parse_photolysis.cpp | 8 ++++---- 8 files changed, 44 insertions(+), 20 deletions(-) diff --git a/include/mechanism_configuration/types/reactions.hpp b/include/mechanism_configuration/types/reactions.hpp index b2586ef8..a766e8a6 100644 --- a/include/mechanism_configuration/types/reactions.hpp +++ b/include/mechanism_configuration/types/reactions.hpp @@ -102,8 +102,8 @@ namespace mechanism_configuration::types { /// @brief Scaling factor to apply to user-provided rate constants double scaling_factor{ 1.0 }; - /// @brief A single reactant - ReactionComponent reactants; + /// @brief Reactants (at most one; empty for a pure production term, e.g. an emission) + std::vector reactants; /// @brief A list of products std::vector products; /// @brief An identifier, optional, uniqueness not enforced diff --git a/src/v0/photolysis_parser.cpp b/src/v0/photolysis_parser.cpp index 7340d591..2a5598ea 100644 --- a/src/v0/photolysis_parser.cpp +++ b/src/v0/photolysis_parser.cpp @@ -31,15 +31,15 @@ namespace mechanism_configuration::v0 errors.insert(errors.end(), parse_error.begin(), parse_error.end()); double scaling_factor = object[keys::SCALING_FACTOR] ? object[keys::SCALING_FACTOR].as() : 1.0; - - if (!reactants.empty()) - { - std::string name = object[keys::MUSICA_NAME].as(); - types::Photolysis user_defined = { - .scaling_factor = scaling_factor, .reactants = reactants[0], .products = products, .name = name - }; - mechanism.reactions.photolysis.push_back(user_defined); - } + std::string name = object[keys::MUSICA_NAME].as(); + + // Reactants may be empty: Music Box Interactive encodes emissions as a + // reactant-less photolysis (so they can carry an irr product). Keep the + // reaction rather than dropping it. + types::Photolysis user_defined = { + .scaling_factor = scaling_factor, .reactants = reactants, .products = products, .name = name + }; + mechanism.reactions.photolysis.push_back(user_defined); } return errors; diff --git a/src/v1/reactions/photolysis.cpp b/src/v1/reactions/photolysis.cpp index 7e56767b..ba10b0f4 100644 --- a/src/v1/reactions/photolysis.cpp +++ b/src/v1/reactions/photolysis.cpp @@ -92,7 +92,7 @@ namespace mechanism_configuration::v1 types::Photolysis photolysis; photolysis.gas_phase = object[keys::gas_phase].as(); - photolysis.reactants = ParseReactionComponent(object, keys::reactants); + photolysis.reactants = ParseReactionComponents(object, keys::reactants); photolysis.products = ParseReactionComponents(object, keys::products); photolysis.unknown_properties = GetComments(object); diff --git a/src/validate.cpp b/src/validate.cpp index aa78576e..7df60215 100644 --- a/src/validate.cpp +++ b/src/validate.cpp @@ -428,7 +428,7 @@ namespace mechanism_configuration for (const auto& x : r.first_order_loss) add("FIRST_ORDER_LOSS", x.gas_phase, Refs({ x.reactants }), Refs(x.products)); for (const auto& x : r.photolysis) - add("PHOTOLYSIS", x.gas_phase, Refs({ x.reactants }), Refs(x.products)); + add("PHOTOLYSIS", x.gas_phase, Refs(x.reactants), Refs(x.products)); for (const auto& x : r.surface) add("SURFACE", x.gas_phase, Refs({ x.gas_phase_species }), Refs(x.gas_phase_products)); for (const auto& x : r.branched) diff --git a/test/unit/v0/test_photolysis_config.cpp b/test/unit/v0/test_photolysis_config.cpp index e4f95812..97cc36fc 100644 --- a/test/unit/v0/test_photolysis_config.cpp +++ b/test/unit/v0/test_photolysis_config.cpp @@ -61,11 +61,11 @@ TEST(PhotolysisConfig, ParseConfig) Mechanism mechanism = *parsed; auto& process_vector = mechanism.reactions.photolysis; - EXPECT_EQ(process_vector.size(), 2); + EXPECT_EQ(process_vector.size(), 3); // first reaction { - EXPECT_EQ(process_vector[0].reactants.name, "foo"); + EXPECT_EQ(process_vector[0].reactants[0].name, "foo"); EXPECT_EQ(process_vector[0].products.size(), 2); EXPECT_EQ(process_vector[0].products[0].name, "bar"); EXPECT_EQ(process_vector[0].products[0].coefficient, 1.0); @@ -77,7 +77,7 @@ TEST(PhotolysisConfig, ParseConfig) // second reaction { - EXPECT_EQ(process_vector[1].reactants.name, "bar"); + EXPECT_EQ(process_vector[1].reactants[0].name, "bar"); EXPECT_EQ(process_vector[1].products.size(), 2); EXPECT_EQ(process_vector[1].products[0].name, "bar"); EXPECT_EQ(process_vector[1].products[0].coefficient, 0.5); @@ -86,6 +86,16 @@ TEST(PhotolysisConfig, ParseConfig) EXPECT_EQ(process_vector[1].name, "jbar"); EXPECT_EQ(process_vector[1].scaling_factor, 2.5); } + + // A photolysis reaction with no reactants (an emission encoded as photolysis) + // is kept, with an empty reactants list, rather than dropped. + { + EXPECT_EQ(process_vector[2].reactants.size(), 0); + ASSERT_EQ(process_vector[2].products.size(), 1); + EXPECT_EQ(process_vector[2].products[0].name, "foo"); + EXPECT_EQ(process_vector[2].products[0].coefficient, 1.0); + EXPECT_EQ(process_vector[2].name, "jemis"); + } } } diff --git a/test/unit/v0/v0_unit_configs/photolysis/valid/reactions.json b/test/unit/v0/v0_unit_configs/photolysis/valid/reactions.json index dc4dec9a..a3e2a93b 100644 --- a/test/unit/v0/v0_unit_configs/photolysis/valid/reactions.json +++ b/test/unit/v0/v0_unit_configs/photolysis/valid/reactions.json @@ -26,6 +26,14 @@ }, "MUSICA name": "jbar", "scaling factor": 2.5 + }, + { + "type": "PHOTOLYSIS", + "reactants": { }, + "products": { + "foo": { "yield": 1.0 } + }, + "MUSICA name": "jemis" } ] } diff --git a/test/unit/v0/v0_unit_configs/photolysis/valid/reactions.yaml b/test/unit/v0/v0_unit_configs/photolysis/valid/reactions.yaml index 776b8c87..517e1b71 100644 --- a/test/unit/v0/v0_unit_configs/photolysis/valid/reactions.yaml +++ b/test/unit/v0/v0_unit_configs/photolysis/valid/reactions.yaml @@ -19,4 +19,10 @@ camp-data: bar: {} scaling factor: 2.5 type: PHOTOLYSIS + - MUSICA name: jemis + products: + foo: + yield: 1.0 + reactants: {} + type: PHOTOLYSIS type: MECHANISM diff --git a/test/unit/v1/reactions/test_parse_photolysis.cpp b/test/unit/v1/reactions/test_parse_photolysis.cpp index 129a8479..eb209f8e 100644 --- a/test/unit/v1/reactions/test_parse_photolysis.cpp +++ b/test/unit/v1/reactions/test_parse_photolysis.cpp @@ -24,8 +24,8 @@ TEST(ParserBase, CanParseValidPhotolysisReaction) EXPECT_EQ(mechanism.reactions.photolysis[0].gas_phase, "gas"); EXPECT_EQ(mechanism.reactions.photolysis[0].name, "my photolysis"); EXPECT_EQ(mechanism.reactions.photolysis[0].scaling_factor, 12.3); - EXPECT_EQ(mechanism.reactions.photolysis[0].reactants.name, "B"); - EXPECT_EQ(mechanism.reactions.photolysis[0].reactants.coefficient, 1); + EXPECT_EQ(mechanism.reactions.photolysis[0].reactants[0].name, "B"); + EXPECT_EQ(mechanism.reactions.photolysis[0].reactants[0].coefficient, 1); EXPECT_EQ(mechanism.reactions.photolysis[0].products.size(), 1); EXPECT_EQ(mechanism.reactions.photolysis[0].products[0].name, "C"); EXPECT_EQ(mechanism.reactions.photolysis[0].products[0].coefficient, 1); @@ -34,8 +34,8 @@ TEST(ParserBase, CanParseValidPhotolysisReaction) EXPECT_EQ(mechanism.reactions.photolysis[1].gas_phase, "gas"); EXPECT_EQ(mechanism.reactions.photolysis[1].scaling_factor, 1); - EXPECT_EQ(mechanism.reactions.photolysis[1].reactants.name, "B"); - EXPECT_EQ(mechanism.reactions.photolysis[1].reactants.coefficient, 1.2); + EXPECT_EQ(mechanism.reactions.photolysis[1].reactants[0].name, "B"); + EXPECT_EQ(mechanism.reactions.photolysis[1].reactants[0].coefficient, 1.2); EXPECT_EQ(mechanism.reactions.photolysis[1].products.size(), 1); EXPECT_EQ(mechanism.reactions.photolysis[1].products[0].name, "C"); EXPECT_EQ(mechanism.reactions.photolysis[1].products[0].coefficient, 0.2);