From 7dd42713fb542dd358406ac7ccf8646edf26770b Mon Sep 17 00:00:00 2001 From: Scott Meech Date: Thu, 25 Jun 2026 11:18:36 -0600 Subject: [PATCH 1/6] feat: add emissions::v1 config parser for MIEM integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new emissions configuration schema under mechanism_configuration::emissions::v1, parallel to the existing chemistry schema. This is the MC side of the MIEM config pipeline described in NCAR/miem#272. New public API: ParseEmissions(path) / ParseEmissions(string) → std::expected EmissionsConfig holds inventories, species maps, regridding settings, and a list of SourceDescriptors. Musica's translator will resolve these into miem::Source structs and drive miem::EmissionsBuilder. Load-time validation covers all v1 invariants: kind/version dispatch, cross-reference checks (inventory and species map names), duplicate source names, duplicate (category, hierarchy) pairs, scaling factor sums ≤ 1.0, and hard rejections for mode: online, vertical injection: plume, and regridding type: scrip (none supported in v1). Note for MC maintainers: yaml-cpp is currently linked PUBLIC in src/CMakeLists.txt, which causes it to leak into downstream consumers. Demoting it to PRIVATE (and moving the three public headers that include yaml-cpp into src/internal/) would be a clean follow-up. Co-Authored-By: Claude Sonnet 4.6 --- examples/emissions/v1/emissions.yaml | 68 ++ .../emissions/v1/parse.hpp | 26 + .../emissions/v1/types.hpp | 98 +++ include/mechanism_configuration/errors.hpp | 13 + src/CMakeLists.txt | 1 + src/emissions/CMakeLists.txt | 1 + src/emissions/v1/CMakeLists.txt | 4 + src/emissions/v1/keys.hpp | 72 +++ src/emissions/v1/parser.cpp | 606 ++++++++++++++++++ src/emissions/v1/parser.hpp | 37 ++ src/errors.cpp | 12 + test/unit/CMakeLists.txt | 1 + test/unit/emissions/CMakeLists.txt | 1 + test/unit/emissions/v1/CMakeLists.txt | 12 + .../duplicate_category_hierarchy.yaml | 30 + .../duplicate_source.yaml | 30 + .../emissions_unit_configs/missing_kind.yaml | 8 + .../emissions_unit_configs/online_source.yaml | 23 + .../scaling_exceeds_one.yaml | 20 + .../unknown_inventory.yaml | 23 + .../unknown_species_map.yaml | 23 + .../unsupported_regridding.yaml | 20 + .../unsupported_vertical_injection.yaml | 24 + .../v1/emissions_unit_configs/valid.yaml | 35 + .../v1/emissions_unit_configs/wrong_kind.yaml | 6 + .../emissions/v1/test_parse_emissions.cpp | 198 ++++++ 26 files changed, 1392 insertions(+) create mode 100644 examples/emissions/v1/emissions.yaml create mode 100644 include/mechanism_configuration/emissions/v1/parse.hpp create mode 100644 include/mechanism_configuration/emissions/v1/types.hpp create mode 100644 src/emissions/CMakeLists.txt create mode 100644 src/emissions/v1/CMakeLists.txt create mode 100644 src/emissions/v1/keys.hpp create mode 100644 src/emissions/v1/parser.cpp create mode 100644 src/emissions/v1/parser.hpp create mode 100644 test/unit/emissions/CMakeLists.txt create mode 100644 test/unit/emissions/v1/CMakeLists.txt create mode 100644 test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/missing_kind.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/online_source.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/valid.yaml create mode 100644 test/unit/emissions/v1/emissions_unit_configs/wrong_kind.yaml create mode 100644 test/unit/emissions/v1/test_parse_emissions.cpp diff --git a/examples/emissions/v1/emissions.yaml b/examples/emissions/v1/emissions.yaml new file mode 100644 index 00000000..077eb9ca --- /dev/null +++ b/examples/emissions/v1/emissions.yaml @@ -0,0 +1,68 @@ +kind: emissions +version: 1.0.0 +name: Example MIEM emissions configuration + +# Environment-expandable data root. POSIX ${VAR} / ${VAR:-default} syntax. +# The parser expands here; MIEM receives a fully-resolved path string. +data root: ${EMIS_DATA_ROOT:-/glade/derecho/scratch/${USER}/emissions} + +# Inventories: named I/O metadata. Offline sources reference by name. +# Moving all sources to a new machine is a one-line edit to `data root`. +inventories: + cams global anthro: + directory: cams/v6.2 + file pattern: CAMS-GLOB-ANT_v6.2_{YYYY}-{MM}.nc + convention: uptempo + finn fires: + directory: finn/v2.5 + file pattern: FINNv2.5_{YYYY}{MM}{DD}.nc + convention: uptempo + +# Species maps: inventory species → mechanism species mappings, referenced by name. +# Scaling factors per inventory species must sum to ≤ 1.0 (mass conservation). +species maps: + MOZART-T1 from CAMS: + mappings: + - inventory species: NOx + mechanism species: NO + scaling factor: 0.9 + - inventory species: NOx + mechanism species: NO2 + scaling factor: 0.1 + - inventory species: CO + mechanism species: CO + - inventory species: bc_anth_sum + mechanism species: BC + +# Regridding: v1 supports only `type: none` (data already on the model mesh). +# Future: `type: scrip` for ESMF/SCRIP weight-file remapping. +regridding: + type: none + +# Sources: the emission signals. HEMCO-style category/hierarchy aggregation. +# Sources in different categories sum; within a category the highest hierarchy +# wins per cell. Duplicate (category, hierarchy) is a load-time error. +sources: + - name: cams anthro + mode: offline + type: anthropogenic + inventory: cams global anthro + species map: MOZART-T1 from CAMS + temporal interpolation: linear + vertical injection: surface + category: 0 + hierarchy: 1 + scaling factor: 1.0 + sector: anthropogenic + + - name: finn fires + mode: offline + type: fire + inventory: finn fires + species map: MOZART-T1 from CAMS + temporal interpolation: nearest + vertical injection: surface + category: 1 + hierarchy: 1 + sector: fire + __notes: "FINN v2.5 daily fire emissions" diff --git a/include/mechanism_configuration/emissions/v1/parse.hpp b/include/mechanism_configuration/emissions/v1/parse.hpp new file mode 100644 index 00000000..718c9208 --- /dev/null +++ b/include/mechanism_configuration/emissions/v1/parse.hpp @@ -0,0 +1,26 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include +#include +#include + +namespace mechanism_configuration::emissions::v1 +{ + /// @brief Parse an emissions configuration file. + /// @param config_path Path to a YAML emissions configuration file. + /// @return The parsed EmissionsConfig, or all structural and semantic errors encountered. + std::expected ParseEmissions(const std::filesystem::path& config_path); + + /// @brief Parse an emissions configuration from an in-memory YAML string. + /// @param content The YAML document contents (not a file path). + /// @return The parsed EmissionsConfig, or all structural and semantic errors encountered. + std::expected ParseEmissions(const std::string& content); + +} // namespace mechanism_configuration::emissions::v1 diff --git a/include/mechanism_configuration/emissions/v1/types.hpp b/include/mechanism_configuration/emissions/v1/types.hpp new file mode 100644 index 00000000..df1dd26e --- /dev/null +++ b/include/mechanism_configuration/emissions/v1/types.hpp @@ -0,0 +1,98 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include +#include +#include + +namespace mechanism_configuration::emissions::v1::types +{ + struct SpeciesMapping + { + std::string inventory_species; + std::string mechanism_species; + double scaling_factor{ 1.0 }; + }; + + struct SpeciesMap + { + std::vector mappings; + }; + + struct Inventory + { + std::string directory; + std::string file_pattern; + std::string convention; + }; + + enum class SourceMode + { + Offline, + }; + + enum class SourceType + { + Anthropogenic, + Fire, + Biogenic, + Dust, + SeaSalt, + Lightning, + }; + + enum class TemporalInterpolation + { + Linear, + Nearest, + None, + }; + + enum class VerticalInjection + { + Surface, + }; + + struct SourceDescriptor + { + std::string name; + SourceMode mode{ SourceMode::Offline }; + SourceType type{ SourceType::Anthropogenic }; + std::string inventory; + std::string species_map; + TemporalInterpolation temporal_interpolation{ TemporalInterpolation::Linear }; + VerticalInjection vertical_injection{ VerticalInjection::Surface }; + int category{ 0 }; + int hierarchy{ 1 }; + double scaling_factor{ 1.0 }; + std::string sector; + std::unordered_map unknown_properties; + }; + + enum class RegriddingType + { + None, + }; + + struct Regridding + { + RegriddingType type{ RegriddingType::None }; + }; + + struct EmissionsConfig + { + Version version; + std::string name; + std::string data_root; + std::unordered_map inventories; + std::unordered_map species_maps; + Regridding regridding; + std::vector sources; + }; + +} // namespace mechanism_configuration::emissions::v1::types diff --git a/include/mechanism_configuration/errors.hpp b/include/mechanism_configuration/errors.hpp index a838f9ea..ea22f8c6 100644 --- a/include/mechanism_configuration/errors.hpp +++ b/include/mechanism_configuration/errors.hpp @@ -53,6 +53,19 @@ namespace mechanism_configuration FileNotFound, UnexpectedError, EmptyObject, + // Emissions-specific error codes + NotAnEmissionsConfig, + DuplicateInventoryDetected, + DuplicateSpeciesMapDetected, + DuplicateSourceDetected, + DuplicateCategoryHierarchy, + SourceRequiresUnknownInventory, + SourceRequiresUnknownSpeciesMap, + SpeciesMapScalingExceedsOne, + UndefinedEnvironmentVariable, + OnlineSourcesNotSupported, + UnsupportedRegriddingType, + UnsupportedVerticalInjection, }; std::string ErrorCodeToString(const ErrorCode& status); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index be92c6e0..393afe85 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -43,6 +43,7 @@ target_include_directories(mechanism_configuration add_subdirectory(v0) add_subdirectory(v1) +add_subdirectory(emissions) target_link_libraries(mechanism_configuration PUBLIC diff --git a/src/emissions/CMakeLists.txt b/src/emissions/CMakeLists.txt new file mode 100644 index 00000000..86a611cc --- /dev/null +++ b/src/emissions/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(v1) diff --git a/src/emissions/v1/CMakeLists.txt b/src/emissions/v1/CMakeLists.txt new file mode 100644 index 00000000..3a995d22 --- /dev/null +++ b/src/emissions/v1/CMakeLists.txt @@ -0,0 +1,4 @@ +target_sources(mechanism_configuration + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/parser.cpp +) diff --git a/src/emissions/v1/keys.hpp b/src/emissions/v1/keys.hpp new file mode 100644 index 00000000..ab43c48f --- /dev/null +++ b/src/emissions/v1/keys.hpp @@ -0,0 +1,72 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +namespace mechanism_configuration::emissions::v1::keys +{ + // Top-level + inline constexpr std::string_view kind = "kind"; + inline constexpr std::string_view kind_value = "emissions"; + inline constexpr std::string_view version = "version"; + inline constexpr std::string_view name = "name"; + inline constexpr std::string_view data_root = "data root"; + + // Sections + inline constexpr std::string_view inventories = "inventories"; + inline constexpr std::string_view species_maps = "species maps"; + inline constexpr std::string_view regridding = "regridding"; + inline constexpr std::string_view sources = "sources"; + + // Inventory entry + inline constexpr std::string_view directory = "directory"; + inline constexpr std::string_view file_pattern = "file pattern"; + inline constexpr std::string_view convention = "convention"; + + // Species map entry + inline constexpr std::string_view mappings = "mappings"; + inline constexpr std::string_view inventory_species = "inventory species"; + inline constexpr std::string_view mechanism_species = "mechanism species"; + inline constexpr std::string_view scaling_factor = "scaling factor"; + + // Regridding entry + inline constexpr std::string_view type = "type"; + inline constexpr std::string_view regridding_none = "none"; + inline constexpr std::string_view regridding_scrip = "scrip"; + + // Source descriptor entry + inline constexpr std::string_view mode = "mode"; + inline constexpr std::string_view source_type = "type"; + inline constexpr std::string_view inventory = "inventory"; + inline constexpr std::string_view species_map = "species map"; + inline constexpr std::string_view temporal_interpolation = "temporal interpolation"; + inline constexpr std::string_view vertical_injection = "vertical injection"; + inline constexpr std::string_view category = "category"; + inline constexpr std::string_view hierarchy = "hierarchy"; + inline constexpr std::string_view sector = "sector"; + + // Mode values + inline constexpr std::string_view mode_offline = "offline"; + inline constexpr std::string_view mode_online = "online"; + + // Source type values + inline constexpr std::string_view type_anthropogenic = "anthropogenic"; + inline constexpr std::string_view type_fire = "fire"; + inline constexpr std::string_view type_biogenic = "biogenic"; + inline constexpr std::string_view type_dust = "dust"; + inline constexpr std::string_view type_sea_salt = "sea salt"; + inline constexpr std::string_view type_lightning = "lightning"; + + // Temporal interpolation values + inline constexpr std::string_view interp_linear = "linear"; + inline constexpr std::string_view interp_nearest = "nearest"; + inline constexpr std::string_view interp_none = "none"; + + // Vertical injection values + inline constexpr std::string_view inject_surface = "surface"; + inline constexpr std::string_view inject_plume = "plume"; + +} // namespace mechanism_configuration::emissions::v1::keys diff --git a/src/emissions/v1/parser.cpp b/src/emissions/v1/parser.cpp new file mode 100644 index 00000000..1f427f6b --- /dev/null +++ b/src/emissions/v1/parser.cpp @@ -0,0 +1,606 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#include "emissions/v1/parser.hpp" + +#include "emissions/v1/keys.hpp" + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace mechanism_configuration::emissions::v1 +{ + namespace + { + void AppendFilePath(const std::string& config_path, Errors& errors) + { + if (config_path.empty()) + return; + for (auto& [code, msg] : errors) + if (msg.find(config_path) == std::string::npos) + msg = config_path + ": " + msg; + } + + // Parse all __ -prefixed keys as unknown properties. + std::unordered_map GetUnknownProperties(const YAML::Node& node) + { + std::unordered_map props; + for (const auto& kv : node) + { + std::string k = kv.first.as(); + if (k.size() >= 2 && k[0] == '_' && k[1] == '_') + props[k] = kv.second.as(); + } + return props; + } + + types::SourceType ParseSourceType(const std::string& s) + { + if (s == keys::type_anthropogenic) return types::SourceType::Anthropogenic; + if (s == keys::type_fire) return types::SourceType::Fire; + if (s == keys::type_biogenic) return types::SourceType::Biogenic; + if (s == keys::type_dust) return types::SourceType::Dust; + if (s == keys::type_sea_salt) return types::SourceType::SeaSalt; + if (s == keys::type_lightning) return types::SourceType::Lightning; + return types::SourceType::Anthropogenic; // validated separately + } + + types::TemporalInterpolation ParseTemporalInterpolation(const std::string& s) + { + if (s == keys::interp_linear) return types::TemporalInterpolation::Linear; + if (s == keys::interp_nearest) return types::TemporalInterpolation::Nearest; + if (s == keys::interp_none) return types::TemporalInterpolation::None; + return types::TemporalInterpolation::Linear; // validated separately + } + } // namespace + + std::string Parser::ExpandEnvVars(const std::string& input, Errors& errors) const + { + std::string result; + result.reserve(input.size()); + size_t i = 0; + while (i < input.size()) + { + if (input[i] == '$' && i + 1 < input.size() && input[i + 1] == '{') + { + size_t end = input.find('}', i + 2); + if (end == std::string::npos) + { + errors.push_back({ ErrorCode::InvalidKey, "Unterminated ${...} in 'data root'" }); + return result; + } + const std::string var_expr = input.substr(i + 2, end - i - 2); + const auto sep = var_expr.find(":-"); + std::string var_name, default_val; + bool has_default = false; + if (sep != std::string::npos) + { + var_name = var_expr.substr(0, sep); + default_val = var_expr.substr(sep + 2); + has_default = true; + } + else + { + var_name = var_expr; + } + const char* val = std::getenv(var_name.c_str()); + if (val) + { + result += val; + } + else if (has_default) + { + result += default_val; + } + else + { + errors.push_back({ ErrorCode::UndefinedEnvironmentVariable, + mc_fmt::format("Environment variable '{}' is not set and has no default", var_name) }); + return result; + } + i = end + 1; + } + else + { + result += input[i++]; + } + } + return result; + } + + std::expected Parser::ValidateAndBuild(const YAML::Node& object) + { + try + { + Errors errors; + + // ── kind ────────────────────────────────────────────────────────────────── + if (!object[std::string(keys::kind)]) + { + errors.push_back({ ErrorCode::NotAnEmissionsConfig, + "Missing 'kind' field; emissions configs must declare 'kind: emissions'" }); + AppendFilePath(config_path_, errors); + return std::unexpected(std::move(errors)); + } + const std::string kind_val = object[std::string(keys::kind)].as(); + if (kind_val != std::string(keys::kind_value)) + { + const ErrorLocation loc{ object[std::string(keys::kind)].Mark().line, + object[std::string(keys::kind)].Mark().column }; + errors.push_back({ ErrorCode::NotAnEmissionsConfig, + mc_fmt::format("{} error: Expected 'kind: emissions', found 'kind: {}'", loc, kind_val) }); + AppendFilePath(config_path_, errors); + return std::unexpected(std::move(errors)); + } + + // ── version ─────────────────────────────────────────────────────────────── + if (!object[std::string(keys::version)]) + { + errors.push_back({ ErrorCode::MissingVersionField, "Missing required 'version' field" }); + AppendFilePath(config_path_, errors); + return std::unexpected(std::move(errors)); + } + const Version version(object[std::string(keys::version)].as()); + constexpr unsigned int SUPPORTED_MAJOR = 1; + if (version.major != SUPPORTED_MAJOR) + { + const ErrorLocation loc{ object[std::string(keys::version)].Mark().line, + object[std::string(keys::version)].Mark().column }; + errors.push_back({ ErrorCode::InvalidVersion, + mc_fmt::format( + "{} error: Unsupported emissions config version '{}'; only major version {} is supported", + loc, + version.to_string(), + SUPPORTED_MAJOR) }); + AppendFilePath(config_path_, errors); + return std::unexpected(std::move(errors)); + } + + types::EmissionsConfig config; + config.version = version; + + // ── name (optional) ─────────────────────────────────────────────────────── + if (object[std::string(keys::name)]) + config.name = object[std::string(keys::name)].as(); + + // ── data root (optional) ────────────────────────────────────────────────── + if (object[std::string(keys::data_root)]) + { + const std::string raw = object[std::string(keys::data_root)].as(); + config.data_root = ExpandEnvVars(raw, errors); + if (!errors.empty()) + { + AppendFilePath(config_path_, errors); + return std::unexpected(std::move(errors)); + } + } + + // ── inventories (optional) ──────────────────────────────────────────────── + if (object[std::string(keys::inventories)]) + { + const YAML::Node& inv_node = object[std::string(keys::inventories)]; + if (!inv_node.IsMap()) + { + errors.push_back({ ErrorCode::InvalidType, "'inventories' must be a mapping" }); + } + else + { + for (const auto& kv : inv_node) + { + const std::string inv_name = kv.first.as(); + if (config.inventories.count(inv_name)) + { + errors.push_back( + { ErrorCode::DuplicateInventoryDetected, + mc_fmt::format("Duplicate inventory name '{}'", inv_name) }); + continue; + } + const YAML::Node& entry = kv.second; + types::Inventory inv; + if (!entry[std::string(keys::directory)]) + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Inventory '{}' is missing required key 'directory'", inv_name) }); + else + inv.directory = entry[std::string(keys::directory)].as(); + + if (!entry[std::string(keys::file_pattern)]) + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Inventory '{}' is missing required key 'file pattern'", inv_name) }); + else + inv.file_pattern = entry[std::string(keys::file_pattern)].as(); + + if (!entry[std::string(keys::convention)]) + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Inventory '{}' is missing required key 'convention'", inv_name) }); + else + inv.convention = entry[std::string(keys::convention)].as(); + + config.inventories.emplace(inv_name, std::move(inv)); + } + } + } + + // ── species maps (optional) ─────────────────────────────────────────────── + if (object[std::string(keys::species_maps)]) + { + const YAML::Node& sm_node = object[std::string(keys::species_maps)]; + if (!sm_node.IsMap()) + { + errors.push_back({ ErrorCode::InvalidType, "'species maps' must be a mapping" }); + } + else + { + for (const auto& kv : sm_node) + { + const std::string map_name = kv.first.as(); + if (config.species_maps.count(map_name)) + { + errors.push_back( + { ErrorCode::DuplicateSpeciesMapDetected, + mc_fmt::format("Duplicate species map name '{}'", map_name) }); + continue; + } + const YAML::Node& entry = kv.second; + types::SpeciesMap smap; + + if (!entry[std::string(keys::mappings)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Species map '{}' is missing required key 'mappings'", map_name) }); + config.species_maps.emplace(map_name, std::move(smap)); + continue; + } + + // Accumulate scaling factors per inventory species to validate the ≤1.0 constraint. + std::unordered_map inv_species_scale_sum; + + for (const auto& mapping_node : entry[std::string(keys::mappings)]) + { + types::SpeciesMapping m; + if (!mapping_node[std::string(keys::inventory_species)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("A mapping in species map '{}' is missing 'inventory species'", map_name) }); + continue; + } + if (!mapping_node[std::string(keys::mechanism_species)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("A mapping in species map '{}' is missing 'mechanism species'", map_name) }); + continue; + } + m.inventory_species = mapping_node[std::string(keys::inventory_species)].as(); + m.mechanism_species = mapping_node[std::string(keys::mechanism_species)].as(); + if (mapping_node[std::string(keys::scaling_factor)]) + m.scaling_factor = mapping_node[std::string(keys::scaling_factor)].as(); + + inv_species_scale_sum[m.inventory_species] += m.scaling_factor; + smap.mappings.push_back(std::move(m)); + } + + // Validate scaling factor sums ≤ 1.0 + for (const auto& [inv_sp, total] : inv_species_scale_sum) + { + if (total > 1.0 + 1e-9) + { + errors.push_back( + { ErrorCode::SpeciesMapScalingExceedsOne, + mc_fmt::format( + "Species map '{}': scaling factors for inventory species '{}' sum to {:.4f}, which exceeds 1.0", + map_name, + inv_sp, + total) }); + } + } + config.species_maps.emplace(map_name, std::move(smap)); + } + } + } + + // ── regridding (optional) ───────────────────────────────────────────────── + if (object[std::string(keys::regridding)]) + { + const YAML::Node& rg_node = object[std::string(keys::regridding)]; + if (rg_node[std::string(keys::type)]) + { + const std::string rg_type = rg_node[std::string(keys::type)].as(); + if (rg_type == std::string(keys::regridding_none)) + { + config.regridding.type = types::RegriddingType::None; + } + else + { + const ErrorLocation loc{ rg_node[std::string(keys::type)].Mark().line, + rg_node[std::string(keys::type)].Mark().column }; + errors.push_back( + { ErrorCode::UnsupportedRegriddingType, + mc_fmt::format( + "{} error: Unsupported regridding type '{}'; only 'none' is supported in v1", loc, rg_type) }); + } + } + } + + // ── sources (optional) ──────────────────────────────────────────────────── + if (object[std::string(keys::sources)]) + { + const YAML::Node& src_node = object[std::string(keys::sources)]; + if (!src_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'sources' must be a sequence" }); + } + else + { + std::set seen_names; + std::set> seen_cat_hier; + + for (const auto& s : src_node) + { + types::SourceDescriptor src; + + // name (required) + if (!s[std::string(keys::name)]) + { + errors.push_back({ ErrorCode::RequiredKeyNotFound, "A source entry is missing required key 'name'" }); + continue; + } + src.name = s[std::string(keys::name)].as(); + + if (seen_names.count(src.name)) + { + errors.push_back( + { ErrorCode::DuplicateSourceDetected, + mc_fmt::format("Duplicate source name '{}'", src.name) }); + continue; + } + seen_names.insert(src.name); + + // mode (required) + if (!s[std::string(keys::mode)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Source '{}' is missing required key 'mode'", src.name) }); + } + else + { + const std::string mode_val = s[std::string(keys::mode)].as(); + if (mode_val == std::string(keys::mode_online)) + { + const ErrorLocation loc{ s[std::string(keys::mode)].Mark().line, + s[std::string(keys::mode)].Mark().column }; + errors.push_back( + { ErrorCode::OnlineSourcesNotSupported, + mc_fmt::format( + "{} error: Source '{}' uses 'mode: online', which is not supported in v1", + loc, + src.name) }); + } + else if (mode_val != std::string(keys::mode_offline)) + { + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format("Source '{}' has unknown mode '{}'; expected 'offline'", src.name, mode_val) }); + } + // mode_offline is the only valid value; src.mode is already Offline by default + } + + // type (required) + if (!s[std::string(keys::source_type)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Source '{}' is missing required key 'type'", src.name) }); + } + else + { + const std::string type_val = s[std::string(keys::source_type)].as(); + static const std::vector valid_types = { keys::type_anthropogenic, keys::type_fire, + keys::type_biogenic, keys::type_dust, + keys::type_sea_salt, keys::type_lightning }; + bool found = false; + for (const auto& vt : valid_types) + if (type_val == std::string(vt)) + found = true; + if (!found) + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format("Source '{}' has unknown type '{}'", src.name, type_val) }); + else + src.type = ParseSourceType(type_val); + } + + // inventory (required) + if (!s[std::string(keys::inventory)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Source '{}' is missing required key 'inventory'", src.name) }); + } + else + { + src.inventory = s[std::string(keys::inventory)].as(); + if (!config.inventories.empty() && !config.inventories.count(src.inventory)) + { + errors.push_back( + { ErrorCode::SourceRequiresUnknownInventory, + mc_fmt::format( + "Source '{}' references inventory '{}' which is not declared in 'inventories'", + src.name, + src.inventory) }); + } + } + + // species map (required) + if (!s[std::string(keys::species_map)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Source '{}' is missing required key 'species map'", src.name) }); + } + else + { + src.species_map = s[std::string(keys::species_map)].as(); + if (!config.species_maps.empty() && !config.species_maps.count(src.species_map)) + { + errors.push_back( + { ErrorCode::SourceRequiresUnknownSpeciesMap, + mc_fmt::format( + "Source '{}' references species map '{}' which is not declared in 'species maps'", + src.name, + src.species_map) }); + } + } + + // temporal interpolation (optional) + if (s[std::string(keys::temporal_interpolation)]) + src.temporal_interpolation = + ParseTemporalInterpolation(s[std::string(keys::temporal_interpolation)].as()); + + // vertical injection (optional) + if (s[std::string(keys::vertical_injection)]) + { + const std::string vi_val = s[std::string(keys::vertical_injection)].as(); + if (vi_val == std::string(keys::inject_plume)) + { + const ErrorLocation loc{ s[std::string(keys::vertical_injection)].Mark().line, + s[std::string(keys::vertical_injection)].Mark().column }; + errors.push_back( + { ErrorCode::UnsupportedVerticalInjection, + mc_fmt::format( + "{} error: Source '{}' uses 'vertical injection: plume', which is not supported in v1", + loc, + src.name) }); + } + else if (vi_val != std::string(keys::inject_surface)) + { + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format( + "Source '{}' has unknown vertical injection '{}'; expected 'surface'", src.name, vi_val) }); + } + } + + // category, hierarchy, scaling factor, sector (all optional) + if (s[std::string(keys::category)]) + src.category = s[std::string(keys::category)].as(); + if (s[std::string(keys::hierarchy)]) + src.hierarchy = s[std::string(keys::hierarchy)].as(); + if (s[std::string(keys::scaling_factor)]) + src.scaling_factor = s[std::string(keys::scaling_factor)].as(); + if (s[std::string(keys::sector)]) + src.sector = s[std::string(keys::sector)].as(); + + // duplicate (category, hierarchy) + const auto cat_hier = std::make_pair(src.category, src.hierarchy); + if (seen_cat_hier.count(cat_hier)) + { + errors.push_back( + { ErrorCode::DuplicateCategoryHierarchy, + mc_fmt::format( + "Source '{}' has duplicate (category: {}, hierarchy: {}) — each (category, hierarchy) pair must be unique", + src.name, + src.category, + src.hierarchy) }); + } + else + { + seen_cat_hier.insert(cat_hier); + } + + src.unknown_properties = GetUnknownProperties(s); + config.sources.push_back(std::move(src)); + } + } + } + + if (!errors.empty()) + { + AppendFilePath(config_path_, errors); + return std::unexpected(std::move(errors)); + } + + return config; + } + catch (const std::exception& e) + { + const std::string where = config_path_.empty() ? "document" : "'" + config_path_ + "'"; + return std::unexpected( + Errors{ { ErrorCode::UnexpectedError, + mc_fmt::format("Failed to parse emissions config {}: {}", where, e.what()) } }); + } + } + + std::expected Parser::Parse(const std::filesystem::path& config_path) + { + config_path_ = config_path.string(); + + if (!std::filesystem::exists(config_path)) + { + return std::unexpected(Errors{ { ErrorCode::FileNotFound, + mc_fmt::format("Configuration file '{}' does not exist.", config_path.string()) } }); + } + + YAML::Node object; + try + { + object = YAML::LoadFile(config_path.string()); + } + catch (const std::exception& e) + { + return std::unexpected( + Errors{ { ErrorCode::UnexpectedError, + mc_fmt::format("Failed to parse '{}': {}", config_path.string(), e.what()) } }); + } + + return ValidateAndBuild(object); + } + + std::expected Parser::Parse(const std::string& content) + { + config_path_ = ""; + + YAML::Node object; + try + { + object = YAML::Load(content); + } + catch (const std::exception& e) + { + return std::unexpected( + Errors{ { ErrorCode::UnexpectedError, + mc_fmt::format("Failed to parse emissions document: {}", e.what()) } }); + } + + return ValidateAndBuild(object); + } + + // ── Public free functions ────────────────────────────────────────────────── + + std::expected ParseEmissions(const std::filesystem::path& config_path) + { + return Parser{}.Parse(config_path); + } + + std::expected ParseEmissions(const std::string& content) + { + return Parser{}.Parse(content); + } + +} // namespace mechanism_configuration::emissions::v1 diff --git a/src/emissions/v1/parser.hpp b/src/emissions/v1/parser.hpp new file mode 100644 index 00000000..d2b09d76 --- /dev/null +++ b/src/emissions/v1/parser.hpp @@ -0,0 +1,37 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include + +#include +#include +#include + +namespace mechanism_configuration::emissions::v1 +{ + class Parser + { + public: + Parser() = default; + + /// @brief Parse an emissions configuration from a file. + std::expected Parse(const std::filesystem::path& config_path); + + /// @brief Parse an emissions configuration from an in-memory YAML string. + std::expected Parse(const std::string& content); + + private: + std::string config_path_; + + std::expected ValidateAndBuild(const YAML::Node& object); + + std::string ExpandEnvVars(const std::string& input, Errors& errors) const; + }; + +} // namespace mechanism_configuration::emissions::v1 diff --git a/src/errors.cpp b/src/errors.cpp index 49875267..161fd22d 100644 --- a/src/errors.cpp +++ b/src/errors.cpp @@ -34,6 +34,18 @@ namespace mechanism_configuration case ErrorCode::FileNotFound: return "FileNotFound"; case ErrorCode::UnexpectedError: return "UnexpectedError"; case ErrorCode::EmptyObject: return "EmptyObject"; + case ErrorCode::NotAnEmissionsConfig: return "NotAnEmissionsConfig"; + case ErrorCode::DuplicateInventoryDetected: return "DuplicateInventoryDetected"; + case ErrorCode::DuplicateSpeciesMapDetected: return "DuplicateSpeciesMapDetected"; + case ErrorCode::DuplicateSourceDetected: return "DuplicateSourceDetected"; + case ErrorCode::DuplicateCategoryHierarchy: return "DuplicateCategoryHierarchy"; + case ErrorCode::SourceRequiresUnknownInventory: return "SourceRequiresUnknownInventory"; + case ErrorCode::SourceRequiresUnknownSpeciesMap: return "SourceRequiresUnknownSpeciesMap"; + case ErrorCode::SpeciesMapScalingExceedsOne: return "SpeciesMapScalingExceedsOne"; + case ErrorCode::UndefinedEnvironmentVariable: return "UndefinedEnvironmentVariable"; + case ErrorCode::OnlineSourcesNotSupported: return "OnlineSourcesNotSupported"; + case ErrorCode::UnsupportedRegriddingType: return "UnsupportedRegriddingType"; + case ErrorCode::UnsupportedVerticalInjection: return "UnsupportedVerticalInjection"; default: return "Unknown"; } } diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index c7386c03..6b377ccf 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -2,3 +2,4 @@ create_standard_test(NAME validate SOURCES test_validate.cpp) add_subdirectory(v0) add_subdirectory(v1) +add_subdirectory(emissions) diff --git a/test/unit/emissions/CMakeLists.txt b/test/unit/emissions/CMakeLists.txt new file mode 100644 index 00000000..86a611cc --- /dev/null +++ b/test/unit/emissions/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(v1) diff --git a/test/unit/emissions/v1/CMakeLists.txt b/test/unit/emissions/v1/CMakeLists.txt new file mode 100644 index 00000000..064f3526 --- /dev/null +++ b/test/unit/emissions/v1/CMakeLists.txt @@ -0,0 +1,12 @@ +################################################################################ +# Tests + +create_standard_test(NAME emissions_v1_parse SOURCES test_parse_emissions.cpp) + +################################################################################ +# Copy test fixtures + +add_custom_target(copy_emissions_unit_test_configs ALL + ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/emissions_unit_configs + ${CMAKE_BINARY_DIR}/emissions_unit_configs) diff --git a/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml b/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml new file mode 100644 index 00000000..b5cd5bba --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml @@ -0,0 +1,30 @@ +kind: emissions +version: 1.0.0 + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + +sources: + - name: source one + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 + - name: source two + mode: offline + type: fire + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml b/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml new file mode 100644 index 00000000..ce382ef9 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml @@ -0,0 +1,30 @@ +kind: emissions +version: 1.0.0 + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + +sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 1 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/missing_kind.yaml b/test/unit/emissions/v1/emissions_unit_configs/missing_kind.yaml new file mode 100644 index 00000000..dab52a91 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/missing_kind.yaml @@ -0,0 +1,8 @@ +version: 1.0.0 +name: No kind field + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo diff --git a/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml b/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml new file mode 100644 index 00000000..b317a074 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml @@ -0,0 +1,23 @@ +kind: emissions +version: 1.0.0 + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + +sources: + - name: online lightning + mode: online + type: lightning + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml b/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml new file mode 100644 index 00000000..1fbea2b5 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml @@ -0,0 +1,20 @@ +kind: emissions +version: 1.0.0 + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bad map: + mappings: + - inventory species: NOx + mechanism species: NO + scaling factor: 0.9 + - inventory species: NOx + mechanism species: NO2 + scaling factor: 0.5 + +sources: [] diff --git a/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml b/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml new file mode 100644 index 00000000..6503308b --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml @@ -0,0 +1,23 @@ +kind: emissions +version: 1.0.0 + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + +sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: nonexistent inventory + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml b/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml new file mode 100644 index 00000000..66f34d4c --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml @@ -0,0 +1,23 @@ +kind: emissions +version: 1.0.0 + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + +sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: nonexistent map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml b/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml new file mode 100644 index 00000000..769f144a --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml @@ -0,0 +1,20 @@ +kind: emissions +version: 1.0.0 + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + +regridding: + type: scrip + weights file: /path/to/weights.nc + +sources: [] diff --git a/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml b/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml new file mode 100644 index 00000000..4888f751 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml @@ -0,0 +1,24 @@ +kind: emissions +version: 1.0.0 + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + +sources: + - name: CAMS black carbon plume + mode: offline + type: fire + inventory: cams bc + species map: bc map + vertical injection: plume + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/valid.yaml b/test/unit/emissions/v1/emissions_unit_configs/valid.yaml new file mode 100644 index 00000000..04f3f867 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/valid.yaml @@ -0,0 +1,35 @@ +kind: emissions +version: 1.0.0 +name: Valid test emissions config + +data root: /test/data + +inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + +species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + scaling factor: 1.0 + +regridding: + type: none + +sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + temporal interpolation: linear + vertical injection: surface + category: 0 + hierarchy: 1 + scaling factor: 1.0 + sector: anthropogenic + __notes: test source diff --git a/test/unit/emissions/v1/emissions_unit_configs/wrong_kind.yaml b/test/unit/emissions/v1/emissions_unit_configs/wrong_kind.yaml new file mode 100644 index 00000000..74437c2e --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/wrong_kind.yaml @@ -0,0 +1,6 @@ +kind: mechanism +version: 1.0.0 +name: This is a chemistry config, not an emissions config +species: [] +phases: [] +reactions: [] diff --git a/test/unit/emissions/v1/test_parse_emissions.cpp b/test/unit/emissions/v1/test_parse_emissions.cpp new file mode 100644 index 00000000..f650f328 --- /dev/null +++ b/test/unit/emissions/v1/test_parse_emissions.cpp @@ -0,0 +1,198 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include + +#include + +using namespace mechanism_configuration; +namespace em = mechanism_configuration::emissions::v1; + +// Wrapper so string literals route to the file-path overload without ambiguity. +static auto ParseFile(const char* p) +{ + return em::ParseEmissions(std::filesystem::path(p)); +} +using em::ParseEmissions; + +TEST(EmissionsV1Parser, ParsesValidConfig) +{ + auto result = ParseFile("emissions_unit_configs/valid.yaml"); + ASSERT_TRUE(result) << "Parse failed: " << (result ? "" : result.error()[0].second); + + const auto& config = *result; + EXPECT_EQ(config.name, "Valid test emissions config"); + EXPECT_EQ(config.data_root, "/test/data"); + EXPECT_EQ(config.version.major, 1u); + + ASSERT_EQ(config.inventories.size(), 1u); + ASSERT_TRUE(config.inventories.count("cams bc")); + EXPECT_EQ(config.inventories.at("cams bc").directory, "cams"); + EXPECT_EQ(config.inventories.at("cams bc").file_pattern, "CAMS-GLOB-ANT_{YYYY}-{MM}.nc"); + EXPECT_EQ(config.inventories.at("cams bc").convention, "uptempo"); + + ASSERT_EQ(config.species_maps.size(), 1u); + ASSERT_TRUE(config.species_maps.count("bc map")); + const auto& smap = config.species_maps.at("bc map"); + ASSERT_EQ(smap.mappings.size(), 1u); + EXPECT_EQ(smap.mappings[0].inventory_species, "bc_anth_sum"); + EXPECT_EQ(smap.mappings[0].mechanism_species, "BC"); + EXPECT_DOUBLE_EQ(smap.mappings[0].scaling_factor, 1.0); + + EXPECT_EQ(config.regridding.type, em::types::RegriddingType::None); + + ASSERT_EQ(config.sources.size(), 1u); + const auto& src = config.sources[0]; + EXPECT_EQ(src.name, "CAMS black carbon"); + EXPECT_EQ(src.mode, em::types::SourceMode::Offline); + EXPECT_EQ(src.type, em::types::SourceType::Anthropogenic); + EXPECT_EQ(src.inventory, "cams bc"); + EXPECT_EQ(src.species_map, "bc map"); + EXPECT_EQ(src.temporal_interpolation, em::types::TemporalInterpolation::Linear); + EXPECT_EQ(src.vertical_injection, em::types::VerticalInjection::Surface); + EXPECT_EQ(src.category, 0); + EXPECT_EQ(src.hierarchy, 1); + EXPECT_DOUBLE_EQ(src.scaling_factor, 1.0); + EXPECT_EQ(src.sector, "anthropogenic"); + ASSERT_EQ(src.unknown_properties.size(), 1u); + EXPECT_EQ(src.unknown_properties.at("__notes"), "test source"); +} + +TEST(EmissionsV1Parser, ParsesValidConfigFromString) +{ + const std::string content = R"( +kind: emissions +version: 1.0.0 +name: In-memory test + +inventories: + my inv: + directory: data + file pattern: file_{YYYY}.nc + convention: uptempo + +species maps: + my map: + mappings: + - inventory species: SO2 + mechanism species: SO2 + +sources: + - name: so2 source + mode: offline + type: anthropogenic + inventory: my inv + species map: my map + category: 0 + hierarchy: 1 +)"; + + auto result = ParseEmissions(content); + ASSERT_TRUE(result); + EXPECT_EQ(result->name, "In-memory test"); + ASSERT_EQ(result->sources.size(), 1u); + EXPECT_EQ(result->sources[0].name, "so2 source"); +} + +TEST(EmissionsV1Parser, RejectsWrongKind) +{ + auto result = ParseFile("emissions_unit_configs/wrong_kind.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::NotAnEmissionsConfig); +} + +TEST(EmissionsV1Parser, RejectsMissingKind) +{ + auto result = ParseFile("emissions_unit_configs/missing_kind.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::NotAnEmissionsConfig); +} + +TEST(EmissionsV1Parser, RejectsDuplicateSourceName) +{ + auto result = ParseFile("emissions_unit_configs/duplicate_source.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::DuplicateSourceDetected); +} + +TEST(EmissionsV1Parser, RejectsDuplicateCategoryHierarchy) +{ + auto result = ParseFile("emissions_unit_configs/duplicate_category_hierarchy.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::DuplicateCategoryHierarchy) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, RejectsSourceWithUnknownInventory) +{ + auto result = ParseFile("emissions_unit_configs/unknown_inventory.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::SourceRequiresUnknownInventory); +} + +TEST(EmissionsV1Parser, RejectsSourceWithUnknownSpeciesMap) +{ + auto result = ParseFile("emissions_unit_configs/unknown_species_map.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::SourceRequiresUnknownSpeciesMap); +} + +TEST(EmissionsV1Parser, RejectsScalingFactorExceedingOne) +{ + auto result = ParseFile("emissions_unit_configs/scaling_exceeds_one.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::SpeciesMapScalingExceedsOne); +} + +TEST(EmissionsV1Parser, RejectsOnlineSource) +{ + auto result = ParseFile("emissions_unit_configs/online_source.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::OnlineSourcesNotSupported); +} + +TEST(EmissionsV1Parser, RejectsUnsupportedRegriddingType) +{ + auto result = ParseFile("emissions_unit_configs/unsupported_regridding.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::UnsupportedRegriddingType); +} + +TEST(EmissionsV1Parser, RejectsUnsupportedVerticalInjection) +{ + auto result = ParseFile("emissions_unit_configs/unsupported_vertical_injection.yaml"); + ASSERT_FALSE(result); + EXPECT_EQ(result.error()[0].first, ErrorCode::UnsupportedVerticalInjection); +} + +TEST(EmissionsV1Parser, AcceptsEmptySourcesList) +{ + const std::string content = R"( +kind: emissions +version: 1.0.0 +sources: [] +)"; + auto result = ParseEmissions(content); + ASSERT_TRUE(result); + EXPECT_TRUE(result->sources.empty()); +} + +TEST(EmissionsV1Parser, AcceptsAbsentOptionalSections) +{ + const std::string content = R"( +kind: emissions +version: 1.0.0 +)"; + auto result = ParseEmissions(content); + ASSERT_TRUE(result); + EXPECT_TRUE(result->sources.empty()); + EXPECT_TRUE(result->inventories.empty()); + EXPECT_TRUE(result->species_maps.empty()); +} From 5e52840ebc64856bcbeb92b83737f844609c6fb6 Mon Sep 17 00:00:00 2001 From: Scott Meech Date: Fri, 26 Jun 2026 10:48:41 -0600 Subject: [PATCH 2/6] refactor: move emissions public headers out of versioned include path Aligns with the library convention where all public headers live directly under include/mechanism_configuration/ without a version subdirectory. The v1 version is still expressed in the namespace and source layout. Co-Authored-By: Claude Sonnet 4.6 --- include/mechanism_configuration/emissions/{v1 => }/parse.hpp | 2 +- include/mechanism_configuration/emissions/{v1 => }/types.hpp | 0 src/emissions/v1/parser.cpp | 2 +- src/emissions/v1/parser.hpp | 2 +- test/unit/emissions/v1/test_parse_emissions.cpp | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename include/mechanism_configuration/emissions/{v1 => }/parse.hpp (94%) rename include/mechanism_configuration/emissions/{v1 => }/types.hpp (100%) diff --git a/include/mechanism_configuration/emissions/v1/parse.hpp b/include/mechanism_configuration/emissions/parse.hpp similarity index 94% rename from include/mechanism_configuration/emissions/v1/parse.hpp rename to include/mechanism_configuration/emissions/parse.hpp index 718c9208..c6de608e 100644 --- a/include/mechanism_configuration/emissions/v1/parse.hpp +++ b/include/mechanism_configuration/emissions/parse.hpp @@ -4,7 +4,7 @@ #pragma once -#include +#include #include #include diff --git a/include/mechanism_configuration/emissions/v1/types.hpp b/include/mechanism_configuration/emissions/types.hpp similarity index 100% rename from include/mechanism_configuration/emissions/v1/types.hpp rename to include/mechanism_configuration/emissions/types.hpp diff --git a/src/emissions/v1/parser.cpp b/src/emissions/v1/parser.cpp index 1f427f6b..edd1f789 100644 --- a/src/emissions/v1/parser.cpp +++ b/src/emissions/v1/parser.cpp @@ -6,7 +6,7 @@ #include "emissions/v1/keys.hpp" -#include +#include #include #include #include diff --git a/src/emissions/v1/parser.hpp b/src/emissions/v1/parser.hpp index d2b09d76..8a5351f9 100644 --- a/src/emissions/v1/parser.hpp +++ b/src/emissions/v1/parser.hpp @@ -4,7 +4,7 @@ #pragma once -#include +#include #include #include diff --git a/test/unit/emissions/v1/test_parse_emissions.cpp b/test/unit/emissions/v1/test_parse_emissions.cpp index f650f328..5420d20d 100644 --- a/test/unit/emissions/v1/test_parse_emissions.cpp +++ b/test/unit/emissions/v1/test_parse_emissions.cpp @@ -2,7 +2,7 @@ // University of Illinois at Urbana-Champaign // SPDX-License-Identifier: Apache-2.0 -#include +#include #include #include From e48af6b8b738f40b05a02ec18967d7d4b29d89c7 Mon Sep 17 00:00:00 2001 From: Scott Meech Date: Fri, 26 Jun 2026 11:21:09 -0600 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20fold=20emissions=20into=20mecha?= =?UTF-8?q?nism=20=E2=80=94=20no=20separate=20namespace,=20parser,=20or=20?= =?UTF-8?q?file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses K20shores' review of #281: - Emissions types moved into mechanism_configuration::types (types.hpp) - EmissionsConfig added as std::optional on Mechanism - emissions: key parsed as an optional section by the main v1 Parse() call - Standalone ParseEmissions() API and emissions::v1 namespace removed - data_root and shell expansion dropped for JSON parity - Examples updated: emissions.yaml gone; full_configuration.yaml gains an emissions: section - Tests routed through Parse() instead of the old standalone parser Co-Authored-By: Claude Sonnet 4.6 --- examples/emissions/v1/emissions.yaml | 68 -- examples/v1/full_configuration.yaml | 35 +- .../emissions/parse.hpp | 26 - .../emissions/types.hpp | 98 --- include/mechanism_configuration/errors.hpp | 2 - include/mechanism_configuration/mechanism.hpp | 3 + include/mechanism_configuration/types.hpp | 82 +++ src/CMakeLists.txt | 1 - src/detail/v1/keys.hpp | 55 ++ src/detail/v1/parser.hpp | 3 +- src/emissions/CMakeLists.txt | 1 - src/emissions/v1/CMakeLists.txt | 4 - src/emissions/v1/keys.hpp | 72 --- src/emissions/v1/parser.cpp | 606 ------------------ src/emissions/v1/parser.hpp | 37 -- src/errors.cpp | 2 - src/v1/parser.cpp | 388 ++++++++++- .../duplicate_category_hierarchy.yaml | 56 +- .../duplicate_source.yaml | 56 +- .../emissions_unit_configs/missing_kind.yaml | 8 - .../emissions_unit_configs/online_source.yaml | 42 +- .../scaling_exceeds_one.yaml | 36 +- .../unknown_inventory.yaml | 42 +- .../unknown_species_map.yaml | 42 +- .../unsupported_regridding.yaml | 34 +- .../unsupported_vertical_injection.yaml | 44 +- .../v1/emissions_unit_configs/valid.yaml | 61 +- .../v1/emissions_unit_configs/wrong_kind.yaml | 6 - .../emissions/v1/test_parse_emissions.cpp | 185 +++--- 29 files changed, 881 insertions(+), 1214 deletions(-) delete mode 100644 examples/emissions/v1/emissions.yaml delete mode 100644 include/mechanism_configuration/emissions/parse.hpp delete mode 100644 include/mechanism_configuration/emissions/types.hpp delete mode 100644 src/emissions/CMakeLists.txt delete mode 100644 src/emissions/v1/CMakeLists.txt delete mode 100644 src/emissions/v1/keys.hpp delete mode 100644 src/emissions/v1/parser.cpp delete mode 100644 src/emissions/v1/parser.hpp delete mode 100644 test/unit/emissions/v1/emissions_unit_configs/missing_kind.yaml delete mode 100644 test/unit/emissions/v1/emissions_unit_configs/wrong_kind.yaml diff --git a/examples/emissions/v1/emissions.yaml b/examples/emissions/v1/emissions.yaml deleted file mode 100644 index 077eb9ca..00000000 --- a/examples/emissions/v1/emissions.yaml +++ /dev/null @@ -1,68 +0,0 @@ -kind: emissions -version: 1.0.0 -name: Example MIEM emissions configuration - -# Environment-expandable data root. POSIX ${VAR} / ${VAR:-default} syntax. -# The parser expands here; MIEM receives a fully-resolved path string. -data root: ${EMIS_DATA_ROOT:-/glade/derecho/scratch/${USER}/emissions} - -# Inventories: named I/O metadata. Offline sources reference by name. -# Moving all sources to a new machine is a one-line edit to `data root`. -inventories: - cams global anthro: - directory: cams/v6.2 - file pattern: CAMS-GLOB-ANT_v6.2_{YYYY}-{MM}.nc - convention: uptempo - finn fires: - directory: finn/v2.5 - file pattern: FINNv2.5_{YYYY}{MM}{DD}.nc - convention: uptempo - -# Species maps: inventory species → mechanism species mappings, referenced by name. -# Scaling factors per inventory species must sum to ≤ 1.0 (mass conservation). -species maps: - MOZART-T1 from CAMS: - mappings: - - inventory species: NOx - mechanism species: NO - scaling factor: 0.9 - - inventory species: NOx - mechanism species: NO2 - scaling factor: 0.1 - - inventory species: CO - mechanism species: CO - - inventory species: bc_anth_sum - mechanism species: BC - -# Regridding: v1 supports only `type: none` (data already on the model mesh). -# Future: `type: scrip` for ESMF/SCRIP weight-file remapping. -regridding: - type: none - -# Sources: the emission signals. HEMCO-style category/hierarchy aggregation. -# Sources in different categories sum; within a category the highest hierarchy -# wins per cell. Duplicate (category, hierarchy) is a load-time error. -sources: - - name: cams anthro - mode: offline - type: anthropogenic - inventory: cams global anthro - species map: MOZART-T1 from CAMS - temporal interpolation: linear - vertical injection: surface - category: 0 - hierarchy: 1 - scaling factor: 1.0 - sector: anthropogenic - - - name: finn fires - mode: offline - type: fire - inventory: finn fires - species map: MOZART-T1 from CAMS - temporal interpolation: nearest - vertical injection: surface - category: 1 - hierarchy: 1 - sector: fire - __notes: "FINN v2.5 daily fire emissions" diff --git a/examples/v1/full_configuration.yaml b/examples/v1/full_configuration.yaml index 8a51f858..d850c2b0 100644 --- a/examples/v1/full_configuration.yaml +++ b/examples/v1/full_configuration.yaml @@ -198,4 +198,37 @@ reactions: coefficient: 1 products: - species name: C - coefficient: 1 \ No newline at end of file + coefficient: 1 +emissions: + inventories: + cams global anthro: + directory: cams/v6.2 + file pattern: CAMS-GLOB-ANT_v6.2_{YYYY}-{MM}.nc + convention: uptempo + species maps: + MOZART-T1 from CAMS: + mappings: + - inventory species: NOx + mechanism species: NO + scaling factor: 0.9 + - inventory species: NOx + mechanism species: NO2 + scaling factor: 0.1 + - inventory species: CO + mechanism species: CO + - inventory species: bc_anth_sum + mechanism species: BC + regridding: + type: none + sources: + - name: cams anthro + mode: offline + type: anthropogenic + inventory: cams global anthro + species map: MOZART-T1 from CAMS + temporal interpolation: linear + vertical injection: surface + category: 0 + hierarchy: 1 + scaling factor: 1.0 + sector: anthropogenic \ No newline at end of file diff --git a/include/mechanism_configuration/emissions/parse.hpp b/include/mechanism_configuration/emissions/parse.hpp deleted file mode 100644 index c6de608e..00000000 --- a/include/mechanism_configuration/emissions/parse.hpp +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (C) 2023–2026 University Corporation for Atmospheric Research -// University of Illinois at Urbana-Champaign -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#include -#include - -#include -#include -#include - -namespace mechanism_configuration::emissions::v1 -{ - /// @brief Parse an emissions configuration file. - /// @param config_path Path to a YAML emissions configuration file. - /// @return The parsed EmissionsConfig, or all structural and semantic errors encountered. - std::expected ParseEmissions(const std::filesystem::path& config_path); - - /// @brief Parse an emissions configuration from an in-memory YAML string. - /// @param content The YAML document contents (not a file path). - /// @return The parsed EmissionsConfig, or all structural and semantic errors encountered. - std::expected ParseEmissions(const std::string& content); - -} // namespace mechanism_configuration::emissions::v1 diff --git a/include/mechanism_configuration/emissions/types.hpp b/include/mechanism_configuration/emissions/types.hpp deleted file mode 100644 index df1dd26e..00000000 --- a/include/mechanism_configuration/emissions/types.hpp +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (C) 2023–2026 University Corporation for Atmospheric Research -// University of Illinois at Urbana-Champaign -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#include - -#include -#include -#include - -namespace mechanism_configuration::emissions::v1::types -{ - struct SpeciesMapping - { - std::string inventory_species; - std::string mechanism_species; - double scaling_factor{ 1.0 }; - }; - - struct SpeciesMap - { - std::vector mappings; - }; - - struct Inventory - { - std::string directory; - std::string file_pattern; - std::string convention; - }; - - enum class SourceMode - { - Offline, - }; - - enum class SourceType - { - Anthropogenic, - Fire, - Biogenic, - Dust, - SeaSalt, - Lightning, - }; - - enum class TemporalInterpolation - { - Linear, - Nearest, - None, - }; - - enum class VerticalInjection - { - Surface, - }; - - struct SourceDescriptor - { - std::string name; - SourceMode mode{ SourceMode::Offline }; - SourceType type{ SourceType::Anthropogenic }; - std::string inventory; - std::string species_map; - TemporalInterpolation temporal_interpolation{ TemporalInterpolation::Linear }; - VerticalInjection vertical_injection{ VerticalInjection::Surface }; - int category{ 0 }; - int hierarchy{ 1 }; - double scaling_factor{ 1.0 }; - std::string sector; - std::unordered_map unknown_properties; - }; - - enum class RegriddingType - { - None, - }; - - struct Regridding - { - RegriddingType type{ RegriddingType::None }; - }; - - struct EmissionsConfig - { - Version version; - std::string name; - std::string data_root; - std::unordered_map inventories; - std::unordered_map species_maps; - Regridding regridding; - std::vector sources; - }; - -} // namespace mechanism_configuration::emissions::v1::types diff --git a/include/mechanism_configuration/errors.hpp b/include/mechanism_configuration/errors.hpp index ea22f8c6..50ccb4ee 100644 --- a/include/mechanism_configuration/errors.hpp +++ b/include/mechanism_configuration/errors.hpp @@ -54,7 +54,6 @@ namespace mechanism_configuration UnexpectedError, EmptyObject, // Emissions-specific error codes - NotAnEmissionsConfig, DuplicateInventoryDetected, DuplicateSpeciesMapDetected, DuplicateSourceDetected, @@ -62,7 +61,6 @@ namespace mechanism_configuration SourceRequiresUnknownInventory, SourceRequiresUnknownSpeciesMap, SpeciesMapScalingExceedsOne, - UndefinedEnvironmentVariable, OnlineSourcesNotSupported, UnsupportedRegriddingType, UnsupportedVerticalInjection, diff --git a/include/mechanism_configuration/mechanism.hpp b/include/mechanism_configuration/mechanism.hpp index 2c656adc..8980f394 100644 --- a/include/mechanism_configuration/mechanism.hpp +++ b/include/mechanism_configuration/mechanism.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -90,5 +91,7 @@ namespace mechanism_configuration Version version; /// @brief Relative tolerance for solver (optional, default: 1e-6) double relative_tolerance{ 1e-6 }; + /// @brief Emissions configuration (optional) + std::optional emissions; }; } // namespace mechanism_configuration \ No newline at end of file diff --git a/include/mechanism_configuration/types.hpp b/include/mechanism_configuration/types.hpp index 6a834f92..a7b3a775 100644 --- a/include/mechanism_configuration/types.hpp +++ b/include/mechanism_configuration/types.hpp @@ -326,4 +326,86 @@ namespace mechanism_configuration::types std::vector lambda_rate_constant; }; + // ── Emissions configuration types ───────────────────────────────────────── + + struct SpeciesMapping + { + std::string inventory_species; + std::string mechanism_species; + double scaling_factor{ 1.0 }; + }; + + struct SpeciesMap + { + std::vector mappings; + }; + + struct Inventory + { + std::string directory; + std::string file_pattern; + std::string convention; + }; + + enum class SourceMode + { + Offline, + }; + + enum class SourceType + { + Anthropogenic, + Fire, + Biogenic, + Dust, + SeaSalt, + Lightning, + }; + + enum class TemporalInterpolation + { + Linear, + Nearest, + None, + }; + + enum class VerticalInjection + { + Surface, + }; + + struct SourceDescriptor + { + std::string name; + SourceMode mode{ SourceMode::Offline }; + SourceType type{ SourceType::Anthropogenic }; + std::string inventory; + std::string species_map; + TemporalInterpolation temporal_interpolation{ TemporalInterpolation::Linear }; + VerticalInjection vertical_injection{ VerticalInjection::Surface }; + int category{ 0 }; + int hierarchy{ 1 }; + double scaling_factor{ 1.0 }; + std::string sector; + std::unordered_map unknown_properties; + }; + + enum class RegriddingType + { + None, + }; + + struct Regridding + { + RegriddingType type{ RegriddingType::None }; + }; + + struct EmissionsConfig + { + std::unordered_map inventories; + std::unordered_map species_maps; + Regridding regridding; + std::vector sources; + }; + } // namespace mechanism_configuration::types diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 393afe85..be92c6e0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -43,7 +43,6 @@ target_include_directories(mechanism_configuration add_subdirectory(v0) add_subdirectory(v1) -add_subdirectory(emissions) target_link_libraries(mechanism_configuration PUBLIC diff --git a/src/detail/v1/keys.hpp b/src/detail/v1/keys.hpp index 939a1337..05df65a2 100644 --- a/src/detail/v1/keys.hpp +++ b/src/detail/v1/keys.hpp @@ -176,4 +176,59 @@ namespace mechanism_configuration::v1::keys // products // name + // ── Emissions section ────────────────────────────────────────────────────── + inline constexpr std::string_view emissions = "emissions"; + + // Emissions top-level sections + inline constexpr std::string_view inventories = "inventories"; + inline constexpr std::string_view species_maps = "species maps"; + inline constexpr std::string_view regridding = "regridding"; + inline constexpr std::string_view sources = "sources"; + + // Inventory entry + inline constexpr std::string_view directory = "directory"; + inline constexpr std::string_view file_pattern = "file pattern"; + inline constexpr std::string_view convention = "convention"; + + // Species map entry + inline constexpr std::string_view mappings = "mappings"; + inline constexpr std::string_view inventory_species = "inventory species"; + inline constexpr std::string_view mechanism_species = "mechanism species"; + // scaling_factor already defined above + + // Regridding values + inline constexpr std::string_view regridding_none = "none"; + inline constexpr std::string_view regridding_scrip = "scrip"; + + // Source descriptor entry + inline constexpr std::string_view mode = "mode"; + inline constexpr std::string_view inventory = "inventory"; + inline constexpr std::string_view species_map = "species map"; + inline constexpr std::string_view temporal_interpolation = "temporal interpolation"; + inline constexpr std::string_view vertical_injection = "vertical injection"; + inline constexpr std::string_view category = "category"; + inline constexpr std::string_view hierarchy = "hierarchy"; + inline constexpr std::string_view sector = "sector"; + + // Mode values + inline constexpr std::string_view mode_offline = "offline"; + inline constexpr std::string_view mode_online = "online"; + + // Source type values + inline constexpr std::string_view type_anthropogenic = "anthropogenic"; + inline constexpr std::string_view type_fire = "fire"; + inline constexpr std::string_view type_biogenic = "biogenic"; + inline constexpr std::string_view type_dust = "dust"; + inline constexpr std::string_view type_sea_salt = "sea salt"; + inline constexpr std::string_view type_lightning = "lightning"; + + // Temporal interpolation values + inline constexpr std::string_view interp_linear = "linear"; + inline constexpr std::string_view interp_nearest = "nearest"; + inline constexpr std::string_view interp_none = "none"; + + // Vertical injection values + inline constexpr std::string_view inject_surface = "surface"; + inline constexpr std::string_view inject_plume = "plume"; + } // namespace mechanism_configuration::v1::keys diff --git a/src/detail/v1/parser.hpp b/src/detail/v1/parser.hpp index 22197bf7..ac338a6b 100644 --- a/src/detail/v1/parser.hpp +++ b/src/detail/v1/parser.hpp @@ -11,6 +11,7 @@ #include #include +#include #include namespace mechanism_configuration::v1 @@ -58,7 +59,7 @@ namespace mechanism_configuration::v1 Errors CheckSchema(const YAML::Node& object); /// @brief Constructs a Mechanism from an already-validated node. - Mechanism Build(const YAML::Node& object); + Mechanism Build(const YAML::Node& object, std::optional emissions); inline void SetConfigPath(const std::string& config_path) { diff --git a/src/emissions/CMakeLists.txt b/src/emissions/CMakeLists.txt deleted file mode 100644 index 86a611cc..00000000 --- a/src/emissions/CMakeLists.txt +++ /dev/null @@ -1 +0,0 @@ -add_subdirectory(v1) diff --git a/src/emissions/v1/CMakeLists.txt b/src/emissions/v1/CMakeLists.txt deleted file mode 100644 index 3a995d22..00000000 --- a/src/emissions/v1/CMakeLists.txt +++ /dev/null @@ -1,4 +0,0 @@ -target_sources(mechanism_configuration - PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/parser.cpp -) diff --git a/src/emissions/v1/keys.hpp b/src/emissions/v1/keys.hpp deleted file mode 100644 index ab43c48f..00000000 --- a/src/emissions/v1/keys.hpp +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (C) 2023–2026 University Corporation for Atmospheric Research -// University of Illinois at Urbana-Champaign -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#include - -namespace mechanism_configuration::emissions::v1::keys -{ - // Top-level - inline constexpr std::string_view kind = "kind"; - inline constexpr std::string_view kind_value = "emissions"; - inline constexpr std::string_view version = "version"; - inline constexpr std::string_view name = "name"; - inline constexpr std::string_view data_root = "data root"; - - // Sections - inline constexpr std::string_view inventories = "inventories"; - inline constexpr std::string_view species_maps = "species maps"; - inline constexpr std::string_view regridding = "regridding"; - inline constexpr std::string_view sources = "sources"; - - // Inventory entry - inline constexpr std::string_view directory = "directory"; - inline constexpr std::string_view file_pattern = "file pattern"; - inline constexpr std::string_view convention = "convention"; - - // Species map entry - inline constexpr std::string_view mappings = "mappings"; - inline constexpr std::string_view inventory_species = "inventory species"; - inline constexpr std::string_view mechanism_species = "mechanism species"; - inline constexpr std::string_view scaling_factor = "scaling factor"; - - // Regridding entry - inline constexpr std::string_view type = "type"; - inline constexpr std::string_view regridding_none = "none"; - inline constexpr std::string_view regridding_scrip = "scrip"; - - // Source descriptor entry - inline constexpr std::string_view mode = "mode"; - inline constexpr std::string_view source_type = "type"; - inline constexpr std::string_view inventory = "inventory"; - inline constexpr std::string_view species_map = "species map"; - inline constexpr std::string_view temporal_interpolation = "temporal interpolation"; - inline constexpr std::string_view vertical_injection = "vertical injection"; - inline constexpr std::string_view category = "category"; - inline constexpr std::string_view hierarchy = "hierarchy"; - inline constexpr std::string_view sector = "sector"; - - // Mode values - inline constexpr std::string_view mode_offline = "offline"; - inline constexpr std::string_view mode_online = "online"; - - // Source type values - inline constexpr std::string_view type_anthropogenic = "anthropogenic"; - inline constexpr std::string_view type_fire = "fire"; - inline constexpr std::string_view type_biogenic = "biogenic"; - inline constexpr std::string_view type_dust = "dust"; - inline constexpr std::string_view type_sea_salt = "sea salt"; - inline constexpr std::string_view type_lightning = "lightning"; - - // Temporal interpolation values - inline constexpr std::string_view interp_linear = "linear"; - inline constexpr std::string_view interp_nearest = "nearest"; - inline constexpr std::string_view interp_none = "none"; - - // Vertical injection values - inline constexpr std::string_view inject_surface = "surface"; - inline constexpr std::string_view inject_plume = "plume"; - -} // namespace mechanism_configuration::emissions::v1::keys diff --git a/src/emissions/v1/parser.cpp b/src/emissions/v1/parser.cpp deleted file mode 100644 index edd1f789..00000000 --- a/src/emissions/v1/parser.cpp +++ /dev/null @@ -1,606 +0,0 @@ -// Copyright (C) 2023–2026 University Corporation for Atmospheric Research -// University of Illinois at Urbana-Champaign -// SPDX-License-Identifier: Apache-2.0 - -#include "emissions/v1/parser.hpp" - -#include "emissions/v1/keys.hpp" - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include - -namespace mechanism_configuration::emissions::v1 -{ - namespace - { - void AppendFilePath(const std::string& config_path, Errors& errors) - { - if (config_path.empty()) - return; - for (auto& [code, msg] : errors) - if (msg.find(config_path) == std::string::npos) - msg = config_path + ": " + msg; - } - - // Parse all __ -prefixed keys as unknown properties. - std::unordered_map GetUnknownProperties(const YAML::Node& node) - { - std::unordered_map props; - for (const auto& kv : node) - { - std::string k = kv.first.as(); - if (k.size() >= 2 && k[0] == '_' && k[1] == '_') - props[k] = kv.second.as(); - } - return props; - } - - types::SourceType ParseSourceType(const std::string& s) - { - if (s == keys::type_anthropogenic) return types::SourceType::Anthropogenic; - if (s == keys::type_fire) return types::SourceType::Fire; - if (s == keys::type_biogenic) return types::SourceType::Biogenic; - if (s == keys::type_dust) return types::SourceType::Dust; - if (s == keys::type_sea_salt) return types::SourceType::SeaSalt; - if (s == keys::type_lightning) return types::SourceType::Lightning; - return types::SourceType::Anthropogenic; // validated separately - } - - types::TemporalInterpolation ParseTemporalInterpolation(const std::string& s) - { - if (s == keys::interp_linear) return types::TemporalInterpolation::Linear; - if (s == keys::interp_nearest) return types::TemporalInterpolation::Nearest; - if (s == keys::interp_none) return types::TemporalInterpolation::None; - return types::TemporalInterpolation::Linear; // validated separately - } - } // namespace - - std::string Parser::ExpandEnvVars(const std::string& input, Errors& errors) const - { - std::string result; - result.reserve(input.size()); - size_t i = 0; - while (i < input.size()) - { - if (input[i] == '$' && i + 1 < input.size() && input[i + 1] == '{') - { - size_t end = input.find('}', i + 2); - if (end == std::string::npos) - { - errors.push_back({ ErrorCode::InvalidKey, "Unterminated ${...} in 'data root'" }); - return result; - } - const std::string var_expr = input.substr(i + 2, end - i - 2); - const auto sep = var_expr.find(":-"); - std::string var_name, default_val; - bool has_default = false; - if (sep != std::string::npos) - { - var_name = var_expr.substr(0, sep); - default_val = var_expr.substr(sep + 2); - has_default = true; - } - else - { - var_name = var_expr; - } - const char* val = std::getenv(var_name.c_str()); - if (val) - { - result += val; - } - else if (has_default) - { - result += default_val; - } - else - { - errors.push_back({ ErrorCode::UndefinedEnvironmentVariable, - mc_fmt::format("Environment variable '{}' is not set and has no default", var_name) }); - return result; - } - i = end + 1; - } - else - { - result += input[i++]; - } - } - return result; - } - - std::expected Parser::ValidateAndBuild(const YAML::Node& object) - { - try - { - Errors errors; - - // ── kind ────────────────────────────────────────────────────────────────── - if (!object[std::string(keys::kind)]) - { - errors.push_back({ ErrorCode::NotAnEmissionsConfig, - "Missing 'kind' field; emissions configs must declare 'kind: emissions'" }); - AppendFilePath(config_path_, errors); - return std::unexpected(std::move(errors)); - } - const std::string kind_val = object[std::string(keys::kind)].as(); - if (kind_val != std::string(keys::kind_value)) - { - const ErrorLocation loc{ object[std::string(keys::kind)].Mark().line, - object[std::string(keys::kind)].Mark().column }; - errors.push_back({ ErrorCode::NotAnEmissionsConfig, - mc_fmt::format("{} error: Expected 'kind: emissions', found 'kind: {}'", loc, kind_val) }); - AppendFilePath(config_path_, errors); - return std::unexpected(std::move(errors)); - } - - // ── version ─────────────────────────────────────────────────────────────── - if (!object[std::string(keys::version)]) - { - errors.push_back({ ErrorCode::MissingVersionField, "Missing required 'version' field" }); - AppendFilePath(config_path_, errors); - return std::unexpected(std::move(errors)); - } - const Version version(object[std::string(keys::version)].as()); - constexpr unsigned int SUPPORTED_MAJOR = 1; - if (version.major != SUPPORTED_MAJOR) - { - const ErrorLocation loc{ object[std::string(keys::version)].Mark().line, - object[std::string(keys::version)].Mark().column }; - errors.push_back({ ErrorCode::InvalidVersion, - mc_fmt::format( - "{} error: Unsupported emissions config version '{}'; only major version {} is supported", - loc, - version.to_string(), - SUPPORTED_MAJOR) }); - AppendFilePath(config_path_, errors); - return std::unexpected(std::move(errors)); - } - - types::EmissionsConfig config; - config.version = version; - - // ── name (optional) ─────────────────────────────────────────────────────── - if (object[std::string(keys::name)]) - config.name = object[std::string(keys::name)].as(); - - // ── data root (optional) ────────────────────────────────────────────────── - if (object[std::string(keys::data_root)]) - { - const std::string raw = object[std::string(keys::data_root)].as(); - config.data_root = ExpandEnvVars(raw, errors); - if (!errors.empty()) - { - AppendFilePath(config_path_, errors); - return std::unexpected(std::move(errors)); - } - } - - // ── inventories (optional) ──────────────────────────────────────────────── - if (object[std::string(keys::inventories)]) - { - const YAML::Node& inv_node = object[std::string(keys::inventories)]; - if (!inv_node.IsMap()) - { - errors.push_back({ ErrorCode::InvalidType, "'inventories' must be a mapping" }); - } - else - { - for (const auto& kv : inv_node) - { - const std::string inv_name = kv.first.as(); - if (config.inventories.count(inv_name)) - { - errors.push_back( - { ErrorCode::DuplicateInventoryDetected, - mc_fmt::format("Duplicate inventory name '{}'", inv_name) }); - continue; - } - const YAML::Node& entry = kv.second; - types::Inventory inv; - if (!entry[std::string(keys::directory)]) - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Inventory '{}' is missing required key 'directory'", inv_name) }); - else - inv.directory = entry[std::string(keys::directory)].as(); - - if (!entry[std::string(keys::file_pattern)]) - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Inventory '{}' is missing required key 'file pattern'", inv_name) }); - else - inv.file_pattern = entry[std::string(keys::file_pattern)].as(); - - if (!entry[std::string(keys::convention)]) - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Inventory '{}' is missing required key 'convention'", inv_name) }); - else - inv.convention = entry[std::string(keys::convention)].as(); - - config.inventories.emplace(inv_name, std::move(inv)); - } - } - } - - // ── species maps (optional) ─────────────────────────────────────────────── - if (object[std::string(keys::species_maps)]) - { - const YAML::Node& sm_node = object[std::string(keys::species_maps)]; - if (!sm_node.IsMap()) - { - errors.push_back({ ErrorCode::InvalidType, "'species maps' must be a mapping" }); - } - else - { - for (const auto& kv : sm_node) - { - const std::string map_name = kv.first.as(); - if (config.species_maps.count(map_name)) - { - errors.push_back( - { ErrorCode::DuplicateSpeciesMapDetected, - mc_fmt::format("Duplicate species map name '{}'", map_name) }); - continue; - } - const YAML::Node& entry = kv.second; - types::SpeciesMap smap; - - if (!entry[std::string(keys::mappings)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Species map '{}' is missing required key 'mappings'", map_name) }); - config.species_maps.emplace(map_name, std::move(smap)); - continue; - } - - // Accumulate scaling factors per inventory species to validate the ≤1.0 constraint. - std::unordered_map inv_species_scale_sum; - - for (const auto& mapping_node : entry[std::string(keys::mappings)]) - { - types::SpeciesMapping m; - if (!mapping_node[std::string(keys::inventory_species)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("A mapping in species map '{}' is missing 'inventory species'", map_name) }); - continue; - } - if (!mapping_node[std::string(keys::mechanism_species)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("A mapping in species map '{}' is missing 'mechanism species'", map_name) }); - continue; - } - m.inventory_species = mapping_node[std::string(keys::inventory_species)].as(); - m.mechanism_species = mapping_node[std::string(keys::mechanism_species)].as(); - if (mapping_node[std::string(keys::scaling_factor)]) - m.scaling_factor = mapping_node[std::string(keys::scaling_factor)].as(); - - inv_species_scale_sum[m.inventory_species] += m.scaling_factor; - smap.mappings.push_back(std::move(m)); - } - - // Validate scaling factor sums ≤ 1.0 - for (const auto& [inv_sp, total] : inv_species_scale_sum) - { - if (total > 1.0 + 1e-9) - { - errors.push_back( - { ErrorCode::SpeciesMapScalingExceedsOne, - mc_fmt::format( - "Species map '{}': scaling factors for inventory species '{}' sum to {:.4f}, which exceeds 1.0", - map_name, - inv_sp, - total) }); - } - } - config.species_maps.emplace(map_name, std::move(smap)); - } - } - } - - // ── regridding (optional) ───────────────────────────────────────────────── - if (object[std::string(keys::regridding)]) - { - const YAML::Node& rg_node = object[std::string(keys::regridding)]; - if (rg_node[std::string(keys::type)]) - { - const std::string rg_type = rg_node[std::string(keys::type)].as(); - if (rg_type == std::string(keys::regridding_none)) - { - config.regridding.type = types::RegriddingType::None; - } - else - { - const ErrorLocation loc{ rg_node[std::string(keys::type)].Mark().line, - rg_node[std::string(keys::type)].Mark().column }; - errors.push_back( - { ErrorCode::UnsupportedRegriddingType, - mc_fmt::format( - "{} error: Unsupported regridding type '{}'; only 'none' is supported in v1", loc, rg_type) }); - } - } - } - - // ── sources (optional) ──────────────────────────────────────────────────── - if (object[std::string(keys::sources)]) - { - const YAML::Node& src_node = object[std::string(keys::sources)]; - if (!src_node.IsSequence()) - { - errors.push_back({ ErrorCode::InvalidType, "'sources' must be a sequence" }); - } - else - { - std::set seen_names; - std::set> seen_cat_hier; - - for (const auto& s : src_node) - { - types::SourceDescriptor src; - - // name (required) - if (!s[std::string(keys::name)]) - { - errors.push_back({ ErrorCode::RequiredKeyNotFound, "A source entry is missing required key 'name'" }); - continue; - } - src.name = s[std::string(keys::name)].as(); - - if (seen_names.count(src.name)) - { - errors.push_back( - { ErrorCode::DuplicateSourceDetected, - mc_fmt::format("Duplicate source name '{}'", src.name) }); - continue; - } - seen_names.insert(src.name); - - // mode (required) - if (!s[std::string(keys::mode)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Source '{}' is missing required key 'mode'", src.name) }); - } - else - { - const std::string mode_val = s[std::string(keys::mode)].as(); - if (mode_val == std::string(keys::mode_online)) - { - const ErrorLocation loc{ s[std::string(keys::mode)].Mark().line, - s[std::string(keys::mode)].Mark().column }; - errors.push_back( - { ErrorCode::OnlineSourcesNotSupported, - mc_fmt::format( - "{} error: Source '{}' uses 'mode: online', which is not supported in v1", - loc, - src.name) }); - } - else if (mode_val != std::string(keys::mode_offline)) - { - errors.push_back( - { ErrorCode::UnknownType, - mc_fmt::format("Source '{}' has unknown mode '{}'; expected 'offline'", src.name, mode_val) }); - } - // mode_offline is the only valid value; src.mode is already Offline by default - } - - // type (required) - if (!s[std::string(keys::source_type)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Source '{}' is missing required key 'type'", src.name) }); - } - else - { - const std::string type_val = s[std::string(keys::source_type)].as(); - static const std::vector valid_types = { keys::type_anthropogenic, keys::type_fire, - keys::type_biogenic, keys::type_dust, - keys::type_sea_salt, keys::type_lightning }; - bool found = false; - for (const auto& vt : valid_types) - if (type_val == std::string(vt)) - found = true; - if (!found) - errors.push_back( - { ErrorCode::UnknownType, - mc_fmt::format("Source '{}' has unknown type '{}'", src.name, type_val) }); - else - src.type = ParseSourceType(type_val); - } - - // inventory (required) - if (!s[std::string(keys::inventory)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Source '{}' is missing required key 'inventory'", src.name) }); - } - else - { - src.inventory = s[std::string(keys::inventory)].as(); - if (!config.inventories.empty() && !config.inventories.count(src.inventory)) - { - errors.push_back( - { ErrorCode::SourceRequiresUnknownInventory, - mc_fmt::format( - "Source '{}' references inventory '{}' which is not declared in 'inventories'", - src.name, - src.inventory) }); - } - } - - // species map (required) - if (!s[std::string(keys::species_map)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Source '{}' is missing required key 'species map'", src.name) }); - } - else - { - src.species_map = s[std::string(keys::species_map)].as(); - if (!config.species_maps.empty() && !config.species_maps.count(src.species_map)) - { - errors.push_back( - { ErrorCode::SourceRequiresUnknownSpeciesMap, - mc_fmt::format( - "Source '{}' references species map '{}' which is not declared in 'species maps'", - src.name, - src.species_map) }); - } - } - - // temporal interpolation (optional) - if (s[std::string(keys::temporal_interpolation)]) - src.temporal_interpolation = - ParseTemporalInterpolation(s[std::string(keys::temporal_interpolation)].as()); - - // vertical injection (optional) - if (s[std::string(keys::vertical_injection)]) - { - const std::string vi_val = s[std::string(keys::vertical_injection)].as(); - if (vi_val == std::string(keys::inject_plume)) - { - const ErrorLocation loc{ s[std::string(keys::vertical_injection)].Mark().line, - s[std::string(keys::vertical_injection)].Mark().column }; - errors.push_back( - { ErrorCode::UnsupportedVerticalInjection, - mc_fmt::format( - "{} error: Source '{}' uses 'vertical injection: plume', which is not supported in v1", - loc, - src.name) }); - } - else if (vi_val != std::string(keys::inject_surface)) - { - errors.push_back( - { ErrorCode::UnknownType, - mc_fmt::format( - "Source '{}' has unknown vertical injection '{}'; expected 'surface'", src.name, vi_val) }); - } - } - - // category, hierarchy, scaling factor, sector (all optional) - if (s[std::string(keys::category)]) - src.category = s[std::string(keys::category)].as(); - if (s[std::string(keys::hierarchy)]) - src.hierarchy = s[std::string(keys::hierarchy)].as(); - if (s[std::string(keys::scaling_factor)]) - src.scaling_factor = s[std::string(keys::scaling_factor)].as(); - if (s[std::string(keys::sector)]) - src.sector = s[std::string(keys::sector)].as(); - - // duplicate (category, hierarchy) - const auto cat_hier = std::make_pair(src.category, src.hierarchy); - if (seen_cat_hier.count(cat_hier)) - { - errors.push_back( - { ErrorCode::DuplicateCategoryHierarchy, - mc_fmt::format( - "Source '{}' has duplicate (category: {}, hierarchy: {}) — each (category, hierarchy) pair must be unique", - src.name, - src.category, - src.hierarchy) }); - } - else - { - seen_cat_hier.insert(cat_hier); - } - - src.unknown_properties = GetUnknownProperties(s); - config.sources.push_back(std::move(src)); - } - } - } - - if (!errors.empty()) - { - AppendFilePath(config_path_, errors); - return std::unexpected(std::move(errors)); - } - - return config; - } - catch (const std::exception& e) - { - const std::string where = config_path_.empty() ? "document" : "'" + config_path_ + "'"; - return std::unexpected( - Errors{ { ErrorCode::UnexpectedError, - mc_fmt::format("Failed to parse emissions config {}: {}", where, e.what()) } }); - } - } - - std::expected Parser::Parse(const std::filesystem::path& config_path) - { - config_path_ = config_path.string(); - - if (!std::filesystem::exists(config_path)) - { - return std::unexpected(Errors{ { ErrorCode::FileNotFound, - mc_fmt::format("Configuration file '{}' does not exist.", config_path.string()) } }); - } - - YAML::Node object; - try - { - object = YAML::LoadFile(config_path.string()); - } - catch (const std::exception& e) - { - return std::unexpected( - Errors{ { ErrorCode::UnexpectedError, - mc_fmt::format("Failed to parse '{}': {}", config_path.string(), e.what()) } }); - } - - return ValidateAndBuild(object); - } - - std::expected Parser::Parse(const std::string& content) - { - config_path_ = ""; - - YAML::Node object; - try - { - object = YAML::Load(content); - } - catch (const std::exception& e) - { - return std::unexpected( - Errors{ { ErrorCode::UnexpectedError, - mc_fmt::format("Failed to parse emissions document: {}", e.what()) } }); - } - - return ValidateAndBuild(object); - } - - // ── Public free functions ────────────────────────────────────────────────── - - std::expected ParseEmissions(const std::filesystem::path& config_path) - { - return Parser{}.Parse(config_path); - } - - std::expected ParseEmissions(const std::string& content) - { - return Parser{}.Parse(content); - } - -} // namespace mechanism_configuration::emissions::v1 diff --git a/src/emissions/v1/parser.hpp b/src/emissions/v1/parser.hpp deleted file mode 100644 index 8a5351f9..00000000 --- a/src/emissions/v1/parser.hpp +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (C) 2023–2026 University Corporation for Atmospheric Research -// University of Illinois at Urbana-Champaign -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#include -#include - -#include - -#include -#include -#include - -namespace mechanism_configuration::emissions::v1 -{ - class Parser - { - public: - Parser() = default; - - /// @brief Parse an emissions configuration from a file. - std::expected Parse(const std::filesystem::path& config_path); - - /// @brief Parse an emissions configuration from an in-memory YAML string. - std::expected Parse(const std::string& content); - - private: - std::string config_path_; - - std::expected ValidateAndBuild(const YAML::Node& object); - - std::string ExpandEnvVars(const std::string& input, Errors& errors) const; - }; - -} // namespace mechanism_configuration::emissions::v1 diff --git a/src/errors.cpp b/src/errors.cpp index 161fd22d..f3788f29 100644 --- a/src/errors.cpp +++ b/src/errors.cpp @@ -34,7 +34,6 @@ namespace mechanism_configuration case ErrorCode::FileNotFound: return "FileNotFound"; case ErrorCode::UnexpectedError: return "UnexpectedError"; case ErrorCode::EmptyObject: return "EmptyObject"; - case ErrorCode::NotAnEmissionsConfig: return "NotAnEmissionsConfig"; case ErrorCode::DuplicateInventoryDetected: return "DuplicateInventoryDetected"; case ErrorCode::DuplicateSpeciesMapDetected: return "DuplicateSpeciesMapDetected"; case ErrorCode::DuplicateSourceDetected: return "DuplicateSourceDetected"; @@ -42,7 +41,6 @@ namespace mechanism_configuration case ErrorCode::SourceRequiresUnknownInventory: return "SourceRequiresUnknownInventory"; case ErrorCode::SourceRequiresUnknownSpeciesMap: return "SourceRequiresUnknownSpeciesMap"; case ErrorCode::SpeciesMapScalingExceedsOne: return "SpeciesMapScalingExceedsOne"; - case ErrorCode::UndefinedEnvironmentVariable: return "UndefinedEnvironmentVariable"; case ErrorCode::OnlineSourcesNotSupported: return "OnlineSourcesNotSupported"; case ErrorCode::UnsupportedRegriddingType: return "UnsupportedRegriddingType"; case ErrorCode::UnsupportedVerticalInjection: return "UnsupportedVerticalInjection"; diff --git a/src/v1/parser.cpp b/src/v1/parser.cpp index d5b01c1a..42d0e825 100644 --- a/src/v1/parser.cpp +++ b/src/v1/parser.cpp @@ -17,6 +17,8 @@ #include #include +#include +#include #include namespace mechanism_configuration::v1 @@ -45,6 +47,367 @@ namespace mechanism_configuration::v1 return ErrorLocation{ node.Mark().line, node.Mark().column }; } + std::unordered_map GetUnknownProperties(const YAML::Node& node) + { + std::unordered_map props; + for (const auto& kv : node) + { + std::string k = kv.first.as(); + if (k.size() >= 2 && k[0] == '_' && k[1] == '_') + props[k] = kv.second.as(); + } + return props; + } + + types::SourceType ParseSourceType(const std::string& s) + { + if (s == std::string(keys::type_fire)) return types::SourceType::Fire; + if (s == std::string(keys::type_biogenic)) return types::SourceType::Biogenic; + if (s == std::string(keys::type_dust)) return types::SourceType::Dust; + if (s == std::string(keys::type_sea_salt)) return types::SourceType::SeaSalt; + if (s == std::string(keys::type_lightning)) return types::SourceType::Lightning; + return types::SourceType::Anthropogenic; + } + + types::TemporalInterpolation ParseTemporalInterpolation(const std::string& s) + { + if (s == std::string(keys::interp_nearest)) return types::TemporalInterpolation::Nearest; + if (s == std::string(keys::interp_none)) return types::TemporalInterpolation::None; + return types::TemporalInterpolation::Linear; + } + + std::expected ParseEmissionsSection(const YAML::Node& node) + { + Errors errors; + types::EmissionsConfig config; + + // inventories (optional) + if (node[std::string(keys::inventories)]) + { + const YAML::Node& inv_node = node[std::string(keys::inventories)]; + if (!inv_node.IsMap()) + { + errors.push_back({ ErrorCode::InvalidType, "'inventories' must be a mapping" }); + } + else + { + for (const auto& kv : inv_node) + { + const std::string inv_name = kv.first.as(); + if (config.inventories.count(inv_name)) + { + errors.push_back( + { ErrorCode::DuplicateInventoryDetected, + mc_fmt::format("Duplicate inventory name '{}'", inv_name) }); + continue; + } + const YAML::Node& entry = kv.second; + types::Inventory inv; + if (!entry[std::string(keys::directory)]) + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Inventory '{}' is missing required key 'directory'", inv_name) }); + else + inv.directory = entry[std::string(keys::directory)].as(); + + if (!entry[std::string(keys::file_pattern)]) + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Inventory '{}' is missing required key 'file pattern'", inv_name) }); + else + inv.file_pattern = entry[std::string(keys::file_pattern)].as(); + + if (!entry[std::string(keys::convention)]) + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Inventory '{}' is missing required key 'convention'", inv_name) }); + else + inv.convention = entry[std::string(keys::convention)].as(); + + config.inventories.emplace(inv_name, std::move(inv)); + } + } + } + + // species maps (optional) + if (node[std::string(keys::species_maps)]) + { + const YAML::Node& sm_node = node[std::string(keys::species_maps)]; + if (!sm_node.IsMap()) + { + errors.push_back({ ErrorCode::InvalidType, "'species maps' must be a mapping" }); + } + else + { + for (const auto& kv : sm_node) + { + const std::string map_name = kv.first.as(); + if (config.species_maps.count(map_name)) + { + errors.push_back( + { ErrorCode::DuplicateSpeciesMapDetected, + mc_fmt::format("Duplicate species map name '{}'", map_name) }); + continue; + } + const YAML::Node& entry = kv.second; + types::SpeciesMap smap; + + if (!entry[std::string(keys::mappings)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Species map '{}' is missing required key 'mappings'", map_name) }); + config.species_maps.emplace(map_name, std::move(smap)); + continue; + } + + std::unordered_map inv_species_scale_sum; + for (const auto& mapping_node : entry[std::string(keys::mappings)]) + { + types::SpeciesMapping m; + if (!mapping_node[std::string(keys::inventory_species)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("A mapping in species map '{}' is missing 'inventory species'", map_name) }); + continue; + } + if (!mapping_node[std::string(keys::mechanism_species)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("A mapping in species map '{}' is missing 'mechanism species'", map_name) }); + continue; + } + m.inventory_species = mapping_node[std::string(keys::inventory_species)].as(); + m.mechanism_species = mapping_node[std::string(keys::mechanism_species)].as(); + if (mapping_node[std::string(keys::scaling_factor)]) + m.scaling_factor = mapping_node[std::string(keys::scaling_factor)].as(); + inv_species_scale_sum[m.inventory_species] += m.scaling_factor; + smap.mappings.push_back(std::move(m)); + } + + for (const auto& [inv_sp, total] : inv_species_scale_sum) + { + if (total > 1.0 + 1e-9) + errors.push_back( + { ErrorCode::SpeciesMapScalingExceedsOne, + mc_fmt::format( + "Species map '{}': scaling factors for inventory species '{}' sum to {:.4f}, which exceeds 1.0", + map_name, + inv_sp, + total) }); + } + config.species_maps.emplace(map_name, std::move(smap)); + } + } + } + + // regridding (optional) + if (node[std::string(keys::regridding)]) + { + const YAML::Node& rg_node = node[std::string(keys::regridding)]; + if (rg_node[std::string(keys::type)]) + { + const std::string rg_type = rg_node[std::string(keys::type)].as(); + if (rg_type == std::string(keys::regridding_none)) + { + config.regridding.type = types::RegriddingType::None; + } + else + { + const ErrorLocation loc = LocationOf(rg_node[std::string(keys::type)]); + errors.push_back( + { ErrorCode::UnsupportedRegriddingType, + mc_fmt::format( + "{} error: Unsupported regridding type '{}'; only 'none' is supported in v1", loc, rg_type) }); + } + } + } + + // sources (optional) + if (node[std::string(keys::sources)]) + { + const YAML::Node& src_node = node[std::string(keys::sources)]; + if (!src_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'sources' must be a sequence" }); + } + else + { + std::set seen_names; + std::set> seen_cat_hier; + + for (const auto& s : src_node) + { + types::SourceDescriptor src; + + if (!s[std::string(keys::name)]) + { + errors.push_back({ ErrorCode::RequiredKeyNotFound, "A source entry is missing required key 'name'" }); + continue; + } + src.name = s[std::string(keys::name)].as(); + + if (seen_names.count(src.name)) + { + errors.push_back( + { ErrorCode::DuplicateSourceDetected, + mc_fmt::format("Duplicate source name '{}'", src.name) }); + continue; + } + seen_names.insert(src.name); + + if (!s[std::string(keys::mode)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Source '{}' is missing required key 'mode'", src.name) }); + } + else + { + const std::string mode_val = s[std::string(keys::mode)].as(); + if (mode_val == std::string(keys::mode_online)) + { + const ErrorLocation loc = LocationOf(s[std::string(keys::mode)]); + errors.push_back( + { ErrorCode::OnlineSourcesNotSupported, + mc_fmt::format( + "{} error: Source '{}' uses 'mode: online', which is not supported in v1", + loc, + src.name) }); + } + else if (mode_val != std::string(keys::mode_offline)) + { + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format("Source '{}' has unknown mode '{}'; expected 'offline'", src.name, mode_val) }); + } + } + + if (!s[std::string(keys::type)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Source '{}' is missing required key 'type'", src.name) }); + } + else + { + const std::string type_val = s[std::string(keys::type)].as(); + static const std::vector valid_types = { + keys::type_anthropogenic, keys::type_fire, keys::type_biogenic, + keys::type_dust, keys::type_sea_salt, keys::type_lightning + }; + bool found = false; + for (const auto& vt : valid_types) + if (type_val == std::string(vt)) + found = true; + if (!found) + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format("Source '{}' has unknown type '{}'", src.name, type_val) }); + else + src.type = ParseSourceType(type_val); + } + + if (!s[std::string(keys::inventory)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Source '{}' is missing required key 'inventory'", src.name) }); + } + else + { + src.inventory = s[std::string(keys::inventory)].as(); + if (!config.inventories.empty() && !config.inventories.count(src.inventory)) + errors.push_back( + { ErrorCode::SourceRequiresUnknownInventory, + mc_fmt::format( + "Source '{}' references inventory '{}' which is not declared in 'inventories'", + src.name, + src.inventory) }); + } + + if (!s[std::string(keys::species_map)]) + { + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("Source '{}' is missing required key 'species map'", src.name) }); + } + else + { + src.species_map = s[std::string(keys::species_map)].as(); + if (!config.species_maps.empty() && !config.species_maps.count(src.species_map)) + errors.push_back( + { ErrorCode::SourceRequiresUnknownSpeciesMap, + mc_fmt::format( + "Source '{}' references species map '{}' which is not declared in 'species maps'", + src.name, + src.species_map) }); + } + + if (s[std::string(keys::temporal_interpolation)]) + src.temporal_interpolation = + ParseTemporalInterpolation(s[std::string(keys::temporal_interpolation)].as()); + + if (s[std::string(keys::vertical_injection)]) + { + const std::string vi_val = s[std::string(keys::vertical_injection)].as(); + if (vi_val == std::string(keys::inject_plume)) + { + const ErrorLocation loc = LocationOf(s[std::string(keys::vertical_injection)]); + errors.push_back( + { ErrorCode::UnsupportedVerticalInjection, + mc_fmt::format( + "{} error: Source '{}' uses 'vertical injection: plume', which is not supported in v1", + loc, + src.name) }); + } + else if (vi_val != std::string(keys::inject_surface)) + { + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format( + "Source '{}' has unknown vertical injection '{}'; expected 'surface'", src.name, vi_val) }); + } + } + + if (s[std::string(keys::category)]) + src.category = s[std::string(keys::category)].as(); + if (s[std::string(keys::hierarchy)]) + src.hierarchy = s[std::string(keys::hierarchy)].as(); + if (s[std::string(keys::scaling_factor)]) + src.scaling_factor = s[std::string(keys::scaling_factor)].as(); + if (s[std::string(keys::sector)]) + src.sector = s[std::string(keys::sector)].as(); + + const auto cat_hier = std::make_pair(src.category, src.hierarchy); + if (seen_cat_hier.count(cat_hier)) + { + errors.push_back( + { ErrorCode::DuplicateCategoryHierarchy, + mc_fmt::format( + "Source '{}' has duplicate (category: {}, hierarchy: {}) — each (category, hierarchy) pair must be unique", + src.name, + src.category, + src.hierarchy) }); + } + else + { + seen_cat_hier.insert(cat_hier); + } + + src.unknown_properties = GetUnknownProperties(s); + config.sources.push_back(std::move(src)); + } + } + } + + if (!errors.empty()) + return std::unexpected(std::move(errors)); + return config; + } + // Appends each component under `key` (reactant- or product-like) as a located reference. void CollectComponents(const YAML::Node& reaction, std::string_view key, std::vector& out) { @@ -212,7 +575,7 @@ namespace mechanism_configuration::v1 Errors errors; std::vector required_keys = { keys::version, keys::species, keys::phases, keys::reactions }; - std::vector optional_keys = { keys::name }; + std::vector optional_keys = { keys::name, keys::emissions }; // Return early if the required keys are not found auto schema_errors = mechanism_configuration::CheckSchema(object, required_keys, optional_keys); @@ -317,13 +680,29 @@ namespace mechanism_configuration::v1 errors.insert(errors.end(), semantic_errors.begin(), semantic_errors.end()); } + // 3) Parse optional emissions section. + std::optional emissions; + if (errors.empty() && object[std::string(keys::emissions)]) + { + auto em = ParseEmissionsSection(object[std::string(keys::emissions)]); + if (!em) + { + AppendFilePath(config_path_, em.error()); + errors.insert(errors.end(), em.error().begin(), em.error().end()); + } + else + { + emissions = std::move(*em); + } + } + if (!errors.empty()) { return std::unexpected(std::move(errors)); } - // 3) Build the Mechanism (only reached when fully valid). - return Build(object); + // 4) Build the Mechanism (only reached when fully valid). + return Build(object, std::move(emissions)); } catch (const std::exception& e) { @@ -333,7 +712,7 @@ namespace mechanism_configuration::v1 } } - Mechanism Parser::Build(const YAML::Node& object) + Mechanism Parser::Build(const YAML::Node& object, std::optional emissions) { Mechanism mechanism; @@ -341,6 +720,7 @@ namespace mechanism_configuration::v1 mechanism.species = ParseSpecies(object[keys::species]); mechanism.phases = ParsePhases(object[keys::phases]); mechanism.reactions = ParseReactions(object[keys::reactions]); + mechanism.emissions = std::move(emissions); if (object[keys::name]) { diff --git a/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml b/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml index b5cd5bba..914e740f 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml @@ -1,30 +1,32 @@ -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC -species maps: - bc map: - mappings: - - inventory species: bc_anth_sum - mechanism species: BC - -sources: - - name: source one - mode: offline - type: anthropogenic - inventory: cams bc - species map: bc map - category: 0 - hierarchy: 1 - - name: source two - mode: offline - type: fire - inventory: cams bc - species map: bc map - category: 0 - hierarchy: 1 + sources: + - name: source one + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 + - name: source two + mode: offline + type: fire + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml b/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml index ce382ef9..ac1e8a37 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml @@ -1,30 +1,32 @@ -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC -species maps: - bc map: - mappings: - - inventory species: bc_anth_sum - mechanism species: BC - -sources: - - name: CAMS black carbon - mode: offline - type: anthropogenic - inventory: cams bc - species map: bc map - category: 0 - hierarchy: 1 - - name: CAMS black carbon - mode: offline - type: anthropogenic - inventory: cams bc - species map: bc map - category: 1 - hierarchy: 1 + sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 1 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/missing_kind.yaml b/test/unit/emissions/v1/emissions_unit_configs/missing_kind.yaml deleted file mode 100644 index dab52a91..00000000 --- a/test/unit/emissions/v1/emissions_unit_configs/missing_kind.yaml +++ /dev/null @@ -1,8 +0,0 @@ -version: 1.0.0 -name: No kind field - -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo diff --git a/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml b/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml index b317a074..86766986 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml @@ -1,23 +1,25 @@ -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC -species maps: - bc map: - mappings: - - inventory species: bc_anth_sum - mechanism species: BC - -sources: - - name: online lightning - mode: online - type: lightning - inventory: cams bc - species map: bc map - category: 0 - hierarchy: 1 + sources: + - name: online lightning + mode: online + type: lightning + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml b/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml index 1fbea2b5..cc12d489 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml @@ -1,20 +1,22 @@ -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + species maps: + bad map: + mappings: + - inventory species: NOx + mechanism species: NO + scaling factor: 0.9 + - inventory species: NOx + mechanism species: NO2 + scaling factor: 0.5 -species maps: - bad map: - mappings: - - inventory species: NOx - mechanism species: NO - scaling factor: 0.9 - - inventory species: NOx - mechanism species: NO2 - scaling factor: 0.5 - -sources: [] + sources: [] diff --git a/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml b/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml index 6503308b..2e8fb749 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml @@ -1,23 +1,25 @@ -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC -species maps: - bc map: - mappings: - - inventory species: bc_anth_sum - mechanism species: BC - -sources: - - name: CAMS black carbon - mode: offline - type: anthropogenic - inventory: nonexistent inventory - species map: bc map - category: 0 - hierarchy: 1 + sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: nonexistent inventory + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml b/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml index 66f34d4c..ca45a953 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml @@ -1,23 +1,25 @@ -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC -species maps: - bc map: - mappings: - - inventory species: bc_anth_sum - mechanism species: BC - -sources: - - name: CAMS black carbon - mode: offline - type: anthropogenic - inventory: cams bc - species map: nonexistent map - category: 0 - hierarchy: 1 + sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: nonexistent map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml b/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml index 769f144a..6c5a9094 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml @@ -1,20 +1,22 @@ -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC -species maps: - bc map: - mappings: - - inventory species: bc_anth_sum - mechanism species: BC + regridding: + type: scrip + weights file: /path/to/weights.nc -regridding: - type: scrip - weights file: /path/to/weights.nc - -sources: [] + sources: [] diff --git a/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml b/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml index 4888f751..7c4fc0a6 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml @@ -1,24 +1,26 @@ -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC -species maps: - bc map: - mappings: - - inventory species: bc_anth_sum - mechanism species: BC - -sources: - - name: CAMS black carbon plume - mode: offline - type: fire - inventory: cams bc - species map: bc map - vertical injection: plume - category: 0 - hierarchy: 1 + sources: + - name: CAMS black carbon plume + mode: offline + type: fire + inventory: cams bc + species map: bc map + vertical injection: plume + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/valid.yaml b/test/unit/emissions/v1/emissions_unit_configs/valid.yaml index 04f3f867..f96eb729 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/valid.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/valid.yaml @@ -1,35 +1,34 @@ -kind: emissions version: 1.0.0 -name: Valid test emissions config +species: [] +phases: [] +reactions: [] +emissions: + inventories: + cams bc: + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo -data root: /test/data + species maps: + bc map: + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + scaling factor: 1.0 -inventories: - cams bc: - directory: cams - file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc - convention: uptempo + regridding: + type: none -species maps: - bc map: - mappings: - - inventory species: bc_anth_sum - mechanism species: BC - scaling factor: 1.0 - -regridding: - type: none - -sources: - - name: CAMS black carbon - mode: offline - type: anthropogenic - inventory: cams bc - species map: bc map - temporal interpolation: linear - vertical injection: surface - category: 0 - hierarchy: 1 - scaling factor: 1.0 - sector: anthropogenic - __notes: test source + sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + temporal interpolation: linear + vertical injection: surface + category: 0 + hierarchy: 1 + scaling factor: 1.0 + sector: anthropogenic + __notes: test source diff --git a/test/unit/emissions/v1/emissions_unit_configs/wrong_kind.yaml b/test/unit/emissions/v1/emissions_unit_configs/wrong_kind.yaml deleted file mode 100644 index 74437c2e..00000000 --- a/test/unit/emissions/v1/emissions_unit_configs/wrong_kind.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: mechanism -version: 1.0.0 -name: This is a chemistry config, not an emissions config -species: [] -phases: [] -reactions: [] diff --git a/test/unit/emissions/v1/test_parse_emissions.cpp b/test/unit/emissions/v1/test_parse_emissions.cpp index 5420d20d..b520c1a2 100644 --- a/test/unit/emissions/v1/test_parse_emissions.cpp +++ b/test/unit/emissions/v1/test_parse_emissions.cpp @@ -2,58 +2,53 @@ // University of Illinois at Urbana-Champaign // SPDX-License-Identifier: Apache-2.0 -#include #include +#include #include #include using namespace mechanism_configuration; -namespace em = mechanism_configuration::emissions::v1; -// Wrapper so string literals route to the file-path overload without ambiguity. static auto ParseFile(const char* p) { - return em::ParseEmissions(std::filesystem::path(p)); + return Parse(std::filesystem::path(p)); } -using em::ParseEmissions; TEST(EmissionsV1Parser, ParsesValidConfig) { auto result = ParseFile("emissions_unit_configs/valid.yaml"); ASSERT_TRUE(result) << "Parse failed: " << (result ? "" : result.error()[0].second); - const auto& config = *result; - EXPECT_EQ(config.name, "Valid test emissions config"); - EXPECT_EQ(config.data_root, "/test/data"); - EXPECT_EQ(config.version.major, 1u); + ASSERT_TRUE(result->emissions.has_value()); + const auto& em = *result->emissions; - ASSERT_EQ(config.inventories.size(), 1u); - ASSERT_TRUE(config.inventories.count("cams bc")); - EXPECT_EQ(config.inventories.at("cams bc").directory, "cams"); - EXPECT_EQ(config.inventories.at("cams bc").file_pattern, "CAMS-GLOB-ANT_{YYYY}-{MM}.nc"); - EXPECT_EQ(config.inventories.at("cams bc").convention, "uptempo"); + ASSERT_EQ(em.inventories.size(), 1u); + ASSERT_TRUE(em.inventories.count("cams bc")); + EXPECT_EQ(em.inventories.at("cams bc").directory, "cams"); + EXPECT_EQ(em.inventories.at("cams bc").file_pattern, "CAMS-GLOB-ANT_{YYYY}-{MM}.nc"); + EXPECT_EQ(em.inventories.at("cams bc").convention, "uptempo"); - ASSERT_EQ(config.species_maps.size(), 1u); - ASSERT_TRUE(config.species_maps.count("bc map")); - const auto& smap = config.species_maps.at("bc map"); + ASSERT_EQ(em.species_maps.size(), 1u); + ASSERT_TRUE(em.species_maps.count("bc map")); + const auto& smap = em.species_maps.at("bc map"); ASSERT_EQ(smap.mappings.size(), 1u); EXPECT_EQ(smap.mappings[0].inventory_species, "bc_anth_sum"); EXPECT_EQ(smap.mappings[0].mechanism_species, "BC"); EXPECT_DOUBLE_EQ(smap.mappings[0].scaling_factor, 1.0); - EXPECT_EQ(config.regridding.type, em::types::RegriddingType::None); + EXPECT_EQ(em.regridding.type, types::RegriddingType::None); - ASSERT_EQ(config.sources.size(), 1u); - const auto& src = config.sources[0]; + ASSERT_EQ(em.sources.size(), 1u); + const auto& src = em.sources[0]; EXPECT_EQ(src.name, "CAMS black carbon"); - EXPECT_EQ(src.mode, em::types::SourceMode::Offline); - EXPECT_EQ(src.type, em::types::SourceType::Anthropogenic); + EXPECT_EQ(src.mode, types::SourceMode::Offline); + EXPECT_EQ(src.type, types::SourceType::Anthropogenic); EXPECT_EQ(src.inventory, "cams bc"); EXPECT_EQ(src.species_map, "bc map"); - EXPECT_EQ(src.temporal_interpolation, em::types::TemporalInterpolation::Linear); - EXPECT_EQ(src.vertical_injection, em::types::VerticalInjection::Surface); + EXPECT_EQ(src.temporal_interpolation, types::TemporalInterpolation::Linear); + EXPECT_EQ(src.vertical_injection, types::VerticalInjection::Surface); EXPECT_EQ(src.category, 0); EXPECT_EQ(src.hierarchy, 1); EXPECT_DOUBLE_EQ(src.scaling_factor, 1.0); @@ -65,58 +60,60 @@ TEST(EmissionsV1Parser, ParsesValidConfig) TEST(EmissionsV1Parser, ParsesValidConfigFromString) { const std::string content = R"( -kind: emissions version: 1.0.0 -name: In-memory test - -inventories: - my inv: - directory: data - file pattern: file_{YYYY}.nc - convention: uptempo - -species maps: - my map: - mappings: - - inventory species: SO2 - mechanism species: SO2 - -sources: - - name: so2 source - mode: offline - type: anthropogenic - inventory: my inv - species map: my map - category: 0 - hierarchy: 1 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + my inv: + directory: data + file pattern: file_{YYYY}.nc + convention: uptempo + species maps: + my map: + mappings: + - inventory species: SO2 + mechanism species: SO2 + sources: + - name: so2 source + mode: offline + type: anthropogenic + inventory: my inv + species map: my map + category: 0 + hierarchy: 1 )"; - auto result = ParseEmissions(content); + auto result = Parse(content); ASSERT_TRUE(result); - EXPECT_EQ(result->name, "In-memory test"); - ASSERT_EQ(result->sources.size(), 1u); - EXPECT_EQ(result->sources[0].name, "so2 source"); + ASSERT_TRUE(result->emissions.has_value()); + ASSERT_EQ(result->emissions->sources.size(), 1u); + EXPECT_EQ(result->emissions->sources[0].name, "so2 source"); } -TEST(EmissionsV1Parser, RejectsWrongKind) +TEST(EmissionsV1Parser, MechanismWithoutEmissionsHasNoEmissions) { - auto result = ParseFile("emissions_unit_configs/wrong_kind.yaml"); - ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::NotAnEmissionsConfig); -} - -TEST(EmissionsV1Parser, RejectsMissingKind) -{ - auto result = ParseFile("emissions_unit_configs/missing_kind.yaml"); - ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::NotAnEmissionsConfig); + const std::string content = R"( +version: 1.0.0 +species: [] +phases: [] +reactions: [] +)"; + auto result = Parse(content); + ASSERT_TRUE(result); + EXPECT_FALSE(result->emissions.has_value()); } TEST(EmissionsV1Parser, RejectsDuplicateSourceName) { auto result = ParseFile("emissions_unit_configs/duplicate_source.yaml"); ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::DuplicateSourceDetected); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::DuplicateSourceDetected) + found = true; + EXPECT_TRUE(found); } TEST(EmissionsV1Parser, RejectsDuplicateCategoryHierarchy) @@ -134,65 +131,97 @@ TEST(EmissionsV1Parser, RejectsSourceWithUnknownInventory) { auto result = ParseFile("emissions_unit_configs/unknown_inventory.yaml"); ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::SourceRequiresUnknownInventory); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::SourceRequiresUnknownInventory) + found = true; + EXPECT_TRUE(found); } TEST(EmissionsV1Parser, RejectsSourceWithUnknownSpeciesMap) { auto result = ParseFile("emissions_unit_configs/unknown_species_map.yaml"); ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::SourceRequiresUnknownSpeciesMap); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::SourceRequiresUnknownSpeciesMap) + found = true; + EXPECT_TRUE(found); } TEST(EmissionsV1Parser, RejectsScalingFactorExceedingOne) { auto result = ParseFile("emissions_unit_configs/scaling_exceeds_one.yaml"); ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::SpeciesMapScalingExceedsOne); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::SpeciesMapScalingExceedsOne) + found = true; + EXPECT_TRUE(found); } TEST(EmissionsV1Parser, RejectsOnlineSource) { auto result = ParseFile("emissions_unit_configs/online_source.yaml"); ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::OnlineSourcesNotSupported); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::OnlineSourcesNotSupported) + found = true; + EXPECT_TRUE(found); } TEST(EmissionsV1Parser, RejectsUnsupportedRegriddingType) { auto result = ParseFile("emissions_unit_configs/unsupported_regridding.yaml"); ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::UnsupportedRegriddingType); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::UnsupportedRegriddingType) + found = true; + EXPECT_TRUE(found); } TEST(EmissionsV1Parser, RejectsUnsupportedVerticalInjection) { auto result = ParseFile("emissions_unit_configs/unsupported_vertical_injection.yaml"); ASSERT_FALSE(result); - EXPECT_EQ(result.error()[0].first, ErrorCode::UnsupportedVerticalInjection); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::UnsupportedVerticalInjection) + found = true; + EXPECT_TRUE(found); } TEST(EmissionsV1Parser, AcceptsEmptySourcesList) { const std::string content = R"( -kind: emissions version: 1.0.0 -sources: [] +species: [] +phases: [] +reactions: [] +emissions: + sources: [] )"; - auto result = ParseEmissions(content); + auto result = Parse(content); ASSERT_TRUE(result); - EXPECT_TRUE(result->sources.empty()); + ASSERT_TRUE(result->emissions.has_value()); + EXPECT_TRUE(result->emissions->sources.empty()); } TEST(EmissionsV1Parser, AcceptsAbsentOptionalSections) { const std::string content = R"( -kind: emissions version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: {} )"; - auto result = ParseEmissions(content); + auto result = Parse(content); ASSERT_TRUE(result); - EXPECT_TRUE(result->sources.empty()); - EXPECT_TRUE(result->inventories.empty()); - EXPECT_TRUE(result->species_maps.empty()); + ASSERT_TRUE(result->emissions.has_value()); + EXPECT_TRUE(result->emissions->sources.empty()); + EXPECT_TRUE(result->emissions->inventories.empty()); + EXPECT_TRUE(result->emissions->species_maps.empty()); } From 3ee8da4fd319e598fe6e76a8217f2c1ce6dc03cc Mon Sep 17 00:00:00 2001 From: Scott Meech Date: Fri, 26 Jun 2026 11:30:04 -0600 Subject: [PATCH 4/6] fix: add Parse(string) overload to public API for in-memory documents The public Parse() only accepted a filesystem::path, so passing a std::string content literal silently converted to a path and failed with FileNotFound. Dispatches to v1::Parser for version 1 documents. Co-Authored-By: Claude Sonnet 4.6 --- include/mechanism_configuration/parse.hpp | 8 ++++++-- src/parse.cpp | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/include/mechanism_configuration/parse.hpp b/include/mechanism_configuration/parse.hpp index ee3d3849..8384ffa6 100644 --- a/include/mechanism_configuration/parse.hpp +++ b/include/mechanism_configuration/parse.hpp @@ -13,8 +13,12 @@ namespace mechanism_configuration { /// @brief Parse a mechanism configuration file, dispatching on its version. - /// @param config_path Path to a configuration file (a std::string converts implicitly), - /// or a directory of version-0 CAMP files. + /// @param config_path Path to a configuration file, or a directory of version-0 CAMP files. /// @return The parsed Mechanism, or all structural and semantic errors encountered. std::expected Parse(const std::filesystem::path& config_path); + + /// @brief Parse a mechanism configuration from an in-memory YAML or JSON string. + /// @param content The document contents (not a file path). + /// @return The parsed Mechanism, or all structural and semantic errors encountered. + std::expected Parse(const std::string& content); } // namespace mechanism_configuration \ No newline at end of file diff --git a/src/parse.cpp b/src/parse.cpp index 56b0ab61..3c500cf2 100644 --- a/src/parse.cpp +++ b/src/parse.cpp @@ -63,6 +63,29 @@ namespace mechanism_configuration ErrorLocation{ version_node.Mark().line, version_node.Mark().column } }; } + std::expected Parse(const std::string& content) + { + YAML::Node object; + try + { + object = YAML::Load(content); + } + catch (const YAML::Exception& e) + { + return std::unexpected( + Errors{ { ErrorCode::UnexpectedError, mc_fmt::format("Failed to parse document: {}", e.what()) } }); + } + + const unsigned int major = object["version"] ? Version(object["version"].as()).major : 0; + switch (major) + { + case 1: return v1::Parser{}.Parse(content); + default: + return std::unexpected(Errors{ { ErrorCode::InvalidVersion, + mc_fmt::format("Unsupported version number '{}'.", major) } }); + } + } + std::expected Parse(const std::filesystem::path& config_path) { auto version = GetVersion(config_path); From 44ef92547a56c81d5c10a7453fbe9e5875bba12a Mon Sep 17 00:00:00 2001 From: Scott Meech Date: Fri, 26 Jun 2026 11:43:41 -0600 Subject: [PATCH 5/6] fix: pass emissions section through ResolveFileConfig and use v1::Parser for string tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveFileConfig only forwarded species/phases/reactions into the combined node; the emissions: map was silently dropped so file-based parses never saw it. Added explicit copy of the emissions key alongside the existing version/name handling. Reverted the public Parse(std::string) overload added in the previous commit — it broke existing callers that passed std::string file paths (they started routing to the content parser instead of the path parser). String-based emission tests now use v1::Parser{} directly, consistent with how the existing integration tests work. Co-Authored-By: Claude Sonnet 4.6 --- include/mechanism_configuration/parse.hpp | 8 ++----- src/parse.cpp | 23 ------------------- src/v1/parser.cpp | 3 +++ .../emissions/v1/test_parse_emissions.cpp | 15 ++++++++---- 4 files changed, 16 insertions(+), 33 deletions(-) diff --git a/include/mechanism_configuration/parse.hpp b/include/mechanism_configuration/parse.hpp index 8384ffa6..ee3d3849 100644 --- a/include/mechanism_configuration/parse.hpp +++ b/include/mechanism_configuration/parse.hpp @@ -13,12 +13,8 @@ namespace mechanism_configuration { /// @brief Parse a mechanism configuration file, dispatching on its version. - /// @param config_path Path to a configuration file, or a directory of version-0 CAMP files. + /// @param config_path Path to a configuration file (a std::string converts implicitly), + /// or a directory of version-0 CAMP files. /// @return The parsed Mechanism, or all structural and semantic errors encountered. std::expected Parse(const std::filesystem::path& config_path); - - /// @brief Parse a mechanism configuration from an in-memory YAML or JSON string. - /// @param content The document contents (not a file path). - /// @return The parsed Mechanism, or all structural and semantic errors encountered. - std::expected Parse(const std::string& content); } // namespace mechanism_configuration \ No newline at end of file diff --git a/src/parse.cpp b/src/parse.cpp index 3c500cf2..56b0ab61 100644 --- a/src/parse.cpp +++ b/src/parse.cpp @@ -63,29 +63,6 @@ namespace mechanism_configuration ErrorLocation{ version_node.Mark().line, version_node.Mark().column } }; } - std::expected Parse(const std::string& content) - { - YAML::Node object; - try - { - object = YAML::Load(content); - } - catch (const YAML::Exception& e) - { - return std::unexpected( - Errors{ { ErrorCode::UnexpectedError, mc_fmt::format("Failed to parse document: {}", e.what()) } }); - } - - const unsigned int major = object["version"] ? Version(object["version"].as()).major : 0; - switch (major) - { - case 1: return v1::Parser{}.Parse(content); - default: - return std::unexpected(Errors{ { ErrorCode::InvalidVersion, - mc_fmt::format("Unsupported version number '{}'.", major) } }); - } - } - std::expected Parse(const std::filesystem::path& config_path) { auto version = GetVersion(config_path); diff --git a/src/v1/parser.cpp b/src/v1/parser.cpp index 42d0e825..7e7c78ea 100644 --- a/src/v1/parser.cpp +++ b/src/v1/parser.cpp @@ -561,6 +561,9 @@ namespace mechanism_configuration::v1 resolve_section(keys::phases); resolve_section(keys::reactions); + if (object[std::string(keys::emissions)]) + combined[std::string(keys::emissions)] = object[std::string(keys::emissions)]; + if (!errors.empty()) { AppendFilePath(config_path_, errors); diff --git a/test/unit/emissions/v1/test_parse_emissions.cpp b/test/unit/emissions/v1/test_parse_emissions.cpp index b520c1a2..985a590a 100644 --- a/test/unit/emissions/v1/test_parse_emissions.cpp +++ b/test/unit/emissions/v1/test_parse_emissions.cpp @@ -2,6 +2,8 @@ // University of Illinois at Urbana-Champaign // SPDX-License-Identifier: Apache-2.0 +#include "detail/v1/parser.hpp" + #include #include @@ -16,6 +18,11 @@ static auto ParseFile(const char* p) return Parse(std::filesystem::path(p)); } +static auto ParseString(const std::string& content) +{ + return v1::Parser{}.Parse(content); +} + TEST(EmissionsV1Parser, ParsesValidConfig) { auto result = ParseFile("emissions_unit_configs/valid.yaml"); @@ -85,7 +92,7 @@ reactions: [] hierarchy: 1 )"; - auto result = Parse(content); + auto result = ParseString(content); ASSERT_TRUE(result); ASSERT_TRUE(result->emissions.has_value()); ASSERT_EQ(result->emissions->sources.size(), 1u); @@ -100,7 +107,7 @@ species: [] phases: [] reactions: [] )"; - auto result = Parse(content); + auto result = ParseString(content); ASSERT_TRUE(result); EXPECT_FALSE(result->emissions.has_value()); } @@ -203,7 +210,7 @@ reactions: [] emissions: sources: [] )"; - auto result = Parse(content); + auto result = ParseString(content); ASSERT_TRUE(result); ASSERT_TRUE(result->emissions.has_value()); EXPECT_TRUE(result->emissions->sources.empty()); @@ -218,7 +225,7 @@ phases: [] reactions: [] emissions: {} )"; - auto result = Parse(content); + auto result = ParseString(content); ASSERT_TRUE(result); ASSERT_TRUE(result->emissions.has_value()); EXPECT_TRUE(result->emissions->sources.empty()); From 51c4647968f3e502915980da32de361bf8f9e497 Mon Sep 17 00:00:00 2001 From: Scott Meech Date: Thu, 2 Jul 2026 08:19:05 -0600 Subject: [PATCH 6/6] refactor: reshape emissions schema to name-keyed lists, split parser, check emissions in Validate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses K20shores' second review round on #281: - inventories/species maps changed from YAML-key-is-name maps to lists of objects with an explicit `name` field; Inventory/SpeciesMap structs gained a name field and EmissionsConfig fields moved from unordered_map to vector to match - Emissions parsing moved out of src/v1/parser.cpp into its own src/detail/v1/emissions_parser.hpp + src/v1/emissions_parser.cpp, mirroring the existing CheckXSchema/ParseX split used by species/phases/reactions - Parser::Build() no longer takes an emissions parameter; emissions are parsed and assigned after Build() in ValidateAndBuild - Validate() now checks emissions semantics (duplicate inventory/ species-map/source names, duplicate (category, hierarchy) pairs, unknown inventory/species-map references, scaling-factor sums) for in-code Mechanisms, not just YAML parses — extended semantics::Input with EmissionsRef/SpeciesMapRef/SourceRef and added the checks to ValidateSemantics. This also tightens behavior: a source referencing an unknown inventory/species-map is now always rejected, even when the inventories/species maps section is entirely absent (previously silently accepted in that case) - full_configuration.json gained a matching emissions block (had none before); full_configuration.yaml and all emissions test fixtures updated to the new list shape - test_validate.cpp gained 9 in-code emissions cases Co-Authored-By: Claude Sonnet 5 --- examples/v1/full_configuration.json | 55 ++- examples/v1/full_configuration.yaml | 4 +- include/mechanism_configuration/types.hpp | 6 +- include/mechanism_configuration/validate.hpp | 32 ++ src/detail/v1/emissions_parser.hpp | 32 ++ src/detail/v1/parser.hpp | 6 +- src/detail/v1/utils.hpp | 3 + src/v1/CMakeLists.txt | 1 + src/v1/emissions_parser.cpp | 294 +++++++++++ src/v1/parser.cpp | 457 +++--------------- src/v1/utils.cpp | 5 + src/validate.cpp | 119 +++++ .../duplicate_category_hierarchy.yaml | 4 +- .../duplicate_source.yaml | 4 +- .../emissions_unit_configs/online_source.yaml | 4 +- .../scaling_exceeds_one.yaml | 4 +- .../unknown_inventory.yaml | 4 +- .../unknown_species_map.yaml | 4 +- .../unsupported_regridding.yaml | 4 +- .../unsupported_vertical_injection.yaml | 4 +- .../v1/emissions_unit_configs/valid.yaml | 4 +- .../emissions/v1/test_parse_emissions.cpp | 16 +- test/unit/test_validate.cpp | 138 ++++++ 23 files changed, 782 insertions(+), 422 deletions(-) create mode 100644 src/detail/v1/emissions_parser.hpp create mode 100644 src/v1/emissions_parser.cpp diff --git a/examples/v1/full_configuration.json b/examples/v1/full_configuration.json index c67b108b..b012ac86 100644 --- a/examples/v1/full_configuration.json +++ b/examples/v1/full_configuration.json @@ -328,5 +328,58 @@ } ] } - ] + ], + "emissions": { + "inventories": [ + { + "name": "cams global anthro", + "directory": "cams/v6.2", + "file pattern": "CAMS-GLOB-ANT_v6.2_{YYYY}-{MM}.nc", + "convention": "uptempo" + } + ], + "species maps": [ + { + "name": "MOZART-T1 from CAMS", + "mappings": [ + { + "inventory species": "NOx", + "mechanism species": "NO", + "scaling factor": 0.9 + }, + { + "inventory species": "NOx", + "mechanism species": "NO2", + "scaling factor": 0.1 + }, + { + "inventory species": "CO", + "mechanism species": "CO" + }, + { + "inventory species": "bc_anth_sum", + "mechanism species": "BC" + } + ] + } + ], + "regridding": { + "type": "none" + }, + "sources": [ + { + "name": "cams anthro", + "mode": "offline", + "type": "anthropogenic", + "inventory": "cams global anthro", + "species map": "MOZART-T1 from CAMS", + "temporal interpolation": "linear", + "vertical injection": "surface", + "category": 0, + "hierarchy": 1, + "scaling factor": 1.0, + "sector": "anthropogenic" + } + ] + } } \ No newline at end of file diff --git a/examples/v1/full_configuration.yaml b/examples/v1/full_configuration.yaml index d850c2b0..ed504b37 100644 --- a/examples/v1/full_configuration.yaml +++ b/examples/v1/full_configuration.yaml @@ -201,12 +201,12 @@ reactions: coefficient: 1 emissions: inventories: - cams global anthro: + - name: cams global anthro directory: cams/v6.2 file pattern: CAMS-GLOB-ANT_v6.2_{YYYY}-{MM}.nc convention: uptempo species maps: - MOZART-T1 from CAMS: + - name: MOZART-T1 from CAMS mappings: - inventory species: NOx mechanism species: NO diff --git a/include/mechanism_configuration/types.hpp b/include/mechanism_configuration/types.hpp index a7b3a775..9b2849e4 100644 --- a/include/mechanism_configuration/types.hpp +++ b/include/mechanism_configuration/types.hpp @@ -337,11 +337,13 @@ namespace mechanism_configuration::types struct SpeciesMap { + std::string name; std::vector mappings; }; struct Inventory { + std::string name; std::string directory; std::string file_pattern; std::string convention; @@ -402,8 +404,8 @@ namespace mechanism_configuration::types struct EmissionsConfig { - std::unordered_map inventories; - std::unordered_map species_maps; + std::vector inventories; + std::vector species_maps; Regridding regridding; std::vector sources; }; diff --git a/include/mechanism_configuration/validate.hpp b/include/mechanism_configuration/validate.hpp index c603271a..63f6b668 100644 --- a/include/mechanism_configuration/validate.hpp +++ b/include/mechanism_configuration/validate.hpp @@ -41,11 +41,43 @@ namespace mechanism_configuration std::optional location; }; + struct SpeciesMappingRef + { + std::string inventory_species; + std::string mechanism_species; + double scaling_factor{ 1.0 }; + }; + + struct SpeciesMapRef + { + std::string name; + std::vector mappings; + std::optional location; + }; + + struct SourceRef + { + std::string name; + NamedRef inventory; // .name = referenced inventory name, .location = reference site + NamedRef species_map; // .name = referenced species-map name, .location = reference site + int category{ 0 }; + int hierarchy{ 1 }; + std::optional location; // location of the source entry itself + }; + + struct EmissionsRef + { + std::vector inventories; + std::vector species_maps; + std::vector sources; + }; + struct Input { std::vector species; std::vector phases; std::vector reactions; + std::optional emissions; // nullopt only when there's no emissions at all }; } // namespace semantics diff --git a/src/detail/v1/emissions_parser.hpp b/src/detail/v1/emissions_parser.hpp new file mode 100644 index 00000000..038ca30e --- /dev/null +++ b/src/detail/v1/emissions_parser.hpp @@ -0,0 +1,32 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include + +namespace mechanism_configuration::v1 +{ + // Structural (schema) validation of the emissions section. Checks required/optional keys, + // value shape, and fixed-enum-membership only; semantic invariants (duplicate names, unknown + // inventory/species-map references, scaling-factor sums) are checked separately by the + // version-neutral ValidateSemantics on the built Mechanism. + + /// @brief Schema-validates the emissions section: inventories, species maps (and their + /// mappings), regridding, and sources. + /// @param emissions_node YAML node for the `emissions` key + /// @return List of structural errors, or empty if the section conforms + Errors CheckEmissionsSchema(const YAML::Node& emissions_node); + + /// @brief Parses a YAML node into an EmissionsConfig. + /// The input must be validated using CheckEmissionsSchema(). + /// This function assumes the structure and types are correct. + /// @param emissions_node YAML node for the `emissions` key + /// @return The parsed EmissionsConfig + types::EmissionsConfig ParseEmissionsSection(const YAML::Node& emissions_node); + +} // namespace mechanism_configuration::v1 diff --git a/src/detail/v1/parser.hpp b/src/detail/v1/parser.hpp index ac338a6b..e581374d 100644 --- a/src/detail/v1/parser.hpp +++ b/src/detail/v1/parser.hpp @@ -11,7 +11,6 @@ #include #include -#include #include namespace mechanism_configuration::v1 @@ -58,8 +57,9 @@ namespace mechanism_configuration::v1 /// invariants are checked separately by ValidateSemantics. Errors CheckSchema(const YAML::Node& object); - /// @brief Constructs a Mechanism from an already-validated node. - Mechanism Build(const YAML::Node& object, std::optional emissions); + /// @brief Constructs a Mechanism from an already-validated node. The emissions section, if + /// present, is parsed separately and assigned by the caller (ValidateAndBuild). + Mechanism Build(const YAML::Node& object); inline void SetConfigPath(const std::string& config_path) { diff --git a/src/detail/v1/utils.hpp b/src/detail/v1/utils.hpp index 4411c8a3..79273453 100644 --- a/src/detail/v1/utils.hpp +++ b/src/detail/v1/utils.hpp @@ -24,6 +24,9 @@ namespace mechanism_configuration::v1 /// @return A YAML sequence node containing the original node(s) YAML::Node AsSequence(const YAML::Node& node); + /// @brief Extracts a YAML node's source location, for error messages that carry line:col. + ErrorLocation LocationOf(const YAML::Node& node); + void AppendFilePath(const std::string& config_path, Errors& errors); std::unordered_map GetComments(const YAML::Node& object); diff --git a/src/v1/CMakeLists.txt b/src/v1/CMakeLists.txt index d752c785..8ace9413 100644 --- a/src/v1/CMakeLists.txt +++ b/src/v1/CMakeLists.txt @@ -1,5 +1,6 @@ target_sources(mechanism_configuration PRIVATE + emissions_parser.cpp parser.cpp type_parsers.cpp type_schema.cpp diff --git a/src/v1/emissions_parser.cpp b/src/v1/emissions_parser.cpp new file mode 100644 index 00000000..fffb0a8c --- /dev/null +++ b/src/v1/emissions_parser.cpp @@ -0,0 +1,294 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#include "detail/v1/emissions_parser.hpp" + +#include "detail/check_schema.hpp" +#include "detail/v1/keys.hpp" +#include "detail/v1/utils.hpp" + +#include +#include + +#include +#include +#include + +namespace mechanism_configuration::v1 +{ + namespace + { + types::SourceType ParseSourceType(const std::string& s) + { + if (s == std::string(keys::type_fire)) return types::SourceType::Fire; + if (s == std::string(keys::type_biogenic)) return types::SourceType::Biogenic; + if (s == std::string(keys::type_dust)) return types::SourceType::Dust; + if (s == std::string(keys::type_sea_salt)) return types::SourceType::SeaSalt; + if (s == std::string(keys::type_lightning)) return types::SourceType::Lightning; + return types::SourceType::Anthropogenic; + } + + types::TemporalInterpolation ParseTemporalInterpolation(const std::string& s) + { + if (s == std::string(keys::interp_nearest)) return types::TemporalInterpolation::Nearest; + if (s == std::string(keys::interp_none)) return types::TemporalInterpolation::None; + return types::TemporalInterpolation::Linear; + } + + Errors CheckInventoriesSchema(const YAML::Node& inventories_node) + { + Errors errors; + if (!inventories_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'inventories' must be a sequence" }); + return errors; + } + + const std::vector required_keys = { keys::name, keys::directory, keys::file_pattern, keys::convention }; + const std::vector optional_keys = {}; + for (const auto& item : inventories_node) + { + auto schema_errors = CheckSchema(item, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + } + return errors; + } + + Errors CheckMappingsSchema(const YAML::Node& mappings_node) + { + Errors errors; + if (!mappings_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'mappings' must be a sequence" }); + return errors; + } + + const std::vector required_keys = { keys::inventory_species, keys::mechanism_species }; + const std::vector optional_keys = { keys::scaling_factor }; + for (const auto& item : mappings_node) + { + auto schema_errors = CheckSchema(item, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + } + return errors; + } + + Errors CheckSpeciesMapsSchema(const YAML::Node& species_maps_node) + { + Errors errors; + if (!species_maps_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'species maps' must be a sequence" }); + return errors; + } + + const std::vector required_keys = { keys::name, keys::mappings }; + const std::vector optional_keys = {}; + for (const auto& item : species_maps_node) + { + auto schema_errors = CheckSchema(item, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + + if (item[std::string(keys::mappings)]) + { + auto mapping_errors = CheckMappingsSchema(item[std::string(keys::mappings)]); + errors.insert(errors.end(), mapping_errors.begin(), mapping_errors.end()); + } + } + return errors; + } + + Errors CheckSourcesSchema(const YAML::Node& sources_node) + { + Errors errors; + if (!sources_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'sources' must be a sequence" }); + return errors; + } + + const std::vector required_keys = { keys::name, keys::mode, + keys::type, keys::inventory, + keys::species_map }; + const std::vector optional_keys = { keys::temporal_interpolation, + keys::vertical_injection, + keys::category, + keys::hierarchy, + keys::scaling_factor, + keys::sector }; + + for (const auto& item : sources_node) + { + auto schema_errors = CheckSchema(item, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + + // Fixed-enum-membership checks: the value is validated against a compile-time fixed + // set, not a cross-reference to another user-supplied name, so this stays structural. + if (item[std::string(keys::mode)]) + { + const std::string mode_val = item[std::string(keys::mode)].as(); + if (mode_val == std::string(keys::mode_online)) + { + const ErrorLocation loc = LocationOf(item[std::string(keys::mode)]); + errors.push_back( + { ErrorCode::OnlineSourcesNotSupported, + mc_fmt::format("{} error: 'mode: online' is not supported in v1", loc) }); + } + else if (mode_val != std::string(keys::mode_offline)) + { + errors.push_back( + { ErrorCode::UnknownType, mc_fmt::format("Unknown mode '{}'; expected 'offline'", mode_val) }); + } + } + + if (item[std::string(keys::type)]) + { + const std::string type_val = item[std::string(keys::type)].as(); + static const std::vector valid_types = { keys::type_anthropogenic, keys::type_fire, + keys::type_biogenic, keys::type_dust, + keys::type_sea_salt, keys::type_lightning }; + bool found = false; + for (const auto& vt : valid_types) + if (type_val == std::string(vt)) + found = true; + if (!found) + errors.push_back({ ErrorCode::UnknownType, mc_fmt::format("Unknown source type '{}'", type_val) }); + } + + if (item[std::string(keys::vertical_injection)]) + { + const std::string vi_val = item[std::string(keys::vertical_injection)].as(); + if (vi_val == std::string(keys::inject_plume)) + { + const ErrorLocation loc = LocationOf(item[std::string(keys::vertical_injection)]); + errors.push_back( + { ErrorCode::UnsupportedVerticalInjection, + mc_fmt::format("{} error: 'vertical injection: plume' is not supported in v1", loc) }); + } + else if (vi_val != std::string(keys::inject_surface)) + { + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format("Unknown vertical injection '{}'; expected 'surface'", vi_val) }); + } + } + } + return errors; + } + } // namespace + + Errors CheckEmissionsSchema(const YAML::Node& emissions_node) + { + Errors errors; + + if (emissions_node[std::string(keys::inventories)]) + { + auto e = CheckInventoriesSchema(emissions_node[std::string(keys::inventories)]); + errors.insert(errors.end(), e.begin(), e.end()); + } + + if (emissions_node[std::string(keys::species_maps)]) + { + auto e = CheckSpeciesMapsSchema(emissions_node[std::string(keys::species_maps)]); + errors.insert(errors.end(), e.begin(), e.end()); + } + + if (emissions_node[std::string(keys::regridding)]) + { + const YAML::Node& rg_node = emissions_node[std::string(keys::regridding)]; + if (rg_node[std::string(keys::type)]) + { + const std::string rg_type = rg_node[std::string(keys::type)].as(); + if (rg_type != std::string(keys::regridding_none)) + { + const ErrorLocation loc = LocationOf(rg_node[std::string(keys::type)]); + errors.push_back( + { ErrorCode::UnsupportedRegriddingType, + mc_fmt::format( + "{} error: Unsupported regridding type '{}'; only 'none' is supported in v1", loc, rg_type) }); + } + } + } + + if (emissions_node[std::string(keys::sources)]) + { + auto e = CheckSourcesSchema(emissions_node[std::string(keys::sources)]); + errors.insert(errors.end(), e.begin(), e.end()); + } + + return errors; + } + + types::EmissionsConfig ParseEmissionsSection(const YAML::Node& node) + { + types::EmissionsConfig config; + + if (node[std::string(keys::inventories)]) + { + for (const auto& item : node[std::string(keys::inventories)]) + { + types::Inventory inv; + inv.name = item[std::string(keys::name)].as(); + inv.directory = item[std::string(keys::directory)].as(); + inv.file_pattern = item[std::string(keys::file_pattern)].as(); + inv.convention = item[std::string(keys::convention)].as(); + config.inventories.push_back(std::move(inv)); + } + } + + if (node[std::string(keys::species_maps)]) + { + for (const auto& item : node[std::string(keys::species_maps)]) + { + types::SpeciesMap smap; + smap.name = item[std::string(keys::name)].as(); + for (const auto& mapping_node : item[std::string(keys::mappings)]) + { + types::SpeciesMapping m; + m.inventory_species = mapping_node[std::string(keys::inventory_species)].as(); + m.mechanism_species = mapping_node[std::string(keys::mechanism_species)].as(); + if (mapping_node[std::string(keys::scaling_factor)]) + m.scaling_factor = mapping_node[std::string(keys::scaling_factor)].as(); + smap.mappings.push_back(std::move(m)); + } + config.species_maps.push_back(std::move(smap)); + } + } + + if (node[std::string(keys::regridding)] && node[std::string(keys::regridding)][std::string(keys::type)]) + { + config.regridding.type = types::RegriddingType::None; + } + + if (node[std::string(keys::sources)]) + { + for (const auto& s : node[std::string(keys::sources)]) + { + types::SourceDescriptor src; + src.name = s[std::string(keys::name)].as(); + src.type = ParseSourceType(s[std::string(keys::type)].as()); + src.inventory = s[std::string(keys::inventory)].as(); + src.species_map = s[std::string(keys::species_map)].as(); + + if (s[std::string(keys::temporal_interpolation)]) + src.temporal_interpolation = + ParseTemporalInterpolation(s[std::string(keys::temporal_interpolation)].as()); + + if (s[std::string(keys::category)]) + src.category = s[std::string(keys::category)].as(); + if (s[std::string(keys::hierarchy)]) + src.hierarchy = s[std::string(keys::hierarchy)].as(); + if (s[std::string(keys::scaling_factor)]) + src.scaling_factor = s[std::string(keys::scaling_factor)].as(); + if (s[std::string(keys::sector)]) + src.sector = s[std::string(keys::sector)].as(); + + src.unknown_properties = GetComments(s); + config.sources.push_back(std::move(src)); + } + } + + return config; + } +} // namespace mechanism_configuration::v1 diff --git a/src/v1/parser.cpp b/src/v1/parser.cpp index 7e7c78ea..48d74d93 100644 --- a/src/v1/parser.cpp +++ b/src/v1/parser.cpp @@ -5,6 +5,7 @@ #include "detail/v1/parser.hpp" #include "detail/check_schema.hpp" +#include "detail/v1/emissions_parser.hpp" #include "detail/v1/keys.hpp" #include "detail/v1/type_parsers.hpp" #include "detail/v1/type_schema.hpp" @@ -17,8 +18,6 @@ #include #include -#include -#include #include namespace mechanism_configuration::v1 @@ -42,372 +41,6 @@ namespace mechanism_configuration::v1 return EntityFormat::Invalid; } - ErrorLocation LocationOf(const YAML::Node& node) - { - return ErrorLocation{ node.Mark().line, node.Mark().column }; - } - - std::unordered_map GetUnknownProperties(const YAML::Node& node) - { - std::unordered_map props; - for (const auto& kv : node) - { - std::string k = kv.first.as(); - if (k.size() >= 2 && k[0] == '_' && k[1] == '_') - props[k] = kv.second.as(); - } - return props; - } - - types::SourceType ParseSourceType(const std::string& s) - { - if (s == std::string(keys::type_fire)) return types::SourceType::Fire; - if (s == std::string(keys::type_biogenic)) return types::SourceType::Biogenic; - if (s == std::string(keys::type_dust)) return types::SourceType::Dust; - if (s == std::string(keys::type_sea_salt)) return types::SourceType::SeaSalt; - if (s == std::string(keys::type_lightning)) return types::SourceType::Lightning; - return types::SourceType::Anthropogenic; - } - - types::TemporalInterpolation ParseTemporalInterpolation(const std::string& s) - { - if (s == std::string(keys::interp_nearest)) return types::TemporalInterpolation::Nearest; - if (s == std::string(keys::interp_none)) return types::TemporalInterpolation::None; - return types::TemporalInterpolation::Linear; - } - - std::expected ParseEmissionsSection(const YAML::Node& node) - { - Errors errors; - types::EmissionsConfig config; - - // inventories (optional) - if (node[std::string(keys::inventories)]) - { - const YAML::Node& inv_node = node[std::string(keys::inventories)]; - if (!inv_node.IsMap()) - { - errors.push_back({ ErrorCode::InvalidType, "'inventories' must be a mapping" }); - } - else - { - for (const auto& kv : inv_node) - { - const std::string inv_name = kv.first.as(); - if (config.inventories.count(inv_name)) - { - errors.push_back( - { ErrorCode::DuplicateInventoryDetected, - mc_fmt::format("Duplicate inventory name '{}'", inv_name) }); - continue; - } - const YAML::Node& entry = kv.second; - types::Inventory inv; - if (!entry[std::string(keys::directory)]) - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Inventory '{}' is missing required key 'directory'", inv_name) }); - else - inv.directory = entry[std::string(keys::directory)].as(); - - if (!entry[std::string(keys::file_pattern)]) - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Inventory '{}' is missing required key 'file pattern'", inv_name) }); - else - inv.file_pattern = entry[std::string(keys::file_pattern)].as(); - - if (!entry[std::string(keys::convention)]) - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Inventory '{}' is missing required key 'convention'", inv_name) }); - else - inv.convention = entry[std::string(keys::convention)].as(); - - config.inventories.emplace(inv_name, std::move(inv)); - } - } - } - - // species maps (optional) - if (node[std::string(keys::species_maps)]) - { - const YAML::Node& sm_node = node[std::string(keys::species_maps)]; - if (!sm_node.IsMap()) - { - errors.push_back({ ErrorCode::InvalidType, "'species maps' must be a mapping" }); - } - else - { - for (const auto& kv : sm_node) - { - const std::string map_name = kv.first.as(); - if (config.species_maps.count(map_name)) - { - errors.push_back( - { ErrorCode::DuplicateSpeciesMapDetected, - mc_fmt::format("Duplicate species map name '{}'", map_name) }); - continue; - } - const YAML::Node& entry = kv.second; - types::SpeciesMap smap; - - if (!entry[std::string(keys::mappings)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Species map '{}' is missing required key 'mappings'", map_name) }); - config.species_maps.emplace(map_name, std::move(smap)); - continue; - } - - std::unordered_map inv_species_scale_sum; - for (const auto& mapping_node : entry[std::string(keys::mappings)]) - { - types::SpeciesMapping m; - if (!mapping_node[std::string(keys::inventory_species)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("A mapping in species map '{}' is missing 'inventory species'", map_name) }); - continue; - } - if (!mapping_node[std::string(keys::mechanism_species)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("A mapping in species map '{}' is missing 'mechanism species'", map_name) }); - continue; - } - m.inventory_species = mapping_node[std::string(keys::inventory_species)].as(); - m.mechanism_species = mapping_node[std::string(keys::mechanism_species)].as(); - if (mapping_node[std::string(keys::scaling_factor)]) - m.scaling_factor = mapping_node[std::string(keys::scaling_factor)].as(); - inv_species_scale_sum[m.inventory_species] += m.scaling_factor; - smap.mappings.push_back(std::move(m)); - } - - for (const auto& [inv_sp, total] : inv_species_scale_sum) - { - if (total > 1.0 + 1e-9) - errors.push_back( - { ErrorCode::SpeciesMapScalingExceedsOne, - mc_fmt::format( - "Species map '{}': scaling factors for inventory species '{}' sum to {:.4f}, which exceeds 1.0", - map_name, - inv_sp, - total) }); - } - config.species_maps.emplace(map_name, std::move(smap)); - } - } - } - - // regridding (optional) - if (node[std::string(keys::regridding)]) - { - const YAML::Node& rg_node = node[std::string(keys::regridding)]; - if (rg_node[std::string(keys::type)]) - { - const std::string rg_type = rg_node[std::string(keys::type)].as(); - if (rg_type == std::string(keys::regridding_none)) - { - config.regridding.type = types::RegriddingType::None; - } - else - { - const ErrorLocation loc = LocationOf(rg_node[std::string(keys::type)]); - errors.push_back( - { ErrorCode::UnsupportedRegriddingType, - mc_fmt::format( - "{} error: Unsupported regridding type '{}'; only 'none' is supported in v1", loc, rg_type) }); - } - } - } - - // sources (optional) - if (node[std::string(keys::sources)]) - { - const YAML::Node& src_node = node[std::string(keys::sources)]; - if (!src_node.IsSequence()) - { - errors.push_back({ ErrorCode::InvalidType, "'sources' must be a sequence" }); - } - else - { - std::set seen_names; - std::set> seen_cat_hier; - - for (const auto& s : src_node) - { - types::SourceDescriptor src; - - if (!s[std::string(keys::name)]) - { - errors.push_back({ ErrorCode::RequiredKeyNotFound, "A source entry is missing required key 'name'" }); - continue; - } - src.name = s[std::string(keys::name)].as(); - - if (seen_names.count(src.name)) - { - errors.push_back( - { ErrorCode::DuplicateSourceDetected, - mc_fmt::format("Duplicate source name '{}'", src.name) }); - continue; - } - seen_names.insert(src.name); - - if (!s[std::string(keys::mode)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Source '{}' is missing required key 'mode'", src.name) }); - } - else - { - const std::string mode_val = s[std::string(keys::mode)].as(); - if (mode_val == std::string(keys::mode_online)) - { - const ErrorLocation loc = LocationOf(s[std::string(keys::mode)]); - errors.push_back( - { ErrorCode::OnlineSourcesNotSupported, - mc_fmt::format( - "{} error: Source '{}' uses 'mode: online', which is not supported in v1", - loc, - src.name) }); - } - else if (mode_val != std::string(keys::mode_offline)) - { - errors.push_back( - { ErrorCode::UnknownType, - mc_fmt::format("Source '{}' has unknown mode '{}'; expected 'offline'", src.name, mode_val) }); - } - } - - if (!s[std::string(keys::type)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Source '{}' is missing required key 'type'", src.name) }); - } - else - { - const std::string type_val = s[std::string(keys::type)].as(); - static const std::vector valid_types = { - keys::type_anthropogenic, keys::type_fire, keys::type_biogenic, - keys::type_dust, keys::type_sea_salt, keys::type_lightning - }; - bool found = false; - for (const auto& vt : valid_types) - if (type_val == std::string(vt)) - found = true; - if (!found) - errors.push_back( - { ErrorCode::UnknownType, - mc_fmt::format("Source '{}' has unknown type '{}'", src.name, type_val) }); - else - src.type = ParseSourceType(type_val); - } - - if (!s[std::string(keys::inventory)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Source '{}' is missing required key 'inventory'", src.name) }); - } - else - { - src.inventory = s[std::string(keys::inventory)].as(); - if (!config.inventories.empty() && !config.inventories.count(src.inventory)) - errors.push_back( - { ErrorCode::SourceRequiresUnknownInventory, - mc_fmt::format( - "Source '{}' references inventory '{}' which is not declared in 'inventories'", - src.name, - src.inventory) }); - } - - if (!s[std::string(keys::species_map)]) - { - errors.push_back( - { ErrorCode::RequiredKeyNotFound, - mc_fmt::format("Source '{}' is missing required key 'species map'", src.name) }); - } - else - { - src.species_map = s[std::string(keys::species_map)].as(); - if (!config.species_maps.empty() && !config.species_maps.count(src.species_map)) - errors.push_back( - { ErrorCode::SourceRequiresUnknownSpeciesMap, - mc_fmt::format( - "Source '{}' references species map '{}' which is not declared in 'species maps'", - src.name, - src.species_map) }); - } - - if (s[std::string(keys::temporal_interpolation)]) - src.temporal_interpolation = - ParseTemporalInterpolation(s[std::string(keys::temporal_interpolation)].as()); - - if (s[std::string(keys::vertical_injection)]) - { - const std::string vi_val = s[std::string(keys::vertical_injection)].as(); - if (vi_val == std::string(keys::inject_plume)) - { - const ErrorLocation loc = LocationOf(s[std::string(keys::vertical_injection)]); - errors.push_back( - { ErrorCode::UnsupportedVerticalInjection, - mc_fmt::format( - "{} error: Source '{}' uses 'vertical injection: plume', which is not supported in v1", - loc, - src.name) }); - } - else if (vi_val != std::string(keys::inject_surface)) - { - errors.push_back( - { ErrorCode::UnknownType, - mc_fmt::format( - "Source '{}' has unknown vertical injection '{}'; expected 'surface'", src.name, vi_val) }); - } - } - - if (s[std::string(keys::category)]) - src.category = s[std::string(keys::category)].as(); - if (s[std::string(keys::hierarchy)]) - src.hierarchy = s[std::string(keys::hierarchy)].as(); - if (s[std::string(keys::scaling_factor)]) - src.scaling_factor = s[std::string(keys::scaling_factor)].as(); - if (s[std::string(keys::sector)]) - src.sector = s[std::string(keys::sector)].as(); - - const auto cat_hier = std::make_pair(src.category, src.hierarchy); - if (seen_cat_hier.count(cat_hier)) - { - errors.push_back( - { ErrorCode::DuplicateCategoryHierarchy, - mc_fmt::format( - "Source '{}' has duplicate (category: {}, hierarchy: {}) — each (category, hierarchy) pair must be unique", - src.name, - src.category, - src.hierarchy) }); - } - else - { - seen_cat_hier.insert(cat_hier); - } - - src.unknown_properties = GetUnknownProperties(s); - config.sources.push_back(std::move(src)); - } - } - } - - if (!errors.empty()) - return std::unexpected(std::move(errors)); - return config; - } - // Appends each component under `key` (reactant- or product-like) as a located reference. void CollectComponents(const YAML::Node& reaction, std::string_view key, std::vector& out) { @@ -461,6 +94,55 @@ namespace mechanism_configuration::v1 input.reactions.push_back(std::move(rr)); } + if (object[std::string(keys::emissions)]) + { + const YAML::Node& emissions_node = object[std::string(keys::emissions)]; + semantics::EmissionsRef emissions_ref; + + if (emissions_node[std::string(keys::inventories)]) + for (const auto& item : emissions_node[std::string(keys::inventories)]) + emissions_ref.inventories.push_back( + { item[std::string(keys::name)].as(), LocationOf(item) }); + + if (emissions_node[std::string(keys::species_maps)]) + for (const auto& item : emissions_node[std::string(keys::species_maps)]) + { + semantics::SpeciesMapRef smap_ref; + smap_ref.name = item[std::string(keys::name)].as(); + smap_ref.location = LocationOf(item); + if (item[std::string(keys::mappings)]) + for (const auto& mapping_node : item[std::string(keys::mappings)]) + { + semantics::SpeciesMappingRef mapping_ref; + mapping_ref.inventory_species = mapping_node[std::string(keys::inventory_species)].as(); + mapping_ref.mechanism_species = mapping_node[std::string(keys::mechanism_species)].as(); + if (mapping_node[std::string(keys::scaling_factor)]) + mapping_ref.scaling_factor = mapping_node[std::string(keys::scaling_factor)].as(); + smap_ref.mappings.push_back(std::move(mapping_ref)); + } + emissions_ref.species_maps.push_back(std::move(smap_ref)); + } + + if (emissions_node[std::string(keys::sources)]) + for (const auto& item : emissions_node[std::string(keys::sources)]) + { + semantics::SourceRef source_ref; + source_ref.name = item[std::string(keys::name)].as(); + source_ref.location = LocationOf(item); + source_ref.inventory = { item[std::string(keys::inventory)].as(), + LocationOf(item[std::string(keys::inventory)]) }; + source_ref.species_map = { item[std::string(keys::species_map)].as(), + LocationOf(item[std::string(keys::species_map)]) }; + if (item[std::string(keys::category)]) + source_ref.category = item[std::string(keys::category)].as(); + if (item[std::string(keys::hierarchy)]) + source_ref.hierarchy = item[std::string(keys::hierarchy)].as(); + emissions_ref.sources.push_back(std::move(source_ref)); + } + + input.emissions = std::move(emissions_ref); + } + return input; } @@ -631,6 +313,17 @@ namespace mechanism_configuration::v1 return errors; } + if (object[std::string(keys::emissions)]) + { + schema_errors = CheckEmissionsSchema(object[std::string(keys::emissions)]); + if (!schema_errors.empty()) + { + AppendFilePath(config_path_, schema_errors); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + return errors; + } + } + return errors; } @@ -683,29 +376,18 @@ namespace mechanism_configuration::v1 errors.insert(errors.end(), semantic_errors.begin(), semantic_errors.end()); } - // 3) Parse optional emissions section. - std::optional emissions; - if (errors.empty() && object[std::string(keys::emissions)]) - { - auto em = ParseEmissionsSection(object[std::string(keys::emissions)]); - if (!em) - { - AppendFilePath(config_path_, em.error()); - errors.insert(errors.end(), em.error().begin(), em.error().end()); - } - else - { - emissions = std::move(*em); - } - } - if (!errors.empty()) { return std::unexpected(std::move(errors)); } - // 4) Build the Mechanism (only reached when fully valid). - return Build(object, std::move(emissions)); + // 3) Build the Mechanism (only reached when fully valid). + Mechanism mechanism = Build(object); + if (object[std::string(keys::emissions)]) + { + mechanism.emissions = ParseEmissionsSection(object[std::string(keys::emissions)]); + } + return mechanism; } catch (const std::exception& e) { @@ -715,7 +397,7 @@ namespace mechanism_configuration::v1 } } - Mechanism Parser::Build(const YAML::Node& object, std::optional emissions) + Mechanism Parser::Build(const YAML::Node& object) { Mechanism mechanism; @@ -723,7 +405,6 @@ namespace mechanism_configuration::v1 mechanism.species = ParseSpecies(object[keys::species]); mechanism.phases = ParsePhases(object[keys::phases]); mechanism.reactions = ParseReactions(object[keys::reactions]); - mechanism.emissions = std::move(emissions); if (object[keys::name]) { diff --git a/src/v1/utils.cpp b/src/v1/utils.cpp index 09e9e2dc..1a56afa1 100644 --- a/src/v1/utils.cpp +++ b/src/v1/utils.cpp @@ -29,6 +29,11 @@ namespace mechanism_configuration return sequence; } + ErrorLocation LocationOf(const YAML::Node& node) + { + return ErrorLocation{ node.Mark().line, node.Mark().column }; + } + std::string GetReactionComponentName(const YAML::Node& component) { // A component may be given as a bare string (shorthand for its name), diff --git a/src/validate.cpp b/src/validate.cpp index 84772969..d63b2492 100644 --- a/src/validate.cpp +++ b/src/validate.cpp @@ -5,11 +5,13 @@ #include #include +#include #include #include #include #include #include +#include #include namespace mechanism_configuration @@ -109,6 +111,93 @@ namespace mechanism_configuration } } + // ---- Emissions ---------------------------------------------------------------------------- + if (input.emissions) + { + const auto& emissions = *input.emissions; + + ReportDuplicates(emissions.inventories, ErrorCode::DuplicateInventoryDetected, "inventory", errors); + + std::vector species_map_names; + for (const auto& smap : emissions.species_maps) + species_map_names.push_back({ smap.name, smap.location }); + ReportDuplicates(species_map_names, ErrorCode::DuplicateSpeciesMapDetected, "species map", errors); + + std::vector source_names; + for (const auto& source : emissions.sources) + source_names.push_back({ source.name, source.location }); + ReportDuplicates(source_names, ErrorCode::DuplicateSourceDetected, "source", errors); + + std::unordered_set inventory_names; + for (const auto& inv : emissions.inventories) + inventory_names.insert(inv.name); + + std::unordered_set species_map_name_set; + for (const auto& smap : emissions.species_maps) + species_map_name_set.insert(smap.name); + + std::map, int> cat_hier_counts; + for (const auto& source : emissions.sources) + ++cat_hier_counts[{ source.category, source.hierarchy }]; + + for (const auto& source : emissions.sources) + { + if (!inventory_names.contains(source.inventory.name)) + errors.push_back( + { ErrorCode::SourceRequiresUnknownInventory, + Message( + source.inventory.location, + mc_fmt::format( + "Source '{}' references inventory '{}' which is not declared in 'inventories'.", + source.name, + source.inventory.name)) }); + + if (!species_map_name_set.contains(source.species_map.name)) + errors.push_back( + { ErrorCode::SourceRequiresUnknownSpeciesMap, + Message( + source.species_map.location, + mc_fmt::format( + "Source '{}' references species map '{}' which is not declared in 'species maps'.", + source.name, + source.species_map.name)) }); + + if (cat_hier_counts[{ source.category, source.hierarchy }] > 1) + errors.push_back( + { ErrorCode::DuplicateCategoryHierarchy, + Message( + source.location, + mc_fmt::format( + "Source '{}' has duplicate (category: {}, hierarchy: {}) — each (category, hierarchy) pair " + "must be unique.", + source.name, + source.category, + source.hierarchy)) }); + } + + for (const auto& smap : emissions.species_maps) + { + std::unordered_map scale_sum; + for (const auto& mapping : smap.mappings) + scale_sum[mapping.inventory_species] += mapping.scaling_factor; + + for (const auto& [inventory_species, total] : scale_sum) + { + if (total > 1.0 + 1e-9) + errors.push_back( + { ErrorCode::SpeciesMapScalingExceedsOne, + Message( + smap.location, + mc_fmt::format( + "Species map '{}': scaling factors for inventory species '{}' sum to {:.4f}, which exceeds " + "1.0.", + smap.name, + inventory_species, + total)) }); + } + } + } + return errors; } @@ -185,6 +274,36 @@ namespace mechanism_configuration add("BRANCHED", x.gas_phase, Refs(x.reactants), Refs(products)); } + if (mechanism.emissions) + { + semantics::EmissionsRef emissions_ref; + + for (const auto& inv : mechanism.emissions->inventories) + emissions_ref.inventories.push_back({ inv.name, std::nullopt }); + + for (const auto& smap : mechanism.emissions->species_maps) + { + semantics::SpeciesMapRef smap_ref; + smap_ref.name = smap.name; + for (const auto& mapping : smap.mappings) + smap_ref.mappings.push_back({ mapping.inventory_species, mapping.mechanism_species, mapping.scaling_factor }); + emissions_ref.species_maps.push_back(std::move(smap_ref)); + } + + for (const auto& source : mechanism.emissions->sources) + { + semantics::SourceRef source_ref; + source_ref.name = source.name; + source_ref.inventory = { source.inventory, std::nullopt }; + source_ref.species_map = { source.species_map, std::nullopt }; + source_ref.category = source.category; + source_ref.hierarchy = source.hierarchy; + emissions_ref.sources.push_back(std::move(source_ref)); + } + + input.emissions = std::move(emissions_ref); + } + return ValidateSemantics(input); } } // namespace mechanism_configuration diff --git a/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml b/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml index 914e740f..a410ab32 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bc map: + - name: bc map mappings: - inventory species: bc_anth_sum mechanism species: BC diff --git a/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml b/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml index ac1e8a37..1038c5e7 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bc map: + - name: bc map mappings: - inventory species: bc_anth_sum mechanism species: BC diff --git a/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml b/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml index 86766986..f0b95ca8 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bc map: + - name: bc map mappings: - inventory species: bc_anth_sum mechanism species: BC diff --git a/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml b/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml index cc12d489..8c32f139 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bad map: + - name: bad map mappings: - inventory species: NOx mechanism species: NO diff --git a/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml b/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml index 2e8fb749..cf35cc1c 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bc map: + - name: bc map mappings: - inventory species: bc_anth_sum mechanism species: BC diff --git a/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml b/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml index ca45a953..c6d1273b 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bc map: + - name: bc map mappings: - inventory species: bc_anth_sum mechanism species: BC diff --git a/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml b/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml index 6c5a9094..f79ecc17 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bc map: + - name: bc map mappings: - inventory species: bc_anth_sum mechanism species: BC diff --git a/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml b/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml index 7c4fc0a6..fc292c75 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bc map: + - name: bc map mappings: - inventory species: bc_anth_sum mechanism species: BC diff --git a/test/unit/emissions/v1/emissions_unit_configs/valid.yaml b/test/unit/emissions/v1/emissions_unit_configs/valid.yaml index f96eb729..f0be17b3 100644 --- a/test/unit/emissions/v1/emissions_unit_configs/valid.yaml +++ b/test/unit/emissions/v1/emissions_unit_configs/valid.yaml @@ -4,13 +4,13 @@ phases: [] reactions: [] emissions: inventories: - cams bc: + - name: cams bc directory: cams file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc convention: uptempo species maps: - bc map: + - name: bc map mappings: - inventory species: bc_anth_sum mechanism species: BC diff --git a/test/unit/emissions/v1/test_parse_emissions.cpp b/test/unit/emissions/v1/test_parse_emissions.cpp index 985a590a..152eba03 100644 --- a/test/unit/emissions/v1/test_parse_emissions.cpp +++ b/test/unit/emissions/v1/test_parse_emissions.cpp @@ -32,14 +32,14 @@ TEST(EmissionsV1Parser, ParsesValidConfig) const auto& em = *result->emissions; ASSERT_EQ(em.inventories.size(), 1u); - ASSERT_TRUE(em.inventories.count("cams bc")); - EXPECT_EQ(em.inventories.at("cams bc").directory, "cams"); - EXPECT_EQ(em.inventories.at("cams bc").file_pattern, "CAMS-GLOB-ANT_{YYYY}-{MM}.nc"); - EXPECT_EQ(em.inventories.at("cams bc").convention, "uptempo"); + EXPECT_EQ(em.inventories[0].name, "cams bc"); + EXPECT_EQ(em.inventories[0].directory, "cams"); + EXPECT_EQ(em.inventories[0].file_pattern, "CAMS-GLOB-ANT_{YYYY}-{MM}.nc"); + EXPECT_EQ(em.inventories[0].convention, "uptempo"); ASSERT_EQ(em.species_maps.size(), 1u); - ASSERT_TRUE(em.species_maps.count("bc map")); - const auto& smap = em.species_maps.at("bc map"); + const auto& smap = em.species_maps[0]; + EXPECT_EQ(smap.name, "bc map"); ASSERT_EQ(smap.mappings.size(), 1u); EXPECT_EQ(smap.mappings[0].inventory_species, "bc_anth_sum"); EXPECT_EQ(smap.mappings[0].mechanism_species, "BC"); @@ -73,12 +73,12 @@ phases: [] reactions: [] emissions: inventories: - my inv: + - name: my inv directory: data file pattern: file_{YYYY}.nc convention: uptempo species maps: - my map: + - name: my map mappings: - inventory species: SO2 mechanism species: SO2 diff --git a/test/unit/test_validate.cpp b/test/unit/test_validate.cpp index 583913c8..3cb15670 100644 --- a/test/unit/test_validate.cpp +++ b/test/unit/test_validate.cpp @@ -29,6 +29,45 @@ namespace return c; } + types::Inventory inventory(const std::string& name) + { + types::Inventory inv; + inv.name = name; + inv.directory = "data"; + inv.file_pattern = "file_{YYYY}.nc"; + inv.convention = "uptempo"; + return inv; + } + + types::SpeciesMapping mapping(const std::string& inventory_species, const std::string& mechanism_species, double scaling_factor = 1.0) + { + types::SpeciesMapping m; + m.inventory_species = inventory_species; + m.mechanism_species = mechanism_species; + m.scaling_factor = scaling_factor; + return m; + } + + types::SpeciesMap species_map(const std::string& name, std::vector mappings) + { + types::SpeciesMap smap; + smap.name = name; + smap.mappings = std::move(mappings); + return smap; + } + + types::SourceDescriptor source( + const std::string& name, const std::string& inventory_name, const std::string& species_map_name, int category = 0, int hierarchy = 1) + { + types::SourceDescriptor src; + src.name = name; + src.inventory = inventory_name; + src.species_map = species_map_name; + src.category = category; + src.hierarchy = hierarchy; + return src; + } + // gas phase {A, B}, aqueous phase {C}; species A, B, C. Mechanism BaseMechanism() { @@ -121,3 +160,102 @@ TEST(Validate, DetectsUnknownPhase) EXPECT_TRUE(HasCode(Validate(m), ErrorCode::UnknownPhase)); } + +// An in-code mechanism with a well-formed emissions section validates cleanly. +TEST(Validate, EmissionsValidConfigAccepted) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc") }; + em.species_maps = { species_map("bc map", { mapping("bc_anth_sum", "BC") }) }; + em.sources = { source("CAMS black carbon", "cams bc", "bc map") }; + m.emissions = em; + + EXPECT_TRUE(Validate(m).empty()); +} + +// A mechanism with no emissions section at all is unaffected (regression guard for the +// std::optional branch). +TEST(Validate, AcceptsMechanismWithoutEmissions) +{ + Mechanism m = BaseMechanism(); + EXPECT_TRUE(Validate(m).empty()); +} + +TEST(Validate, EmissionsDetectsDuplicateInventory) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc"), inventory("cams bc") }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::DuplicateInventoryDetected)); +} + +TEST(Validate, EmissionsDetectsDuplicateSpeciesMap) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.species_maps = { species_map("bc map", {}), species_map("bc map", {}) }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::DuplicateSpeciesMapDetected)); +} + +TEST(Validate, EmissionsDetectsDuplicateSource) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc") }; + em.species_maps = { species_map("bc map", {}) }; + em.sources = { source("CAMS black carbon", "cams bc", "bc map", 0, 1), source("CAMS black carbon", "cams bc", "bc map", 1, 1) }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::DuplicateSourceDetected)); +} + +TEST(Validate, EmissionsDetectsDuplicateCategoryHierarchy) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc") }; + em.species_maps = { species_map("bc map", {}) }; + em.sources = { source("source one", "cams bc", "bc map", 0, 1), source("source two", "cams bc", "bc map", 0, 1) }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::DuplicateCategoryHierarchy)); +} + +// The check runs unconditionally, even when `inventories` is empty/absent — a source cannot +// reference an inventory that was never declared. +TEST(Validate, EmissionsDetectsSourceWithUnknownInventory) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.species_maps = { species_map("bc map", {}) }; + em.sources = { source("CAMS black carbon", "nonexistent inventory", "bc map") }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::SourceRequiresUnknownInventory)); +} + +TEST(Validate, EmissionsDetectsSourceWithUnknownSpeciesMap) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc") }; + em.sources = { source("CAMS black carbon", "cams bc", "nonexistent map") }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::SourceRequiresUnknownSpeciesMap)); +} + +TEST(Validate, EmissionsDetectsScalingFactorExceedsOne) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.species_maps = { species_map("bad map", { mapping("NOx", "NO", 0.9), mapping("NOx", "NO2", 0.5) }) }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::SpeciesMapScalingExceedsOne)); +}