Skip to content

Commit f388d3d

Browse files
authored
Merge pull request #174 from alongd/flux-mol-images
Flux diagrams with molecule images + main-workflow wiring
2 parents 287b92e + 3eaa8cb commit f388d3d

9 files changed

Lines changed: 601 additions & 27 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
uses: actions/checkout@v6
4444
with:
4545
repository: ReactionMechanismGenerator/ARC
46-
ref: linear_ts
46+
ref: main
4747
path: ARC
4848

4949
# 3. Checkout RMG Database

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,6 @@ htmlcov/*
149149
#VSCode files
150150
.vscode/*
151151
.vscode/launch.json
152+
153+
# Local-only agent working files (never commit)
154+
docs/superpowers/

‎docs/input_reference.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ t3:
1919
options:
2020
flux_adapter: RMG # optional, can use any implemented simulation adapter, default: 'RMG'
2121
profiles_adapter: RMG # optional, can use any implemented simulation adapter, default: 'RMG'
22+
generate_flux_diagrams: true # optional, whether the main T3 workflow generates flux diagrams
23+
# after the sensitivity-analysis step of each iteration,
24+
# default: True
25+
flux_diagrams_with_images: true # optional, whether to render each flux-diagram node as a
26+
# molecule structure image (True) instead of a plain text
27+
# label (False), default: True
28+
flux_diagram_reactors: null # optional, which reactor(s) to draw a flux diagram for:
29+
# None -> the first reactor only; a 1-based reactor number or a
30+
# list of numbers -> those reactors; 'all' -> every reactor,
31+
# default: None
2232
collision_violators_thermo: false # optional, whether to calculate thermo of species participating
2333
# in collision violating reactions, default: False
2434
collision_violators_rates: false # optional, whether to calculate rates of core collision violating

‎t3/main.py‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@
1919
import os
2020
import re
2121
import shutil
22+
import traceback
2223
from collections import deque
2324

25+
import cantera as ct
26+
2427
from arc.common import (get_number_with_ordinal_indicator,
2528
get_ordinal_indicator,
2629
key_by_val,
@@ -37,6 +40,7 @@
3740
from t3.common import (DATA_BASE_PATH,
3841
PROJECTS_BASE_PATH,
3942
VALID_CHARS,
43+
convert_termination_time_to_seconds,
4044
delete_root_rmg_log,
4145
get_species_by_label,
4246
sa_dict_from_yaml,
@@ -49,6 +53,8 @@
4953
from t3.runners.rmg_runner import rmg_runner, run_arkane_job
5054
from t3.schema import InputBase
5155
from t3.simulate.factory import simulate_factory
56+
from t3.utils.fix_cantera import fix_cantera
57+
from t3.utils.flux import generate_flux
5258
from t3.utils.libraries import append_to_rmg_libraries
5359
from t3.utils.writer import write_pdep_network_file, write_rmg_input_file
5460
from t3.utils.cantera_parser import load_cantera_yaml_file
@@ -293,6 +299,8 @@ def execute(self):
293299
content=comparison,
294300
)
295301

302+
self._generate_flux_diagrams()
303+
296304
additional_calcs_required = self.determine_species_and_reactions_to_calculate()
297305

298306
# ARC
@@ -321,6 +329,7 @@ def execute(self):
321329
f'------------------------------------------------------\n')
322330
self.set_paths()
323331
self.run_rmg(restart_rmg=False)
332+
self._generate_flux_diagrams()
324333

325334
self.logger.log_species_summary(species_dict=self.species)
326335
self.logger.log_reactions_summary(reactions_dict=self.reactions)
@@ -355,6 +364,7 @@ def set_paths(self,
355364
'chem annotated': os.path.join(iteration_path, 'RMG', 'chemkin', 'chem_annotated.inp'),
356365
'species dict': os.path.join(iteration_path, 'RMG', 'chemkin', 'species_dictionary.txt'),
357366
'figs': os.path.join(iteration_path, 'Figures'),
367+
'flux diagrams': os.path.join(iteration_path, 'flux'),
358368
'SA': os.path.join(iteration_path, 'SA'),
359369
'SA coefficients': os.path.join(iteration_path, 'SA', 'sa_coefficients.yml'),
360370
'SA dict': os.path.join(iteration_path, 'SA', 'sa.yaml'),
@@ -1551,6 +1561,189 @@ def add_reaction(self,
15511561
self.reactions[rxn_key].reasons.append(reason)
15521562
return None
15531563

1564+
def _select_flux_reactors(self) -> list[int]:
1565+
"""Resolve flux_diagram_reactors to 0-based reactor indices (None -> first reactor)."""
1566+
n = len(self.rmg['reactors'])
1567+
selection = self.t3['options']['flux_diagram_reactors']
1568+
if selection is None:
1569+
return [0] if n else []
1570+
if selection == 'all':
1571+
return list(range(n))
1572+
numbers = selection if isinstance(selection, list) else [selection]
1573+
indices = []
1574+
for num in numbers:
1575+
idx = num - 1 # option is 1-based
1576+
if 0 <= idx < n and idx not in indices:
1577+
indices.append(idx)
1578+
elif not (0 <= idx < n):
1579+
self.logger.warning(f'flux_diagram_reactors: reactor {num} out of range '
1580+
f'(have {n}); skipping.')
1581+
return indices
1582+
1583+
def _flux_observables(self) -> list[str]:
1584+
"""Base-label observables for flux, independent of whether SA ran (order-preserving dedupe)."""
1585+
labels = [s['label'] for s in self.rmg['species']
1586+
if s.get('observable') or s.get('SA_observable')] or list(self.sa_observables)
1587+
seen, deduped = set(), []
1588+
for label in labels:
1589+
if label not in seen:
1590+
seen.add(label)
1591+
deduped.append(label)
1592+
return deduped
1593+
1594+
def _cantera_name_map(self, model) -> tuple[dict, set, set]:
1595+
"""Return (base_map, ambiguous_bases, full_names).
1596+
1597+
base_map: base label ('H') -> full cantera name ('H(3)').
1598+
ambiguous_bases: base labels that map to >1 species (unusable as a base match).
1599+
full_names: the set of exact cantera species names (an exact full-name match always wins).
1600+
"""
1601+
base_map, ambiguous, full_names = dict(), set(), set()
1602+
for i in range(model.n_species):
1603+
name = model.species()[i].name
1604+
full_names.add(name)
1605+
base = name.split('(')[0]
1606+
if base in base_map and base_map[base] != name:
1607+
ambiguous.add(base)
1608+
base_map[base] = name
1609+
return base_map, ambiguous, full_names
1610+
1611+
def _resolve_species_name(self, label: str, base_map: dict, ambiguous: set,
1612+
full_names: set) -> str | None:
1613+
"""Resolve an rmg label to a full cantera name; exact full-name wins over base match."""
1614+
if label in full_names:
1615+
return label
1616+
if label in base_map and label not in ambiguous:
1617+
return base_map[label]
1618+
return None
1619+
1620+
def _flux_reactor_type(self) -> tuple[str, bool] | None:
1621+
"""Map the configured simulate adapter to (flux reactor_type, energy) or None if unsupported.
1622+
1623+
generate_flux supports only 'BatchP' and 'JSR'. PFR / constant-UV adapters have no flux
1624+
equivalent, so they are unsupported (skip+warn).
1625+
"""
1626+
adapter = ((self.t3.get('sensitivity') or {}).get('adapter') or '')
1627+
supported = {'CanteraJSR': ('JSR', False),
1628+
'CanteraConstantTP': ('BatchP', False),
1629+
'CanteraConstantHP': ('BatchP', True), # adiabatic const-P -> energy on
1630+
'RMGConstantTP': ('BatchP', False),
1631+
'': ('BatchP', False)} # no adapter: gas batch const T P default
1632+
unsupported = {'CanteraConstantUV', 'CanteraPFR', 'CanteraPFRTProfile'}
1633+
if adapter in supported:
1634+
if adapter == '':
1635+
self.logger.info('No sensitivity adapter set; assuming a BatchP flux reactor.')
1636+
return supported[adapter]
1637+
if adapter in unsupported:
1638+
self.logger.warning(f"Flux diagrams are not supported for simulate adapter "
1639+
f"'{adapter}'; skipping flux diagrams.")
1640+
return None
1641+
self.logger.warning(f"Unknown simulate adapter '{adapter}' — cannot determine the flux "
1642+
f"reactor type; skipping flux diagrams.")
1643+
return None
1644+
1645+
def _flux_conditions_from_reactor(self, reactor: dict, base_map: dict, ambiguous: set,
1646+
full_names: set, reactor_type: str, energy: bool) -> dict | None:
1647+
"""Build generate_flux conditions from one RMG reactor, or None if the reactor is unusable.
1648+
1649+
A reactor is skipped entirely (returns None) if it is liquid/ranged, lacks a termination
1650+
time, or if ANY species with positive concentration cannot be unambiguously mapped to a
1651+
cantera species — dropping a real reactant would silently distort the flux graph.
1652+
"""
1653+
if reactor.get('P') is None or reactor['type'] != 'gas batch constant T P':
1654+
self.logger.warning(f"Flux-diagram conditions are derived only from 'gas batch constant "
1655+
f"T P' reactor entries; skipping reactor entry of type "
1656+
f"'{reactor.get('type')}'.")
1657+
return None
1658+
if isinstance(reactor['T'], list) or isinstance(reactor['P'], list):
1659+
self.logger.warning('Flux diagrams do not support ranged T/P reactors; skipping.')
1660+
return None
1661+
if reactor.get('termination_time') is None:
1662+
self.logger.warning('Flux diagrams require a reactor termination_time; skipping.')
1663+
return None
1664+
composition = dict()
1665+
for spc in self.rmg['species']:
1666+
conc = spc['concentration']
1667+
if isinstance(conc, (list, tuple)):
1668+
self.logger.warning('Flux diagrams do not support ranged concentrations; skipping reactor.')
1669+
return None
1670+
if conc <= 0:
1671+
continue
1672+
name = self._resolve_species_name(spc['label'], base_map, ambiguous, full_names)
1673+
if name is None:
1674+
self.logger.warning(f"Flux diagrams: reactant '{spc['label']}' (concentration "
1675+
f"{conc}) could not be mapped to a cantera species; "
1676+
f"skipping this reactor to avoid a distorted flux graph.")
1677+
return None
1678+
composition[name] = conc
1679+
if not composition:
1680+
self.logger.warning('Flux diagrams: no positive-concentration species for this reactor; skipping.')
1681+
return None
1682+
t_final = convert_termination_time_to_seconds(reactor['termination_time'])
1683+
return {'reactor_type': reactor_type, 'energy': energy,
1684+
'T': reactor['T'], 'P': reactor['P'],
1685+
'times': [f * t_final for f in (0.1, 0.5, 1.0)], # sample sub-terminal + terminal
1686+
'composition': composition}
1687+
1688+
def _generate_flux_diagrams(self) -> None:
1689+
"""Generate flux diagrams for the selected reactor(s). Never raises."""
1690+
try:
1691+
if not self.t3['options']['generate_flux_diagrams']:
1692+
return
1693+
model_path = self.paths['cantera annotated']
1694+
if not os.path.isfile(model_path):
1695+
self.logger.warning(f'Skipping flux diagrams: cantera model not found at {model_path}.')
1696+
return
1697+
indices = self._select_flux_reactors()
1698+
if not indices:
1699+
return
1700+
reactor_kind = self._flux_reactor_type()
1701+
if reactor_kind is None:
1702+
return # unsupported adapter, already warned
1703+
reactor_type, energy = reactor_kind
1704+
observable_labels = self._flux_observables()
1705+
if not observable_labels:
1706+
self.logger.info('Skipping flux diagrams: no observables identified.')
1707+
return
1708+
fix_cantera(model_path=model_path) # fix before loading (generate_flux expects fixed)
1709+
model = ct.Solution(model_path)
1710+
base_map, ambiguous, full_names = self._cantera_name_map(model)
1711+
observables = [name for name in
1712+
(self._resolve_species_name(o, base_map, ambiguous, full_names)
1713+
for o in observable_labels) if name is not None]
1714+
if not observables:
1715+
self.logger.warning(f'Skipping flux diagrams: none of the observables '
1716+
f'{observable_labels} map to cantera species.')
1717+
return
1718+
draw_images = self.t3['options']['flux_diagrams_with_images']
1719+
species_dict_path = self.paths['species dict'] if draw_images else None
1720+
# Use a per-reactor subfolder whenever the user explicitly selected reactor(s) or when
1721+
# more than one reactor is drawn; the generic folder is reserved for the default case
1722+
# (no explicit selection -> the single first reactor), preserving reactor identity.
1723+
explicit = self.t3['options']['flux_diagram_reactors'] is not None
1724+
for idx in indices:
1725+
folder = self.paths['flux diagrams'] if (not explicit and len(indices) == 1) else \
1726+
os.path.join(self.paths['flux diagrams'], f'reactor_{idx + 1}')
1727+
try:
1728+
conditions = self._flux_conditions_from_reactor(
1729+
self.rmg['reactors'][idx], base_map, ambiguous, full_names,
1730+
reactor_type, energy)
1731+
if conditions is None:
1732+
continue
1733+
generate_flux(model_path=model_path, folder_path=folder,
1734+
observables=observables,
1735+
draw_molecule_images=draw_images,
1736+
species_dictionary_path=species_dict_path,
1737+
logger=self.logger, fix_cantera_model=False,
1738+
**conditions)
1739+
except Exception as e:
1740+
self.logger.warning(f'Could not generate flux diagram for reactor {idx + 1} '
1741+
f'(target folder {folder}): {e.__class__.__name__}: {e}')
1742+
self.logger.debug(traceback.format_exc())
1743+
except Exception as e:
1744+
self.logger.warning(f'Flux-diagram generation failed: {e.__class__.__name__}: {e}')
1745+
self.logger.debug(traceback.format_exc())
1746+
15541747
def dump_sa_coefficients(self):
15551748
"""
15561749
Save the SA coefficients dictionary to a YAML file for user evaluation

‎t3/schema.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import os
77
from enum import Enum
8-
from typing import Annotated
8+
from typing import Annotated, Literal
99

1010
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator, model_validator
1111

@@ -32,6 +32,9 @@ class T3Options(BaseModel):
3232
A class for validating input.T3.options arguments
3333
"""
3434
flux_adapter: Annotated[str, Field(max_length=255)] = 'RMG'
35+
generate_flux_diagrams: bool = True
36+
flux_diagrams_with_images: bool = True
37+
flux_diagram_reactors: int | list[int] | Literal['all'] | None = None
3538
profiles_adapter: Annotated[str, Field(max_length=255)] = 'RMG'
3639
collision_violators_thermo: bool = False
3740
collision_violators_rates: bool = False
@@ -66,6 +69,19 @@ def enforce_collision_thermo(self) -> T3Options:
6669
self.collision_violators_thermo = True
6770
return self
6871

72+
@field_validator('flux_diagram_reactors', mode='before')
73+
@classmethod
74+
def check_flux_diagram_reactors(cls, value):
75+
"""flux_diagram_reactors: 1-based reactor number(s) or 'all'."""
76+
if value is None or value == 'all':
77+
return value
78+
nums = value if isinstance(value, list) else [value]
79+
for n in nums:
80+
if isinstance(n, bool) or not isinstance(n, int) or n < 1:
81+
raise ValueError(f'flux_diagram_reactors must be positive 1-based int(s) or '
82+
f'"all"; got {value!r}')
83+
return value
84+
6985
@field_validator('library_name')
7086
@classmethod
7187
def check_library_name(cls, value):

0 commit comments

Comments
 (0)