|
19 | 19 | import os |
20 | 20 | import re |
21 | 21 | import shutil |
| 22 | +import traceback |
22 | 23 | from collections import deque |
23 | 24 |
|
| 25 | +import cantera as ct |
| 26 | + |
24 | 27 | from arc.common import (get_number_with_ordinal_indicator, |
25 | 28 | get_ordinal_indicator, |
26 | 29 | key_by_val, |
|
37 | 40 | from t3.common import (DATA_BASE_PATH, |
38 | 41 | PROJECTS_BASE_PATH, |
39 | 42 | VALID_CHARS, |
| 43 | + convert_termination_time_to_seconds, |
40 | 44 | delete_root_rmg_log, |
41 | 45 | get_species_by_label, |
42 | 46 | sa_dict_from_yaml, |
|
49 | 53 | from t3.runners.rmg_runner import rmg_runner, run_arkane_job |
50 | 54 | from t3.schema import InputBase |
51 | 55 | from t3.simulate.factory import simulate_factory |
| 56 | +from t3.utils.fix_cantera import fix_cantera |
| 57 | +from t3.utils.flux import generate_flux |
52 | 58 | from t3.utils.libraries import append_to_rmg_libraries |
53 | 59 | from t3.utils.writer import write_pdep_network_file, write_rmg_input_file |
54 | 60 | from t3.utils.cantera_parser import load_cantera_yaml_file |
@@ -293,6 +299,8 @@ def execute(self): |
293 | 299 | content=comparison, |
294 | 300 | ) |
295 | 301 |
|
| 302 | + self._generate_flux_diagrams() |
| 303 | + |
296 | 304 | additional_calcs_required = self.determine_species_and_reactions_to_calculate() |
297 | 305 |
|
298 | 306 | # ARC |
@@ -321,6 +329,7 @@ def execute(self): |
321 | 329 | f'------------------------------------------------------\n') |
322 | 330 | self.set_paths() |
323 | 331 | self.run_rmg(restart_rmg=False) |
| 332 | + self._generate_flux_diagrams() |
324 | 333 |
|
325 | 334 | self.logger.log_species_summary(species_dict=self.species) |
326 | 335 | self.logger.log_reactions_summary(reactions_dict=self.reactions) |
@@ -355,6 +364,7 @@ def set_paths(self, |
355 | 364 | 'chem annotated': os.path.join(iteration_path, 'RMG', 'chemkin', 'chem_annotated.inp'), |
356 | 365 | 'species dict': os.path.join(iteration_path, 'RMG', 'chemkin', 'species_dictionary.txt'), |
357 | 366 | 'figs': os.path.join(iteration_path, 'Figures'), |
| 367 | + 'flux diagrams': os.path.join(iteration_path, 'flux'), |
358 | 368 | 'SA': os.path.join(iteration_path, 'SA'), |
359 | 369 | 'SA coefficients': os.path.join(iteration_path, 'SA', 'sa_coefficients.yml'), |
360 | 370 | 'SA dict': os.path.join(iteration_path, 'SA', 'sa.yaml'), |
@@ -1551,6 +1561,189 @@ def add_reaction(self, |
1551 | 1561 | self.reactions[rxn_key].reasons.append(reason) |
1552 | 1562 | return None |
1553 | 1563 |
|
| 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 | + |
1554 | 1747 | def dump_sa_coefficients(self): |
1555 | 1748 | """ |
1556 | 1749 | Save the SA coefficients dictionary to a YAML file for user evaluation |
|
0 commit comments