def parse_mechanism_micm(
name: str, data: dict[str, Any], *, convert_openatmos_activation_energy: bool = False
) -> MechanismDefinition:
"""Parse MICM/OpenAtmos standard dictionary into internal model."""
if not isinstance(data, dict) or "species" not in data or not data["species"]:
raise CompilationError(
stage="parsing",
message="OpenAtmos v1 data must define at least one species",
)
species = []
for s in data.get("species", []):
if not isinstance(s, dict) or not s.get("name"):
raise CompilationError(
stage="parsing",
message="Species in OpenAtmos v1 mechanism must have a name",
)
sp_name = s.get("name")
# Default to GAS if not specified in basic MICM
phase = PhaseMode.GAS
sp_type = str(s.get("type", "")).lower()
sp_role = str(s.get("role", "")).lower()
# Only explicit OpenAtmos fixed roles and the conventional third-body
# placeholders are non-evolving. Atmospheric species such as O2 and
# H2O may look like background constituents, but TS1/MICM evolves them
# and their tendency must therefore be part of the same ODE system.
if sp_name in ("AIR", "M") or sp_type == "fixed" or sp_role == "fixed":
role = "fixed"
else:
role = "variable"
species.append(
SpeciesDefinition(
name=sp_name,
phase=phase,
role=role,
solver_atol=s.get("_atol"),
solver_rtol=s.get("_rtol"),
)
)
phases = []
for p in data.get("phases", []):
phases.append(PhaseDefinition(name=p.get("name"), solver_mode=SolverMode.IMPLICIT))
# Build species name set for validation
species_names = {s.name for s in species}
reactions = []
equilibrium_reactions: list[EquilibriumDefinition] = []
def micm_signed_parameters(raw: dict[str, Any]) -> dict[str, Any]:
"""Match musica/MICM's signed activation-energy representation.
The OpenAtmos JSON serialisation carries positive activation energies;
musica converts them to MICM's signed ``exp(C / T)`` convention when it
constructs an Arrhenius or Troe object. MKPP lowers the latter form,
so apply the same conversion before symbolic rate/Jacobian generation.
"""
converted = dict(raw)
for key in ("C", "k0_C", "kinf_C", "C0", "C1", "C2", "C3"):
value = converted.get(key)
if isinstance(value, int | float):
converted[key] = -float(value)
for key in ("k0", "kinf"):
value = converted.get(key)
if isinstance(value, dict):
nested = dict(value)
if isinstance(nested.get("C"), int | float):
nested["C"] = -float(nested["C"])
converted[key] = nested
return converted
for r in data.get("reactions", []):
rtype = r.get("type", "UNKNOWN")
if rtype == "EQUILIBRIUM":
# Parse EQUILIBRIUM block into EquilibriumDefinition
system = r.get("system", "")
raw_total_species = r.get("total_species", {})
regime_blending = r.get("regime_blending", "sigmoid")
transition_width = r.get("transition_width", 0.05)
activity_model = r.get("activity_model", "fixed")
eq_constants = r.get("equilibrium_constants", {})
continuous_transition = r.get("continuous_transition", True)
# Flatten total_species: each element maps to [gas_species, *aerosol_species]
total_species: dict[str, list[str]] = {}
for element_name, element_data in raw_total_species.items():
gas_sp = element_data.get("gas", "")
aerosol_sp = element_data.get("aerosol", [])
if isinstance(aerosol_sp, str):
aerosol_sp = [aerosol_sp]
flat_list = [gas_sp] + aerosol_sp
total_species[element_name] = flat_list
# Validate all referenced species exist in the mechanism species list
for element_name, sp_list in total_species.items():
for sp_name in sp_list:
if sp_name not in species_names:
raise CompilationError(
stage="parsing",
message=f"EQUILIBRIUM references unknown species '{sp_name}' " f"in element '{element_name}'",
species_name=sp_name,
)
# Validate equilibrium constants have required params (A, dH, Tref)
for const_name, const_params in eq_constants.items():
for required_field in ("A", "dH", "Tref"):
if required_field not in const_params:
raise CompilationError(
stage="validation",
message=f"Equilibrium constant '{const_name}' requires A, dH, Tref parameters",
)
equilibrium_reactions.append(
EquilibriumDefinition(
system=system,
total_species=total_species,
regime_blending=regime_blending,
transition_width=transition_width,
activity_model=activity_model,
equilibrium_constants=eq_constants,
continuous_transition=continuous_transition,
relaxation_timescale_inv=r.get("relaxation_timescale_inv", 1e6),
)
)
# EQUILIBRIUM is not a kinetic reaction — do NOT add to reactions list
continue
# OpenAtmos surface uptake reactions encode their gas-phase side with
# dedicated fields rather than the ordinary reactants/products maps.
# Normalize them into the common representation so downstream
# symbolic lowering applies both the concentration dependence and the
# product stoichiometry. This is format-level behaviour, not a
# mechanism-specific convention.
if rtype.upper() == "SURFACE":
gas_species = r.get("gas-phase species")
reactants = {str(gas_species): 1.0} if gas_species else {}
products = _normalize_species_dict(r.get("gas-phase products", {}))
else:
reactants = _normalize_species_dict(r.get("reactants", {}))
products = _normalize_species_dict(r.get("products", {}))
# Extract all potential rate parameters instead of just A
# For MICM compliance, parameters can include k0, kinf, Fc, gamma, etc.
parameters = {}
for k, v in r.items():
if k not in ("type", "reactants", "products", "stiff", "continuous_transition"):
parameters[k] = v
if convert_openatmos_activation_energy:
parameters = micm_signed_parameters(parameters)
# Maintain backwards compat for the simple tests
base_rate = str(r.get("A", ""))
reactions.append(
ReactionDefinition(
reaction_type=rtype,
reactants=reactants,
products=products,
rate_expression=base_rate,
parameters=parameters,
stiff=r.get("stiff", False),
# OpenAtmos/MUSICA photolysis entries are continuous by definition;
# older YAML mechanisms may opt out explicitly.
continuous_transition=r.get("continuous_transition", rtype.upper() == "PHOTOLYSIS"),
)
)
# Detect PHASE_CHANGE / EQUILIBRIUM conflicts: collect species from each
phase_change_species: set[str] = set()
for rxn in reactions:
if rxn.reaction_type == "PHASE_CHANGE":
phase_change_species.update(rxn.reactants.keys())
phase_change_species.update(rxn.products.keys())
equilibrium_species: set[str] = set()
for eq_def in equilibrium_reactions:
for sp_list in eq_def.total_species.values():
equilibrium_species.update(sp_list)
overlap = phase_change_species & equilibrium_species
if overlap:
conflict_name = sorted(overlap)[0]
raise CompilationError(
stage="validation",
message=f"Species '{conflict_name}' cannot have both " f"PHASE_CHANGE and EQUILIBRIUM declarations",
species_name=conflict_name,
)
from .model import ArrayDefinition, HostInterfaceSchema
host_interface = None
if "host_interface" in data and "arrays" in data["host_interface"]:
arrays = []
for arr_data in data["host_interface"]["arrays"]:
arrays.append(
ArrayDefinition(
name=arr_data.get("name", "unknown"),
rank=arr_data.get("rank", 0),
layout=arr_data.get("layout", "LayoutLeft"),
extent=arr_data.get("extent"),
unit=arr_data.get("unit", "unknown"),
ownership=arr_data.get("ownership", "host"),
)
)
host_interface = HostInterfaceSchema(arrays=arrays)
# A kinetic reaction may declare an `activation_trigger` naming a runtime
# meteorological quantity, e.g. "meteo.cloud_liquid_water > 1.0e-6". When
# present, the reaction's rate is gated by a smooth indicator of that
# quantity and the generated solver must accept it as an extra per-cell
# equilibrium input. Only cloud liquid water is currently supported; an
# unrecognized quantity is rejected rather than silently ignored, because a
# dropped trigger would run ungated chemistry (the exact failure R1 guards).
_TRIGGER_QUANTITIES = {"meteo.cloud_liquid_water": "cloud_liquid_water"}
cloud_gated = False
for rxn in reactions:
trigger = rxn.parameters.get("activation_trigger")
if trigger is None:
continue
quantity = str(trigger).split(">")[0].strip()
if quantity not in _TRIGGER_QUANTITIES:
raise CompilationError(
stage="validation",
message=(
f"reaction declares unsupported activation_trigger '{trigger}'; "
f"supported quantities: {sorted(_TRIGGER_QUANTITIES)}"
),
)
cloud_gated = True
return MechanismDefinition(
name=name,
description=data.get("description", ""),
aerosol_representation=AerosolRepresentation.BULK,
species=species,
phases=phases,
reactions=reactions,
host_interface=host_interface,
equilibrium_reactions=equilibrium_reactions,
metadata=data.get("metadata", {}),
has_cloud_gated_reaction=cloud_gated,
)