diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dd9cba97..b345276d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,60 +1,79 @@
# Contributing to JAFF
-Welcome! We're excited that you're interested in contributing to JAFF (Just Another Fancy Format), an astrochemical network parser.
+Thanks for your interest in contributing! This is a quick-start summary — the
+full guides live in [`docs/development/`](docs/development/) and online in the
+[Documentation](https://jaff-chemistry.github.io/jaff/).
-PLEASE NOTE:
-If you choose to make contributions to the code, you hereby grant a non-exclusive, royalty-free perpetual license
-to install, use, modify, prepare derivative works, incorporate into other computer software,
-distribute, and sublicense such enhancements or derivative works thereof, in binary and source code form.
+## Ways to Contribute
-## Getting Started
+- **Report bugs** — open an issue with steps to reproduce.
+- **Suggest features** — open an issue to discuss before building anything large.
+- **Fix issues / add features** — pick an issue, then send a PR.
+- **Improve docs** — corrections and clarifications are always welcome.
-1. Fork the repository
-2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/jaff.git`
-3. Create a new branch: `git checkout -b your-feature-name`
-4. Install the package in development mode: `uv pip install -e ".[dev]"`
+For major changes, open an issue first so the direction can be agreed on before
+you invest time.
-## Development Process
+## Development Setup
-### Code Style
+Fork the repo, clone your fork, and install in editable mode with dev tooling:
-- Match the style and formatting of surrounding code
-- Keep solutions simple, clean, and maintainable
-- Each code file should start with a 2-line comment beginning with "ABOUTME: "
-- Preserve existing comments unless they are demonstrably false
+```bash
+git clone https://github.com/YOUR_USERNAME/jaff.git
+cd jaff
+pip install -e ".[dev]"
+```
-### Making Changes
+Requires **Python 3.11+**. Full instructions (venv, uv, conda, IDE setup) are in
+the [Installation Guide](docs/development/installation.md).
-1. Make the smallest reasonable changes to achieve your goal
-2. Write tests BEFORE implementing new features (Test-Driven Development)
-3. Ensure all tests pass before submitting
-4. Commit frequently with clear, descriptive messages
+## Workflow
-### Testing
+1. **Branch** off an up-to-date `main` — never commit to `main` directly. Prefix
+ the branch with its purpose: `feature/`, `bug-fix/`, `docs/`, `refactor/`,
+ `test/`, or `chore/`.
-All contributions should include:
+ ```bash
+ git checkout main && git pull upstream main
+ git checkout -b feature/short-description
+ ```
-- Unit tests for individual components
-- An example Python notebook in `examples/` demonstrating your feature
+2. **Commit** with clear messages explaining _what_ and _why_. Use Conventional
+ Commit types (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`):
-Run tests with: `uv run pytest` (once test framework is set up)
+ ```bash
+ git commit -m "feat: add support for GPU code generation"
+ ```
-### Submitting Changes
+3. **Check** that tests pass and code is formatted before opening a PR:
-1. Push your branch to your fork
-2. Create a Pull Request with:
- - Clear description of the changes
- - Reference to any related issues
- - Test results showing all tests pass
-3. Address any review feedback promptly
+ ```bash
+ pytest # run the test suite
+ ruff check . # lint
+ ruff format . # format
+ ```
-## Questions?
+4. **Open a PR** against `jaff-chemistry/jaff:main`. Summarize the change,
+ reference related issues (`Fixes #123`), and note anything reviewers should
+ look at. All CI checks (tests on Linux/macOS/Windows × Python 3.11–3.13, docs
+ build, notebook execution) must pass before merge.
-Open an issue for:
+## Standards
-- Bug reports
-- Feature requests
-- Questions about the codebase
-- Discussion of potential contributions
+- **Code style** — Ruff (90-char lines, double quotes), built-in generics and
+ `X | None` unions, NumPy-style docstrings. See the
+ [Code Style Guide](docs/development/code-style.md).
+- **Tests** — pytest, one behaviour per test, descriptive names. New code should
+ be fully covered. See the [Testing Guide](docs/development/testing.md).
+- **Codebase layout** — see the
+ [Codebase Structure](docs/development/codebase-structure.md) guide.
-Thank you for contributing to JAFF!
+## Getting Help
+
+- **GitHub Issues** — bug reports and feature requests.
+- **GitHub Discussions** — questions about the codebase or usage.
+
+## License
+
+By contributing, you agree that your contributions are licensed under the
+project's [MIT License](LICENSE).
diff --git a/README.md b/README.md
index 48acd0bb..014e04a2 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,46 @@
-
JAFF
-
- (Just Another Fancy Format)
-
+
+
+
JAFF
+
+
Just Another Fancy Format
+
+
A fast, multi-format astrochemical network parser with analysis, code generation, and explicit photochemistry.
+
+
+
+
+
+
+
+
+
+
+ Installation ·
+ Quick Start ·
+ Formats ·
+ Documentation ·
+ Contributing
+
-An astrochemical network parser that supports multiple reaction network formats including KIDA, UDFA, PRIZMO, KROME, and UCLCHEM.
+---
+
+## Overview
+
+**JAFF** is an astrochemical network parser that reads multiple network formats, validates and analyses them, generates simulation code, and handles explicit photochemistry — all from a single tool.
+
+- **Multi-format** — parse KIDA, UDFA, PRIZMO, KROME, and UCLCHEM networks
+- **Automatic validation** — catch malformed reactions and inconsistent species
+- **Analysis** — inspect species, reactions, masses, charges, and rates
+- **Code generation** — emit ODE solvers in C, C++, Python, Fortran, Rust, Julia, and R
+- **Explicit photochemistry** — first-class treatment of photoreactions
-For detailed instructons, please refer to the [Documentation](https://jaff-chemistry.github.io/jaff/)
+> Full guides and API reference live in the [Documentation](https://jaff-chemistry.github.io/jaff/).
## Installation
-### From source
+Clone the repository and install with `pip`:
```bash
git clone https://github.com/jaff-chemistry/jaff.git
@@ -19,111 +48,82 @@ cd jaff
pip install .
```
-### For development
-
-```bash
-git clone https://github.com/jaff-chemistry/jaff.git
-cd jaff
-pip install -e . # Editable install
-```
+Requires **Python 3.11+**.
## Quick Start
-### Command Line Usage
+### Command Line
-After installation, you can use the `jaff` command:
+Installation provides two commands: `jaffx` for quick network inspection and
+export, and `jaffgen` for code generation.
```bash
-# Load and validate a network file
-jaff networks/gas_reactions_kida.uva.2024.in
+# Count species and reactions in a network
+jaffx get num-species --network networks/COthin/react_COthin.jet
+jaffx get num-reactions --network networks/COthin/react_COthin.jet
-# List all species and reactions
-jaff networks/test.dat --list-species --list-reactions
+# Export rate coefficients over a temperature range to a text file
+jaffx export txt --network networks/COthin/react_COthin.jet --tmin 10 --tmax 1e4
```
-### Python API Usage
+### Network Parsing
+
+The format is auto-detected — the same `Network` call reads KIDA, KROME,
+PRIZMO, UDFA, and UCLCHEM files, plus JAFF's own `.jaff` format:
```python
from jaff import Network
-# Load a chemical network
-network = Network("networks/react_COthin")
+network = Network("networks/COthin/react_COthin.jet")
+network = Network("networks/kida_uva_2024/gas_reactions_kida.uva.2024.jet")
+```
+
+### Analysis
+```python
# Access species
for species in network.species:
print(f"{species.name}: mass={species.mass}, charge={species.charge}")
# Access reactions
for reaction in network.reactions:
- print(f"{reaction.get_sympy()}")
+ print(f"{reaction.reactants}")
```
-## Features
+### Code Generation
-- **Multi-format support**: Automatically detects and parses KIDA, UDFA, PRIZMO, KROME, and UCLCHEM formats
-- **Validation**: Checks for mass and charge conservation in reactions
-- **Species analysis**: Automatic extraction of elemental composition and properties
-- **Rate calculations**: Temperature-dependent rate coefficient evaluation
-- **ODE generation**: Creates differential equations for chemical kinetics
-
-## Supported Network Formats
-
-- **KIDA**: Kinetic Database for Astrochemistry format
- Reference: [A&A, 689, A63 (2024)](https://doi.org/10.1051/0004-6361/202450606)
-- **UDFA**: UMIST Database for Astrochemistry format
- Reference: [A&A, 682, A109 (2024)](https://doi.org/10.1051/0004-6361/202346908)
-- **PRIZMO**: Uses `->` separator with `VARIABLES{}` blocks
- Reference:[MNRAS 494, 4471–4491 (2020)](https://doi.org/10.1093/mnras/staa971)
-- **KROME**: Comma-separated values with `@format:` header
- Reference: [MNRAS 439, 2386–2419 (2014)](https://doi.org/10.1093/mnras/stu114)
-- **UCLCHEM**: Comma-separated with `,NAN,` marker (UNDER CONSTRUCTION)
- Reference: [J. Holdship et al 2017 AJ 154 38](https://doi.org/10.3847/1538-3881/aa773f)
+```bash
+jaffgen --template microphysics --network networks/GOW/GOW.jet
+```
-## Primitive Variables
+**Supported languages:** C · C++ · Python · Fortran · Rust · Julia · R
-The following variables are recognized in rate expressions:
+## Supported Network Formats
-- `tgas`: gas temperature, K
-- `av`: visual extinction, Draine units
-- `crate`: cosmic rays ionization rate of H2, 1/s
-- `ntot`: total number density, 1/cm3
-- `hnuclei`: H nuclei number density, 1/cm3
-- `d2g`: dust-to-gas mass ratio
+| Format | Reference |
+| ----------- | ---------------------------------------------------------------------------- |
+| **KIDA** | [A&A, 689, A63 (2024)](https://doi.org/10.1051/0004-6361/202450606) |
+| **UDFA** | [A&A, 682, A109 (2024)](https://doi.org/10.1051/0004-6361/202346908) |
+| **PRIZMO** | [MNRAS 494, 4471–4491 (2020)](https://doi.org/10.1093/mnras/staa971) |
+| **KROME** | [MNRAS 439, 2386–2419 (2014)](https://doi.org/10.1093/mnras/stu114) |
+| **UCLCHEM** | [J. Holdship et al 2017 AJ 154 38](https://doi.org/10.3847/1538-3881/aa773f) |
## Examples
-Example network files can be found in the `networks/` directory.
-
-## Development
-
-To contribute or modify JAFF:
-
-```bash
-# Install in development mode with dev dependencies
-pip install -e ".[dev]"
-
-# Run tests
-pytest
+Example network files can be found in the [`networks/`](networks/) directory.
-# Format code
-ruff format
+## Contributing
-# Lint code
-ruff check src/jaff
+Contributions are welcome! To contribute or modify JAFF, please refer to our
+[Contributing Guide](CONTRIBUTING.md).
-# Organize imports
-ruff check --select I --fix
-```
+## License
-## JAFF Schema Validation
-
-JAFF network exports are JSON payloads serialized to `.jaff` (optionally gzip-compressed as `.jaff.gz`).
-To validate a decompressed payload against the schema:
-
-```bash
-check-jsonschema --schemafile jaff.network.schema.json test.jaff
-```
+Distributed under the **MIT License**. See [`LICENSE`](LICENSE) for details.
---
-
+
diff --git a/assets/logo.png b/assets/logo.png
index 69b7b2c8..a61eb87d 100644
Binary files a/assets/logo.png and b/assets/logo.png differ
diff --git a/assets/logo.svg b/assets/logo.svg
new file mode 100644
index 00000000..122ae583
--- /dev/null
+++ b/assets/logo.svg
@@ -0,0 +1,39 @@
+
+ JAFF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/logo.webp b/assets/logo.webp
index 475ef55e..e9d7b8ca 100644
Binary files a/assets/logo.webp and b/assets/logo.webp differ
diff --git a/docs/api/codegen/codegen/get_commons.md b/docs/api/codegen/codegen/get_commons.md
index 4fde0a4d..3b591053 100644
--- a/docs/api/codegen/codegen/get_commons.md
+++ b/docs/api/codegen/codegen/get_commons.md
@@ -22,7 +22,7 @@ const int nreactions = 5;
**Parameters**
**idx_offset** : _int, optional_
-: Base index added to each species position. `-1` uses the language default stored in `self.ioff`. Default `-1`.
+: Base index added to each species position. `-1` uses the language default stored in `self.lang.idx_offset`. Default `-1`.
**idx_prefix** : _str, optional_
: Prefix for index names, e.g. `"idx_"`. Default `""`.
diff --git a/docs/api/codegen/codegen/get_language_tokens.md b/docs/api/codegen/codegen/get_language_tokens.md
deleted file mode 100644
index 8f411922..00000000
--- a/docs/api/codegen/codegen/get_language_tokens.md
+++ /dev/null
@@ -1,39 +0,0 @@
----
-tags:
- - Api
- - Code-generation
----
-
-# get_language_tokens
-
-`#!python Codegen.get_language_tokens()`
-
-Static method. Returns the full language token dictionary mapping canonical language names to their code-generation configuration. Result is cached.
-
-**Returns**
-
-_dict\[str, LangModifier\]_
-: Mapping from canonical language name to a `LangModifier` `TypedDict`. Supported keys: `"cxx"`, `"c"`, `"fortran"`, `"python"`, `"rust"`, `"julia"`, `"r"`. (User-facing aliases like `"c++"`, `"cpp"`, `"py"`, `"f90"`, `"rs"`, `"jl"` are normalised to these canonical names before lookup.)
-
-Each `LangModifier` is a `TypedDict` with the following fields:
-
-| Key | Type | Description |
-| --- | --- | --- |
-| `brac` | `str` | Two-character left/right bracket pair for 1-D array indexing (e.g. `"[]"` for C/C++/Python/Rust/Julia/R, `"()"` for Fortran). |
-| `assignment_op` | `str` | Assignment operator (`"="` for most languages, `"<-"` for R). |
-| `line_end` | `str` | Statement terminator (`";"` for C/C++/Rust, `""` for Python/Fortran/Julia/R). |
-| `matrix_sep` | `str` | Separator between row and column indices for 2-D access (`"]["` for C/C++/Python/Rust/Julia, `", "` for Fortran/R). |
-| `code_gen` | `Callable[..., str]` | SymPy printer used to serialise expressions (`sympy.cxxcode`, `sympy.ccode`, `sympy.fcode`, `sympy.pycode`, `sympy.rust_code`, `sympy.julia_code`, `sympy.rcode`). |
-| `idx_offset` | `int` | Base index added to all array subscripts (`0` for C/C++/Python/Rust, `1` for Fortran/Julia/R). |
-| `comment` | `str` | Single-line comment prefix (`"//"` for C/C++/Rust, `"!"` for Fortran, `"#"` for Python/Julia/R). |
-| `types` | `dict[str, str]` | Mapping from generic type name (`"int"`, `"float"`, `"double"`, `"bool"`) to language-specific spelling, e.g. `{"double": "double "}` for C/C++, `{"double": "f64 "}` for Rust, `{"double": "Float64 "}` for Julia. Empty `{}` for Python/Fortran/R. |
-| `extras` | `dict[str, Any]` | Miscellaneous language-specific tokens. Common keys: `"type_qualifier"` (`"const "` for C/C++/Rust/Julia) and `"class_specifier"` (`"static "` for C/C++, `"save "` for Fortran, `""` for Rust/Julia). Empty `{}` for Python/R. |
-
-**Example**
-
-```python
-tokens = Codegen.get_language_tokens()
-tokens["cxx"]["brac"] # "[]"
-tokens["fortran"]["idx_offset"] # 1
-tokens["r"]["assignment_op"] # "<-"
-```
diff --git a/docs/api/codegen/codegen/index.md b/docs/api/codegen/codegen/index.md
index 741e0cf7..74b114c3 100644
--- a/docs/api/codegen/codegen/index.md
+++ b/docs/api/codegen/codegen/index.md
@@ -14,7 +14,7 @@ Supported languages: C++ (`cxx`, `cpp`, `c++`), C (`c`), Fortran 90 (`f90`, `for
## Constructor
-`#!python Codegen(network, lang="c++", brac_format="", matrix_format="")`
+`#!python Codegen(network, lang="c++")`
**Parameters**
@@ -22,32 +22,38 @@ Supported languages: C++ (`cxx`, `cpp`, `c++`), C (`c`), Fortran 90 (`f90`, `for
: The chemical reaction network.
**lang** : *str, optional*
-: Target language. Default `"c++"`.
-
-**brac_format** : *str, optional*
-: Override 1D bracket style: `"[]"`, `"()"`, `"{}"`, `"<>"`. Default `""` (language default).
-
-**matrix_format** : *str, optional*
-: Override 2D format: `"[]"`, `"[,]"`, `"()"`, `"(,)"`, `"{}"`, `"{,}"`, `"<>"`, `"<,>"`. Default `""`.
+: Target language alias. Default `"c++"`. Resolved to a `Language` via its alias table.
**Raises**
-*ValueError*
-: If `lang`, `brac_format`, or `matrix_format` is not recognized.
+*InvalidLanguageError*
+: If `lang` is not a recognized language.
## Attributes
| Attribute | Type | Description |
|-----------|------|-------------|
| `net` | `Network` | The reaction network being code-generated |
-| `lang` | `str` | Canonical language identifier (e.g. `"cxx"`, `"fortran"`, `"python"`) |
-| `lb`, `rb` | `str` | Left and right bracket characters for 1-D array indexing (e.g. `"["` and `"]"`) |
-| `mlb`, `mrb` | `str` | Left and right bracket characters for 2-D array indexing |
-| `matrix_sep` | `str` | Index separator for 2-D arrays (e.g. `"]["` for C-style, `", "` for Fortran) |
-| `assignment_op` | `str` | Assignment operator for the target language (`"="` for most, `"<-"` for R) |
-| `line_end` | `str` | Statement terminator for the target language (`";"` for C/C++/Rust, `""` for others) |
-| `code_gen` | `Callable` | SymPy printer function used to serialise symbolic expressions |
-| `ioff` | `int` | Default array index offset (`0` for C/C++/Python/Rust, `1` for Fortran/Julia/R) |
-| `comment` | `str` | Single-line comment prefix for the target language (`"//"`, `"!!"`, or `"#"`) |
-| `types` | `dict` | Mapping from generic type names to language-specific spellings (e.g. `{"double": "double "}` for C++) |
-| `extras` | `dict` | Additional language-specific tokens such as type qualifiers and class specifiers |
+| `lang` | `Language` | The resolved language config carrying every syntax token (see below) |
+
+### Language tokens (`cg.lang`)
+
+All per-language syntax lives on the `Language` object at `cg.lang`. Per-method
+bracket/token overrides (`brac_format`, `matrix_format`, `assignment_op`,
+`line_end` on the `get_*_str` methods) are applied by the `scoped_tokens`
+decorator, which transiently swaps `cg.lang` for a `Language.derive`-d view for
+the duration of that call and restores it afterwards.
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `lang.name` | `str` | Canonical language identifier (e.g. `"cxx"`, `"fortran"`, `"python"`) |
+| `lang.lb`, `lang.rb` | `str` | Left and right bracket characters for 1-D array indexing (e.g. `"["` and `"]"`) |
+| `lang.mlb`, `lang.mrb` | `str` | Left and right bracket characters for 2-D array indexing |
+| `lang.sep` | `str` | Index separator for 2-D arrays (e.g. `"]["` for C-style, `", "` for Fortran) |
+| `lang.assignment_op` | `str` | Assignment operator for the target language (`"="` for most, `"<-"` for R) |
+| `lang.line_end` | `str` | Statement terminator for the target language (`";"` for C/C++/Rust, `""` for others) |
+| `lang.code_gen` | `Callable` | SymPy printer function used to serialise symbolic expressions |
+| `lang.idx_offset` | `int` | Default array index offset (`0` for C/C++/Python/Rust, `1` for Fortran/Julia/R) |
+| `lang.comment` | `str` | Single-line comment prefix for the target language (`"//"`, `"!"`, or `"#"`) |
+| `lang.types` | `dict` | Mapping from generic type names to language-specific spellings (e.g. `{"double": "double "}` for C++) |
+| `lang.extras` | `dict` | Additional language-specific tokens such as type qualifiers and class specifiers |
diff --git a/docs/api/core/network/index.md b/docs/api/core/network/index.md
index 11349ddb..b9a88581 100644
--- a/docs/api/core/network/index.md
+++ b/docs/api/core/network/index.md
@@ -54,7 +54,7 @@ _FileNotFoundError_
| ----------------- | ------------------- | -------------------------------------------------------------------------------------------- |
| `label` | `str` | Human-readable network identifier; defaults to the source file stem |
| `file_name` | `Path` | Resolved absolute path to the source network file |
-| `species` | `Species` | Ordered catalogue of all species in the network |
+| `species` | `Species` | Ordered catalogue of the network's core (real) species; special pseudo-species (`_PHOTON`, `_CR`, ...) are excluded |
| `reactions` | `Reactions` | Ordered catalogue of all reactions in the network |
| `elements` | `Elements` | Element catalogue derived from all species; used for composition matrices |
| `reactant_matrix` | `ndarray` | Shape (n_reactions, n_species) stoichiometry matrix for reactants |
diff --git a/docs/api/core/reaction/band_xsecs.md b/docs/api/core/reaction/band_xsecs.md
new file mode 100644
index 00000000..ec56d9d2
--- /dev/null
+++ b/docs/api/core/reaction/band_xsecs.md
@@ -0,0 +1,41 @@
+---
+tags:
+ - Api
+ - Reaction
+---
+
+# Reaction.band_xsecs
+
+`#!python band_xsecs` _(property)_
+
+Band-averaged cross sections for this reaction, one row per radiation band, as a
+tidy `pandas.DataFrame`. Assembled from the `rad_groups` back-references, which
+are populated when a radiation field is configured on the network (via the
+`rad_bands` argument to `Network`). Intended as the data source for band bar
+plots (see the `show_bands` option of [`plot_xsecs`](plot_xsecs.md)).
+
+**Returns**
+
+_pandas.DataFrame_
+: One row per band this reaction contributes to, with columns:
+
+| Column | Unit | Description |
+| ----------- | ---- | ----------------------------------------------------------------------------------------------- |
+| `lower` | eV | Lower band edge |
+| `upper` | eV | Upper band edge (`inf` for an open top band) |
+| `eavg` | eV | Photon-number-weighted band-average energy |
+| `xsec` | cm² | Photon-number-weighted band-average cross section (`NaN` for custom-rate reactions) |
+| `xsec_frac` | — | Fraction of the total cross section (or `dRad`) attributed to the band |
+
+: The frame is empty (with the columns above) when the reaction contributes to
+no band, e.g. no radiation field is configured. Rows are ordered by ascending
+band index.
+
+**Example**
+
+```python
+net = Network("networks/h_photoionization/h_photo.jet", rad_bands=[1, 13.6, 100, "inf"])
+rxn = net.reactions.photo_reactions()[0]
+rxn.band_xsecs # DataFrame: lower / upper / eavg / xsec / xsec_frac
+rxn.plot_xsecs(show_bands=True) # overlay the band-averaged bars on σ(E)
+```
diff --git a/docs/api/core/reaction/get_code.md b/docs/api/core/reaction/get_code.md
index 2de91163..97405fc8 100644
--- a/docs/api/core/reaction/get_code.md
+++ b/docs/api/core/reaction/get_code.md
@@ -13,9 +13,14 @@ Returns the rate expression as a code string in the target language.
**Parameters**
**lang** : _str, optional_
-: Target language: `"python"`, `"c"`, `"cxx"`, `"fortran"`, `"rust"`, `"julia"`, `"r"`. Default `"cpp"`.
+: Target language name or alias — `"python"`/`"py"`, `"c"`, `"cxx"`/`"cpp"`/`"c++"`, `"fortran"`/`"f90"`, `"rust"`/`"rs"`, `"julia"`/`"jl"`, `"r"`. Default `"cpp"`.
**Returns**
_str_
: Rate expression code. Photo-reactions return `"photorates($IDX$, ...)"`.
+
+**Raises**
+
+_InvalidLanguageError_
+: If `lang` is not a recognized language.
diff --git a/docs/api/core/reaction/index.md b/docs/api/core/reaction/index.md
index d047eae5..d0d0eb13 100644
--- a/docs/api/core/reaction/index.md
+++ b/docs/api/core/reaction/index.md
@@ -12,7 +12,7 @@ The `Reaction` class represents a single chemical reaction, holding its reactant
## Constructor
-`#!python Reaction(reactants, products, rate, tmin, tmax, dE, dRad_dt, original_string, index, errors=False)`
+`#!python Reaction(reactants, products, rate, tmin, tmax, dE, dRad, original_string, index, type="unknown", errors=False)`
**Parameters**
@@ -43,6 +43,9 @@ The `Reaction` class represents a single chemical reaction, holding its reactant
**index** : _int_
: Position in the network reaction list.
+**type** : _str, optional_
+: Reaction type concluded by the network-format parser. One of `"photo"`, `"cosmic_ray"`, `"3_body"`, `"unknown"`. Default `"unknown"`.
+
**errors** : _bool, optional_
: If `True`, terminate the process on mass or charge conservation violations instead of merely logging a warning. Default `False`.
@@ -61,6 +64,7 @@ The `Reaction` class represents a single chemical reaction, holding its reactant
| `serialized` | `str` | Canonical name-level form `"__"` |
| `serialized_exploded` | `str` | Like `serialized` but built from atom-level serialized forms of each species (isomer-insensitive) |
| `index` | `int` | Zero-based position in the parent `Reactions` catalogue |
-| `metadata` | `dict` | Arbitrary key/value store. `metadata["type"]` is populated by `rtype()` |
+| `type` | `str` | Reaction type concluded by the parser: `"photo"`, `"cosmic_ray"`, `"3_body"`, or `"unknown"` |
| `custom_rad_rate` | `bool` | `True` when the radiation rate was supplied via a `.jfunc` aux function rather than computed from cross-sections |
| `xsecs_dict` | `XsecsProps or None` | Photo cross-section data for the reaction's single decay channel. Holds `units`, `_equations` (`pa` photo-absorption flag and `decay_type`, either `"ionization"` or `"dissociation"`), `photon_energy` (eV), and the `photo_absorption` / `photodecay` arrays (cm², or `None` where absent). `None` for non-photo reactions |
+| `rad_groups` | `list[RadiationGroup]` | Back-references to the radiation bands this reaction contributes to, populated when a radiation field is configured (empty otherwise). See the [`band_xsecs`](band_xsecs.md) property for the band-averaged cross sections |
diff --git a/docs/api/core/reaction/plot_rate_coefficient.md b/docs/api/core/reaction/plot_rate_coefficient.md
index ffdf0669..faef35a4 100644
--- a/docs/api/core/reaction/plot_rate_coefficient.md
+++ b/docs/api/core/reaction/plot_rate_coefficient.md
@@ -8,7 +8,7 @@ tags:
`#!python plot_rate_coefficient(fig=None, ax=None, title=None, grid=True, show=True, save=False, filename="")`
-Plots the rate coefficient as a function of gas temperature on a log-log scale, using the styled `jaff.plotting.Plotter` house style. The temperature axis spans \[`tmin`, `tmax`\]; when either is `None`, defaults of 2.73 K and 1e6 K are used respectively.
+Plots the rate coefficient as a function of gas temperature on a log-log scale. A thin wrapper around the [`jaff.plotting.plot_rates`](../../plotting/plot_rates.md) free function (call that directly to overlay several reactions on shared axes). The temperature axis spans \[`tmin`, `tmax`\]; when either is `None`, defaults of 2.73 K and 1e6 K are used respectively.
**Parameters**
@@ -32,5 +32,5 @@ Plots the rate coefficient as a function of gas temperature on a log-log scale,
**Returns**
-_tuple\[matplotlib.figure.Figure, matplotlib.axes.Axes\]_
-: The figure and axes drawn on.
+_tuple\[matplotlib.figure.Figure, matplotlib.axes.Axes\] or None_
+: The figure and axes drawn on, or `None` if the rate cannot be evaluated numerically (e.g. a photo reaction, whose rate carries the symbolic radiation-density variable).
diff --git a/docs/api/core/reaction/plot_xsecs.md b/docs/api/core/reaction/plot_xsecs.md
index ecb79f73..ef0a61cd 100644
--- a/docs/api/core/reaction/plot_xsecs.md
+++ b/docs/api/core/reaction/plot_xsecs.md
@@ -6,9 +6,9 @@ tags:
# plot_xsecs
-`#!python plot_xsecs(processes="all", layout="overlay", fig=None, ax=None, energy_unit="eV", xsec_unit="Mb", energy_log=True, xsecs_log=True, title=None, grid=True, show=True, save=False, filename="")`
+`#!python plot_xsecs(processes="all", layout="overlay", fig=None, ax=None, energy_unit="eV", xsec_unit="Mb", energy_log=True, xsecs_log=True, shade=False, show_bands=False, title=None, grid=True, show=True, save=False, filename="")`
-Plots photo cross sections against photon energy or wavelength. Drawing, unit conversion, and labelling are delegated to `jaff.plotting.Plotter.plot_xsec`. Does nothing (logs a message and returns `None`) if `xsecs_dict` is `None` or no requested process has data. Cross-section data are stored as photon energies in eV and cross sections in cm²; both are converted to the requested units before plotting.
+Plots photo cross sections against photon energy or wavelength. Drawing, unit conversion, and labelling are delegated to the [`jaff.plotting.plot_xsecs`](../../plotting/plot_xsecs.md) free function (call that directly to overlay several reactions on shared axes). Does nothing (logs a message and returns `None`) if `xsecs_dict` is `None` or no requested process has data. Cross-section data are stored as photon energies in eV and cross sections in cm²; both are converted to the requested units before plotting.
**Parameters**
@@ -33,6 +33,12 @@ Plots photo cross sections against photon energy or wavelength. Drawing, unit co
**xsecs_log** : _bool, optional_
: Log-scale the cross-section axis. Default `True`.
+**shade** : _bool or float, optional_
+: Shade the area under each curve. `True` uses a default alpha; a float sets the alpha explicitly. Default `False`.
+
+**show_bands** : _bool, optional_
+: Overlay the band-averaged cross section (from the [`band_xsecs`](band_xsecs.md) property) as bars. Only meaningful when a radiation field is configured; silently draws nothing otherwise. Default `False`.
+
**title** : _str or None, optional_
: Plot title. Defaults to the LaTeX reaction equation.
@@ -46,7 +52,7 @@ Plots photo cross sections against photon energy or wavelength. Drawing, unit co
: Save to `filename` (format inferred from the extension). Default `False`.
**filename** : _str, optional_
-: Output path. Defaults to `"_.png"`.
+: Output path. Defaults to `"_cross_sections.png"`.
**Returns**
diff --git a/docs/api/core/reaction/rtype.md b/docs/api/core/reaction/rtype.md
deleted file mode 100644
index 7ad8320d..00000000
--- a/docs/api/core/reaction/rtype.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-tags:
- - Api
- - Reaction
----
-
-# rtype
-
-`#!python rtype()`
-
-Classifies this reaction by inspecting its rate expression and stores the result in `self.metadata["type"]`.
-
-Classification rules (evaluated in order):
-
-| Type | Condition |
-|------|-----------|
-| `"photo"` | Rate is or contains a `photorates(...)` function call |
-| `"cosmic_ray"` | Rate contains the free symbol `crate` |
-| `"photo_av"` | Rate contains the free symbol `av` |
-| `"3_body"` | Rate contains the free symbol `ntot` |
-| `"unknown"` | None of the above match |
-
-**Returns**
-
-_str_
-: One of `"photo"`, `"cosmic_ray"`, `"photo_av"`, `"3_body"`, or `"unknown"`.
diff --git a/docs/api/core/reaction/serialize.md b/docs/api/core/reaction/serialize.md
index 4fb6b9fc..47e1ccd4 100644
--- a/docs/api/core/reaction/serialize.md
+++ b/docs/api/core/reaction/serialize.md
@@ -8,9 +8,9 @@ tags:
`#!python serialize()`
-Builds the name-level serialized form (isomer-sensitive). Species names are sorted alphabetically and joined with `"_"`. Reactants and products are separated by `"__"`.
+Builds the name-level serialized form (isomer-sensitive). Species names are sorted alphabetically and joined with `"."`. Reactants and products are separated by `"__"`. (The `"."` joiner — not `"_"` — is used because special pseudo-species names start with `_`.)
**Returns**
_str_
-: Name-level canonical key, e.g. `"H_H2Oj__H2O_Hj"` for `H + H2O+ -> H2O + H+`.
+: Name-level canonical key, e.g. `"H.H2O+__H+.H2O"` for `H + H2O+ -> H2O + H+`.
diff --git a/docs/api/core/reaction/serialize_exploded.md b/docs/api/core/reaction/serialize_exploded.md
index e321dc19..c3530f1c 100644
--- a/docs/api/core/reaction/serialize_exploded.md
+++ b/docs/api/core/reaction/serialize_exploded.md
@@ -8,9 +8,9 @@ tags:
`#!python serialize_exploded()`
-Builds the atom-level serialized form (isomer-insensitive). Each species is replaced by its `Specie.serialized` form (e.g. H2O+ → `"+/H/H/O"`), then species tokens are sorted and joined with `"_"`. Reactants and products are separated by `"__"`.
+Builds the atom-level serialized form (isomer-insensitive). Each species is replaced by its `Specie.serialized` form (e.g. H2O+ → `"+/H/H/O"`), then species tokens are sorted and joined with `"."`. Reactants and products are separated by `"__"`.
**Returns**
_str_
-: Atom-level canonical key, e.g. `"+/H/H/O__H/H/O_Hj"` for a reaction involving H2O+.
+: Atom-level canonical key, e.g. `"+/H/H/O.H__+/H.H/H/O"` for `H + H2O+ -> H2O + H+`.
diff --git a/docs/api/core/reactions/from_serialized.md b/docs/api/core/reactions/from_serialized.md
index 5bdbc8e4..98152183 100644
--- a/docs/api/core/reactions/from_serialized.md
+++ b/docs/api/core/reactions/from_serialized.md
@@ -13,7 +13,7 @@ Look up a reaction by its name-level serialized form.
**Parameters**
**serialized** : _str_
-: Canonical form `"__"`, e.g. `"H_H2Oj__H2_OHj"`.
+: Canonical form `"__"`, e.g. `"H.H2O+__H2.OH+"`.
**Returns**
diff --git a/docs/api/core/reactions/from_verbatim.md b/docs/api/core/reactions/from_verbatim.md
index eb7f9b3e..87f93cb4 100644
--- a/docs/api/core/reactions/from_verbatim.md
+++ b/docs/api/core/reactions/from_verbatim.md
@@ -6,7 +6,7 @@ tags:
# from_verbatim
-`#!python from_verbatim(verbatim, rtype=None)`
+`#!python from_verbatim(verbatim, type=None)`
Looks up a reaction by its verbatim string, optionally filtering by type.
@@ -15,7 +15,7 @@ Looks up a reaction by its verbatim string, optionally filtering by type.
**verbatim** : _str_
: Verbatim reaction string to look up.
-**rtype** : _str or None, optional_
+**type** : _str or None, optional_
: If given, only returns the reaction if its type matches.
**Returns**
diff --git a/docs/api/core/reactions/get.md b/docs/api/core/reactions/get.md
index e9e62824..281dffd2 100644
--- a/docs/api/core/reactions/get.md
+++ b/docs/api/core/reactions/get.md
@@ -6,16 +6,16 @@ tags:
# get
-`#!python get(reaction, rtype=None)`
+`#!python get(reaction, type=None)`
Look up a reaction by verbatim string or serialized form, with optional type filter.
**Parameters**
**reaction** : _str_
-: Verbatim string (e.g. `"H + H2O+ -> H2 + OH+"`) or serialized form (e.g. `"H_H2Oj__H2_OHj"`).
+: Verbatim string (e.g. `"H + H2O+ -> H2 + OH+"`) or serialized form (e.g. `"H.H2O+__H2.OH+"`).
-**rtype** : _str or None, optional_
+**type** : _str or None, optional_
: If given, only returns the reaction if its type matches.
**Returns**
diff --git a/docs/api/core/reactions/photo_reactions.md b/docs/api/core/reactions/photo_reactions.md
index fb1bde51..6fdf8df1 100644
--- a/docs/api/core/reactions/photo_reactions.md
+++ b/docs/api/core/reactions/photo_reactions.md
@@ -8,7 +8,7 @@ tags:
`#!python photo_reactions()`
-Returns every photo-reaction in the catalogue (i.e. reactions with `rtype == "photo"`), preserving their relative catalogue order.
+Returns every photo-reaction in the catalogue (i.e. reactions with `type == "photo"`), preserving their relative catalogue order.
**Returns**
diff --git a/docs/api/core/reactions/rtypes.md b/docs/api/core/reactions/types.md
similarity index 90%
rename from docs/api/core/reactions/rtypes.md
rename to docs/api/core/reactions/types.md
index 778ae12b..42e7aaf9 100644
--- a/docs/api/core/reactions/rtypes.md
+++ b/docs/api/core/reactions/types.md
@@ -4,9 +4,9 @@ tags:
- Reaction
---
-# rtypes
+# types
-`#!python rtypes()`
+`#!python types()`
Returns the reaction-type label (e.g. `"photo"`, `"cosmic_ray"`) for every reaction in the catalogue, in catalogue order.
diff --git a/docs/api/core/reactions/with_rtype.md b/docs/api/core/reactions/with_type.md
similarity index 57%
rename from docs/api/core/reactions/with_rtype.md
rename to docs/api/core/reactions/with_type.md
index 5074f6c3..14800839 100644
--- a/docs/api/core/reactions/with_rtype.md
+++ b/docs/api/core/reactions/with_type.md
@@ -4,16 +4,16 @@ tags:
- Reaction
---
-# with_rtype
+# with_type
-`#!python with_rtype(rtype)`
+`#!python with_type(type)`
-Filters the catalogue and returns every reaction whose type label matches *rtype*, preserving their relative catalogue order.
+Filters the catalogue and returns every reaction whose type label matches *type*, preserving their relative catalogue order.
**Parameters**
-**rtype** : _str_
-: Reaction-type label to match, e.g. `"photo"`, `"cosmic_ray"`. Must be one of the type strings stored in `Reaction.metadata["type"]`.
+**type** : _str_
+: Reaction-type label to match, e.g. `"photo"`, `"cosmic_ray"`. Must be one of the type strings stored on `Reaction.type`.
**Returns**
diff --git a/docs/api/core/species/normalized_names.md b/docs/api/core/species/normalized_names.md
index eb4c549d..9db7e389 100644
--- a/docs/api/core/species/normalized_names.md
+++ b/docs/api/core/species/normalized_names.md
@@ -6,9 +6,17 @@ tags:
# normalized_names
-`#!python normalized_names()`
+`#!python normalized_names(pos='p', neg='n')`
-Returns a normalized identifier string for every species in the collection, in catalogue order. Each name is lowercased and has `"+"` replaced by `"p"` and `"-"` replaced by `"n"`, producing strings that are valid variable names in C, Fortran, and Python. For example `"HCO+"` becomes `"hcop"` and `"e-"` becomes `"en"`.
+Returns a normalized identifier string for every species in the collection, in catalogue order. Each name is lowercased and has `"+"` replaced by `pos` and `"-"` replaced by `neg`, producing strings that are valid variable names in C, Fortran, and Python. With the defaults, `"HCO+"` becomes `"hcop"` and `"e-"` becomes `"en"`.
+
+**Parameters**
+
+_pos_ : `str`, optional
+: Replacement for `"+"`, by default `"p"`.
+
+_neg_ : `str`, optional
+: Replacement for `"-"`, by default `"n"`.
**Returns**
diff --git a/docs/api/index.md b/docs/api/index.md
index fa8ec941..1294bd33 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -37,11 +37,17 @@ This is a complete reference for all public APIs in JAFF.
[:octicons-arrow-right-24: jaff.physics](physics/index.md)
+- :phosphor-chart-line:{ .sm .middle } **Plotting**
+
+ Publication-quality plots of rates and cross sections: `plot_rates`, `plot_xsecs`, `Plotter`.
+
+ [:octicons-arrow-right-24: jaff.plotting](plotting/index.md)
+
## Module Overview
-JAFF's public API is organized into four subpackages: `core` (network data model), `codegen` (source code generation), `drivers` (file I/O), and `physics` (constants and photochemistry).
+JAFF's public API is organized into five subpackages: `core` (network data model), `codegen` (source code generation), `drivers` (file I/O), `physics` (constants and photochemistry), and `plotting` (rate and cross-section plots).
```mermaid
classDiagram
@@ -66,8 +72,14 @@ classDiagram
constants
Photochemistry
}
+ class plotting {
+ plot_rates
+ plot_xsecs
+ Plotter
+ }
jaff --> core
jaff --> codegen
jaff --> drivers
jaff --> physics
+ jaff --> plotting
```
diff --git a/docs/api/plotting/index.md b/docs/api/plotting/index.md
new file mode 100644
index 00000000..850e0675
--- /dev/null
+++ b/docs/api/plotting/index.md
@@ -0,0 +1,83 @@
+---
+tags:
+ - Api
+icon: phosphor/chart-line
+---
+
+# jaff.plotting
+
+Publication-quality plotting for reaction rates and photo cross sections, built
+on the [seaborn objects](https://seaborn.pydata.org/tutorial/objects_interface.html)
+interface. The preferred entry points are the free functions `plot_rates` and
+`plot_xsecs`, which accept a single item **or a list** and overlay them on
+shared axes.
+
+## Functions
+
+| Function | Description |
+| ----------------------------------- | --------------------------------------------------------------------------------------------- |
+| [`plot_rates`](plot_rates.md) | Plot one or more rate coefficients (reactions, SymPy expressions, or `(x, y)` arrays) |
+| [`plot_xsecs`](plot_xsecs.md) | Plot photo cross sections for one or more reactions, with optional shading and band bars |
+| `apply_global_theme` | Apply the house theme globally and persistently for the session |
+
+## Classes
+
+| Class | Description |
+| ------------------------- | --------------------------------------------------------------------- |
+| [`Plotter`](plotter.md) | Low-level renderer; `render_series` is the shared multi-curve backend |
+
+## Quick start
+
+```python
+from jaff import Network
+from jaff.plotting import plot_rates, plot_xsecs
+
+net = Network("networks/h_photoionization/h_photo.jet", rad_bands=[1, 13.6, 100, "inf"])
+
+# One reaction, or many on shared axes with a legend.
+plot_rates(net.reactions[0])
+plot_rates(list(net.reactions)) # overlay all rates
+net.reactions.plot_rates() # equivalent, via the catalogue
+
+# Cross sections, with shading and band-averaged bars.
+photo = net.reactions.photo_reactions()[0]
+plot_xsecs(photo, shade=True, show_bands=True)
+plot_xsecs(net.reactions.photo_reactions()) # overlay several reactions
+```
+
+The `Reaction.plot_rate_coefficient` / `Reaction.plot_xsecs` methods and the
+`Reactions.plot_rates` / `Reactions.plot_xsecs` catalogue methods are thin
+wrappers over these functions.
+
+## Theming
+
+The house theme is built from seaborn's own style machinery
+(`seaborn.axes_style` + `seaborn.plotting_context`). By default it is applied
+*scoped* — only while a figure is being drawn — so importing or using the
+plotter never mutates global matplotlib state. Opt into a sticky, session-wide
+theme with `apply_global_theme()`.
+
+The default style is `"darkgrid"` with the JAFF brand palette. Three palettes
+are exported:
+
+| Palette | Description |
+| --------------- | ------------------------------------------------------ |
+| `LOGO_PALETTE` | JAFF brand colours (default): purple, magenta, coral, amber |
+| `MUTED_PALETTE` | seaborn "muted" (10 colours; use for many curves) |
+| `DEEP_PALETTE` | seaborn "deep" (10 colours) |
+
+```python
+from jaff.plotting import Plotter, MUTED_PALETTE, apply_global_theme
+
+Plotter(palette=MUTED_PALETTE, style="whitegrid") # per-plotter override
+apply_global_theme(palette=MUTED_PALETTE) # sticky, whole session
+```
+
+## Design
+
+Both free functions build a tidy long `DataFrame` and delegate to
+[`Plotter.render_series`](plotter.md), the single home for multi-curve
+rendering (log scales, axis trimming, shaded fills, band bars, per-curve
+line-width variation, and legends). The plotting package imports only
+`numpy` / `pandas` / `sympy` / `seaborn` — never `jaff.core` — so it stays a
+leaf dependency and can also plot bare SymPy expressions and arrays.
diff --git a/docs/api/plotting/plot_rates.md b/docs/api/plotting/plot_rates.md
new file mode 100644
index 00000000..52ca3dde
--- /dev/null
+++ b/docs/api/plotting/plot_rates.md
@@ -0,0 +1,80 @@
+---
+tags:
+ - Api
+ - Plotting
+---
+
+# plot_rates
+
+`#!python plot_rates(rates, *, tmin=None, tmax=None, var="tgas", npoints=100, labels=None, palette=None, xlabel="Temperature (K)", ylabel=r"Rate coefficient $k$", xscale="log", yscale="log", shade=False, title="", fig=None, ax=None, grid=True, show=True, save=False, filename="rates.png")`
+
+Plots one or more rate coefficients on shared axes with a legend. Each curve is
+coerced to `(x, y, label)` and drawn with a distinct line width (thinner curves
+in front). Inputs are duck-typed and dispatched per item, so a single call may
+mix reactions, expressions, and arrays.
+
+**Parameters**
+
+**rates** : _reaction-like, sympy.Basic, (x, y) tuple, or list of these_
+: What to plot. Each item is one of:
+
+ - a **reaction-like** object (anything exposing `rate`, and usually `tmin` / `tmax` / `get_latex`): its rate is evaluated over its temperature range on a log grid;
+ - a **SymPy expression**: evaluated over `[tmin, tmax]` (both required);
+ - an **`(x, y)`** pair of arrays: used verbatim.
+
+**tmin, tmax** : _float or None, optional_
+: Temperature range (K). For reactions, falls back to each reaction's own bounds, then to `2.73` / `1e6`. Required for bare SymPy expressions.
+
+**var** : _str, optional_
+: Symbol name to substitute when evaluating reaction rates. Default `"tgas"`.
+
+**npoints** : _int, optional_
+: Number of log-spaced sample points. Default `100`.
+
+**labels** : _list\[str\] or None, optional_
+: Legend labels aligned to `rates`. Defaults to each reaction's LaTeX equation (or `str`).
+
+**palette** : _list\[str\] or None, optional_
+: Colour-cycle override (e.g. `MUTED_PALETTE` for many curves).
+
+**xlabel, ylabel** : _str, optional_
+: Axis labels. Default `"Temperature (K)"` and `Rate coefficient $k$`.
+
+**xscale, yscale** : _str, optional_
+: `"log"` (default) or `"linear"`.
+
+**shade** : _bool or float, optional_
+: Shade the area under each curve. Default `False`.
+
+**title** : _str, optional_
+: Plot title. Default `""`.
+
+**fig, ax** : _matplotlib.figure.Figure / matplotlib.axes.Axes or None, optional_
+: Existing figure/axes to draw on. Created if `None`.
+
+**grid, show, save, filename** : _optional_
+: Standard rendering controls. `filename` default `"rates.png"`.
+
+**Returns**
+
+_tuple\[matplotlib.figure.Figure, matplotlib.axes.Axes\] or None_
+: The figure and axes, or `None` when no input could be evaluated.
+
+!!! note
+ Photo-reaction rates carry a symbolic radiation-density variable and cannot
+ be evaluated as a function of temperature; such inputs are skipped with a
+ warning.
+
+**Examples**
+
+```python
+from jaff.plotting import plot_rates
+import sympy as sp
+
+plot_rates(net.reactions[0]) # single reaction
+plot_rates(list(net.reactions)) # overlay all, legend by equation
+plot_rates([r1, r2], tmax=1e4, shade=True) # shared bounds + shading
+
+t = sp.Symbol("tgas")
+plot_rates([1e-10 * (t / 300) ** 0.5], tmin=10, tmax=1e4, labels=["my rate"])
+```
diff --git a/docs/api/plotting/plot_xsecs.md b/docs/api/plotting/plot_xsecs.md
new file mode 100644
index 00000000..59733601
--- /dev/null
+++ b/docs/api/plotting/plot_xsecs.md
@@ -0,0 +1,76 @@
+---
+tags:
+ - Api
+ - Plotting
+---
+
+# plot_xsecs
+
+`#!python plot_xsecs(reactions, *, processes="all", layout="overlay", energy_unit="eV", xsec_unit="Mb", energy_log=True, xsecs_log=True, trim=True, shade=False, show_bands=False, palette=None, title=None, fig=None, ax=None, grid=True, show=True, save=False, filename="")`
+
+Plots photo cross sections for one or more reactions on shared axes. Handles
+unit conversion, log scaling, axis trimming, optional shading, and optional
+band-averaged bars. Reactions without cross-section data are skipped with a log
+message; returns `None` if nothing can be drawn.
+
+**Parameters**
+
+**reactions** : _reaction-like or list of reaction-like_
+: A single reaction (anything exposing `xsecs_dict` and `band_xsecs`) or a list of them. Their cross sections are overlaid.
+
+**processes** : _str or list\[str\] or None, optional_
+: Which processes to draw. `"all"` (default) or `None` plots every process with data; a single key or list selects a subset. Valid keys: `"photo_absorption"`, `"photodecay"`. An invalid key raises `KeyError`.
+
+**layout** : _str, optional_
+: `"overlay"` (default) draws all curves on one axes; `"subplots"` gives each curve its own stacked panel.
+
+**energy_unit** : _str, optional_
+: Horizontal-axis unit: `"eV"` (default), `"erg"`, `"nm"`, or `"um"`.
+
+**xsec_unit** : _str, optional_
+: Cross-section unit: `"Mb"` (default), `"cm^2"`, or `"barn"`.
+
+**energy_log, xsecs_log** : _bool, optional_
+: Log-scale the energy / cross-section axis. Default `True`.
+
+**trim** : _bool, optional_
+: Tighten the energy axis to the positive data span. Default `True`.
+
+**shade** : _bool or float, optional_
+: Shade the area under each curve. `True` uses a default alpha; a float sets the alpha explicitly. Default `False`.
+
+**show_bands** : _bool, optional_
+: Overlay the band-averaged cross section as bars for every reaction that has them (see [`Reaction.band_xsecs`](../core/reaction/band_xsecs.md)). Default `False`.
+
+**palette** : _list\[str\] or None, optional_
+: Colour-cycle override.
+
+**title** : _str or None, optional_
+: Plot title. Defaults to the LaTeX equation for a single reaction, else empty.
+
+**fig, ax** : _matplotlib.figure.Figure / matplotlib.axes.Axes or None, optional_
+: Existing figure/axes to draw on (overlay only). Created if `None`.
+
+**grid, show, save, filename** : _optional_
+: Standard rendering controls.
+
+**Returns**
+
+_tuple\[matplotlib.figure.Figure, matplotlib.axes.Axes\] or None_
+: The figure and axes (overlay) or array of axes (subplots); `None` when no reaction has cross-section data.
+
+!!! note
+ With a single reaction the legend uses the process names; with several it
+ prefixes each with the reaction so curves stay distinguishable.
+
+**Examples**
+
+```python
+from jaff.plotting import plot_xsecs
+
+photo = net.reactions.photo_reactions()[0]
+plot_xsecs(photo) # one reaction, all processes
+plot_xsecs(photo, shade=True, show_bands=True) # shading + band bars
+plot_xsecs(photo, energy_unit="nm", xsec_unit="cm^2")
+plot_xsecs(net.reactions.photo_reactions()) # overlay several reactions
+```
diff --git a/docs/api/plotting/plotter.md b/docs/api/plotting/plotter.md
new file mode 100644
index 00000000..15a1f4a0
--- /dev/null
+++ b/docs/api/plotting/plotter.md
@@ -0,0 +1,66 @@
+---
+tags:
+ - Api
+ - Plotting
+---
+
+# Plotter
+
+`jaff.plotting.Plotter`
+
+Low-level renderer behind [`plot_rates`](plot_rates.md) and
+[`plot_xsecs`](plot_xsecs.md). Most users should call those free functions;
+`Plotter` is the styling + rendering engine they delegate to.
+
+## Constructor
+
+`#!python Plotter(palette=None, global_theme=False, **rc_overrides)`
+
+**palette** : _list\[str\] or None, optional_
+: Colour cycle for curves. Defaults to `LOGO_PALETTE` (the JAFF brand palette). Pass `MUTED_PALETTE` / `DEEP_PALETTE` for the seaborn cycles.
+
+**global_theme** : _bool, optional_
+: If `True`, apply the house theme globally and persistently on construction (mutating `matplotlib.rcParams`). If `False` (default), the theme is scoped to each plot call, leaving global state untouched.
+
+**\*\*rc_overrides**
+: Individual theme entries to override, forwarded to `theme_rc` (e.g. `style="whitegrid"`, `context="talk"`, or any `rcParams` key).
+
+## Methods
+
+### render_series
+
+`#!python render_series(df, *, x_col, y_col, group_col, xlabel, ylabel, log_x, log_y, dynamic_x=False, trim=False, shade=False, bands_df=None, layout="overlay", title="", fig=None, ax=None, grid=True, show=True, save=False, filename="plot.png")`
+
+The single home for multi-curve rendering. Takes a tidy long `DataFrame` (one
+group per curve) and handles log scales, dynamic-log downgrade (`dynamic_x`),
+axis trimming (`trim`), shaded fills (`shade`), band bars (`bands_df`),
+per-curve line-width variation, the legend, and the house theme. Both free
+functions build a frame and call this.
+
+Returns `#!python (fig, ax)` for `layout="overlay"`, or `#!python (fig, axes)`
+(an array of axes) for `layout="subplots"`.
+
+### plot
+
+`#!python plot(x, y, fig=None, ax=None, xlabel="", ylabel="", xscale="linear", yscale="linear", title="", label="", grid=True, show=True, save=False, filename="plot.png", **line_kw)`
+
+Generic single-line plot. Retained for backward compatibility;
+[`plot_rates`](plot_rates.md) is preferred for one or many curves.
+
+### plot_xsec
+
+`#!python plot_xsec(xsecs, processes=None, layout="overlay", fig=None, ax=None, energy_unit="eV", xsec_unit="cm^2", energy_log=True, xsec_log=True, trim=True, shade=False, show_bands=False, bands=None, title="", grid=True, show=True, save=False, filename="xsec.png")`
+
+Plots a single `XsecsProps` mapping. Retained for backward compatibility;
+[`plot_xsecs`](plot_xsecs.md) is preferred (it accepts one or many reactions).
+
+## Example
+
+```python
+from jaff.plotting import Plotter, MUTED_PALETTE
+
+# Bespoke styling; then compose onto the returned axes.
+p = Plotter(palette=MUTED_PALETTE, style="whitegrid", context="talk")
+fig, ax = p.plot([1, 2, 3], [4, 5, 6], xscale="log", yscale="log", show=False)
+ax.set_title("custom")
+```
diff --git a/docs/assets/logo.png b/docs/assets/logo.png
index 69b7b2c8..a61eb87d 100644
Binary files a/docs/assets/logo.png and b/docs/assets/logo.png differ
diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg
new file mode 100644
index 00000000..122ae583
--- /dev/null
+++ b/docs/assets/logo.svg
@@ -0,0 +1,39 @@
+
+ JAFF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/logo.webp b/docs/assets/logo.webp
index 475ef55e..e9d7b8ca 100644
Binary files a/docs/assets/logo.webp and b/docs/assets/logo.webp differ
diff --git a/docs/development/adding-parsers.md b/docs/development/adding-parsers.md
index 2f9510b5..57749ee7 100644
--- a/docs/development/adding-parsers.md
+++ b/docs/development/adding-parsers.md
@@ -6,163 +6,251 @@ icon: phosphor/file-code
# Adding a New Network Parser
-JAFF's file parser (`NetworkParser` in `src/jaff/core/_network_engine.py`) auto-detects the format of an astrochemical network file and parses each reaction line into a common internal representation. Adding support for a new format requires only two things: a pair of regular expressions and a handler method.
+JAFF's file parser (`NetworkParser` in `src/jaff/core/parsers/network/_engine.py`) auto-detects the format of an astrochemical network file and parses each reaction line into a common internal representation. Each supported format is a self-contained **plugin**: a `NetworkFormat` subclass that lives in its own subpackage under `core/parsers/network/_formats/`. Adding a new format means adding one subpackage — the engine and the existing formats are never touched.
## How the Parser Works
-The parser is fully regex-driven. Every line in the network file is tested against an ordered dictionary of patterns. The **first pattern whose `global_re` matches wins**, and its associated handler is called to extract the reaction data.
+`NetworkParser` discovers every registered format through `all_formats()` and sorts them by their declared `priority` (lower is matched first — **not** file or import order). For each non-blank line, it walks the formats in priority order; the **first format whose `_global_re` matches wins**, and its `handle()` method extracts the reaction data and appends it to the shared `ParseContext`.
```mermaid
flowchart TD
- A[NetworkParser.__init__] --> B[__set_known_replacements\nPre-populate SymPy aliases for\ncommon shorthand symbols]
- B --> C[__global_patterns_dict\nCompile ordered regex pattern dict]
- C --> D[__parse_file\nRead all lines into memory]
+ A[NetworkParser.__init__] --> B[__set_known_replacments\nPre-populate SymPy aliases for\ncommon shorthand symbols]
+ B --> C[all_formats\nImport format subpackages,\nsort instances by priority]
+ C --> S[build_state\nMerge each format's default_state\ninto ParseContext.state]
+ S --> D[__parse_file\nRead all lines into memory]
D --> E{For each line}
E --> F{Line empty or whitespace?}
F -- yes --> E
- F -- no --> G[Iterate patterns dict\nin declaration order]
- G --> H{global_re.match line?}
- H -- no match,\ntry next pattern --> G
- G -- no pattern matched --> E
- H -- first match wins --> I[Set matched_handler\nfrom pattern entry]
- I --> J[Call handler]
- J --> K{local_re.match line?}
- K -- no match --> L[Call error handler\nraise ParserError with\nline number + file path]
+ F -- no --> G[Iterate formats\nin priority order]
+ G --> H{fmt._global_re.match line?}
+ H -- no match,\ntry next format --> G
+ G -- no format matched --> E
+ H -- first match wins --> J[fmt.handle match, ctx]
+ J --> K{fmt._local_re.match line?}
+ K -- no match --> L[fmt._handle_errors\nctx.raise_error with\nline number + file path]
K -- yes --> M[Extract named groups\nr · p · tmin · tmax · rate · string]
M --> N[Normalize species names\nReplace format-specific symbols\ne.g. HE→He user_crflux→crate]
- N --> O[Append parsedListProps dict\nto __parsed_list]
+ N --> O[Append parsedListProps dict\nto ctx.parsed_list]
O --> E
E -- all lines done --> P[__normalize_rates\nLowercase all rate strings]
P --> Q[resolve_symbolic_dependencies\nSubstitute @var / VARIABLES globals]
Q --> R[get_parsed returns\nparsed_list + globals dict]
```
-### The Two-Level Regex Design
+### The `NetworkFormat` contract
+
+Every format subclasses `NetworkFormat` (`_formats/_base.py`) and implements:
+
+| Member | Purpose |
+| ----------------------- | ---------------------------------------------------------------------------------------------------- |
+| `priority: int` | Match order. Lower is tried first. Use the gap-spaced scheme below so new formats slot in cleanly. |
+| `name: str` | Unique format identifier. |
+| `state_key: str` | Namespace into `ParseContext.state` for mutable per-format props. `""` (default) means no state. |
+| `default_state()` | Initial props for `state_key`, merged once at construction. Override only if the format keeps state. |
+| `_global_re(ctx)` | Fast, broad filter. Identifies lines that _could_ belong to this format. Matched first. |
+| `_local_re(ctx)` | Detailed extractor. Uses **named groups** to capture every field. Matched inside `handle`. |
+| `handle(match, ctx)` | Process a matched line, mutating `ctx` (append a reaction and/or update state). |
-Each pattern entry has **two** regexes:
+`_global_re` / `_local_re` take `ctx` so the regex can depend on live parse state (KROME rebuilds its `_local_re` from the column counts a `@format:` header wrote). When a pattern is static, compile it once with `@cache`.
-| Field | Purpose |
-| ----------- | -------------------------------------------------------------------------------------------------------------------------- |
-| `global_re` | Fast, broad filter. Identifies lines that _could_ belong to this format. Matched first. |
-| `local_re` | Detailed extractor. Uses **named groups** to capture every field of the reaction. Matched only after `global_re` succeeds. |
+### The Two-Level Regex Design
+
+| Field | Purpose |
+| ------------ | -------------------------------------------------------------------------------------------------------------------------- |
+| `_global_re` | Fast, broad filter. Identifies lines that _could_ belong to this format. Matched first. |
+| `_local_re` | Detailed extractor. Uses **named groups** to capture every field of the reaction. Matched only after `_global_re` succeeds.|
-This split keeps the hot path (`global_re`) cheap, while `local_re` does the heavy structural matching and populates the named groups the handler reads.
+This split keeps the hot path (`_global_re`) cheap, while `_local_re` does the heavy structural matching and populates the named groups the handler reads. The `handle` method receives the **global** match (useful for error diagnostics) and recomputes the local match itself.
### The Parsed Reaction Dict
-Every handler must append a `parsedListProps` dict with exactly these keys:
+Every handler appends a `parsedListProps` dict (defined in `core/parsers/network/_typing/`) to `ctx.parsed_list`, with exactly these keys:
| Key | Type | Description |
| ---------- | --------------- | --------------------------------------------------- |
-| `"r"` | `list[str]` | Reactant name strings |
+| `"r"` | `list[str]` | Reactant name strings (include any agent pseudo-species, see below) |
| `"p"` | `list[str]` | Product name strings |
| `"tmin"` | `float or None` | Lower temperature bound in Kelvin, or `None` |
| `"tmax"` | `float or None` | Upper temperature bound in Kelvin, or `None` |
| `"rate"` | `str` | Rate expression as a Python/SymPy-compatible string |
+| `"type"` | `str` | Reaction type concluded by the parser: `"photo"`, `"cosmic_ray"`, `"3_body"`, or `"unknown"` |
| `"string"` | `str` | Original network-file line (for error reporting) |
+### Concluding the reaction type
+
+The parser — not the rate expression — decides the reaction `"type"`. Conclude
+it **structurally** so it survives custom/auxiliary rates, and inject the driving
+**agent pseudo-species** into the reactant list when the format implies one but
+the line omits it:
+
+- A radiation-driven reaction carries `_PHOTON` as a reactant → `"photo"`.
+- A cosmic-ray reaction carries `_CR` / `_CRP` / `_CRPHOT` → `"cosmic_ray"`.
+- Three or more *real* (non-`_`) reactants → `"3_body"`.
+- Otherwise `"unknown"`.
+
+These special pseudo-species (leading `_`) give a reaction its identity and
+serialization but are excluded from the kinetics. See the existing formats'
+`_reaction_type` and agent-injection logic (e.g. `kida/reaction.py`,
+`krome/reaction.py`) for reference implementations.
+
After all lines are parsed, `__normalize_rates` lower-cases every `"rate"` string, and `resolve_symbolic_dependencies` substitutes any global variables (e.g. from `@var` or `VARIABLES` blocks) into the expressions.
---
## Step-by-Step: Adding a New Format
-### 1. Add an entry to `__global_patterns_dict`
-
-Open `src/jaff/core/_network_engine.py` and locate the `__global_patterns_dict` method (line 772). Add your format's entry to the `patterns` dict **in the correct position** — order is critical because the first matching pattern wins.
-
-```python
-"my_format": {
- "global_re": r"^(?!\s*[!#@]).*\|.*$", # (1)
- "local_re": (
- r"^\s*"
- r"(?P[^|]+)"
- r"\s*\|\s*"
- r"(?P[^|]+)"
- r"\s*\|\s*"
- r"(?P[^|]*)"
- r"\s*\|\s*"
- r"(?P[^|]*)"
- r"\s*\|\s*"
- r"(?P.*?)"
- r"\s*$"
- ), # (2)
- "handler": self.__handle_my_format, # (3)
-},
+### 1. Create the format subpackage
+
+Add a folder under `src/jaff/core/parsers/network/_formats/`, e.g. `my_format/`, with a `reaction.py` module. Multi-line-type formats (like KROME's `@format:` header, `@var:`, and reaction lines) get one module per line type — see `krome/` (`header.py`, `var.py`, `reaction.py`).
+
+```python title="_formats/my_format/reaction.py"
+import re
+from functools import cache
+
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class MyFormatReaction(NetworkFormat):
+ """My pipe-delimited reaction line."""
+
+ priority = 55
+ name = "my_format"
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern: # (1)
+ return re.compile(r"^(?!\s*[!#@]).*\|.*$")
+
+ @cache
+ def _local_re(self, ctx: ParseContext) -> re.Pattern: # (2)
+ return re.compile(
+ r"^\s*"
+ r"(?P[^|]+)\s*\|\s*"
+ r"(?P[^|]+)\s*\|\s*"
+ r"(?P[^|]*)\s*\|\s*"
+ r"(?P[^|]*)\s*\|\s*"
+ r"(?P.*?)\s*$"
+ )
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None: # (3)
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ self._handle_errors(match, ctx)
+
+ rr = [r.strip() for r in local.group("reactants").split("+") if r.strip()]
+ pp = [p.strip() for p in local.group("products").split("+") if p.strip()]
+
+ tmin_str = local.group("tmin").strip()
+ tmax_str = local.group("tmax").strip()
+ t_min = float(tmin_str) if tmin_str else None
+ t_max = float(tmax_str) if tmax_str else None
+
+ # Replace any format-specific symbols with JAFF canonical names
+ rate = local.group("rate").strip().replace("my_crflux", "crate")
+
+ # Conclude the reaction type structurally (inject the agent species
+ # first if the format implies one). Three or more real reactants => 3-body.
+ rtype = "3_body" if sum(not r.startswith("_") for r in rr) >= 3 else "unknown"
+
+ ctx.parsed_list.append(
+ {
+ "r": rr,
+ "p": pp,
+ "tmin": t_min,
+ "tmax": t_max,
+ "rate": rate,
+ "type": rtype,
+ "string": ctx.line.strip(),
+ }
+ )
+
+ def _handle_errors(self, match: re.Match, ctx: ParseContext) -> None:
+ ctx.raise_error("Invalid MY_FORMAT reaction detected")
```
-1. **`global_re`** — match any non-comment line that contains `|`. Keep it broad and fast.
-2. **`local_re`** — use named groups (`?P`) to capture every field. Named groups map directly to `#!python match.group("name")` calls in your handler.
-3. **`handler`** — the private method you will write in the next step.
+1. **`_global_re`** — match any non-comment line that contains `|`. Keep it broad and fast. `@cache` because it does not depend on parse state.
+2. **`_local_re`** — use named groups (`?P`) to capture every field. Named groups map directly to `#!python local.group("name")` calls in your handler.
+3. **`handle`** — receives the global match; recomputes the local match, extracts fields, and appends to `ctx.parsed_list`. Use `ctx.raise_error`, `ctx.globals`, `ctx.logger`, `ctx.line`, and `ctx.nline` instead of instance state — the engine owns no per-line state.
-
-!!! warning "Order matters"
- The patterns dict is iterated sequentially. Place your format **before** any pattern whose `global_re` would also match your format's lines, and **after** any format that should take priority. The existing order is:
+!!! warning "Choosing `priority`"
+ Formats are matched in ascending `priority`. Place your format **before** any format whose `_global_re` would also match your lines, and **after** any that should take precedence. The existing order (gap-spaced so you can insert between any two without renumbering):
- `krome_format` → `krome_var` → `prizmo_vars` → `prizmo` → `udfa` → `krome` → `uclchem` → `kida`
+ | priority | format |
+ | -------- | ------------- |
+ | 10 | `krome_format` (`@format:` header) |
+ | 20 | `krome_var` (`@var:`) |
+ | 30 | `prizmo_vars` (`VARIABLES { }`) |
+ | 40 | `prizmo` |
+ | 50 | `udfa` |
+ | 60 | `krome` (reaction) |
+ | 70 | `uclchem` |
+ | 80 | `kida` |
- Formats with more specific `global_re` patterns (e.g. `krome_format` matches only `@format:` lines) should come before broader ones.
+ Formats with more specific `_global_re` patterns (e.g. `krome_format` matches only `@format:` lines) should get a lower number than broader ones.
---
-### 2. Write the handler method
+### 2. Export and register the format
-The handler extracts fields from `local_re` and appends to `self.__parsed_list`. Follow the pattern used by existing handlers:
+Add the subpackage's `__init__.py` so the class is imported (which runs its `@register` decorator):
-```python
-def __handle_my_format(self) -> None:
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__handle_my_format_errors()
-
- reactants: str = match.group("reactants")
- products: str = match.group("products")
- tmin_str: str = match.group("tmin").strip()
- tmax_str: str = match.group("tmax").strip()
- rate: str = match.group("rate").strip()
-
- # Split and normalize species lists
- rr: list[str] = [r.strip() for r in reactants.split("+") if r.strip()]
- pp: list[str] = [p.strip() for p in products.split("+") if p.strip()]
-
- # Parse temperature bounds — return None when absent or out of range
- t_min: float | None = float(tmin_str) if tmin_str else None
- t_max: float | None = float(tmax_str) if tmax_str else None
-
- # Replace any format-specific symbols with JAFF canonical names
- rate = rate.replace("my_crflux", "crate").replace("my_av", "av")
-
- self.__parsed_list.append(
- {
- "r": rr,
- "p": pp,
- "tmin": t_min,
- "tmax": t_max,
- "rate": rate,
- "string": self.__line.strip(),
- }
- )
+```python title="_formats/my_format/__init__.py"
+from .reaction import MyFormatReaction
+
+__all__ = ["MyFormatReaction"]
+```
+
+Then add the subpackage to the import line inside `all_formats()` in `_formats/__init__.py` so registration is triggered:
+
+```python title="_formats/__init__.py"
+def all_formats() -> list[NetworkFormat]:
+ from . import kida, krome, my_format, prizmo, uclchem, udfa # noqa: F401
+
+ return sorted((cls() for cls in _REGISTRY), key=lambda fmt: fmt.priority)
```
+That is the only shared file you edit — registration is by `priority`, not import order, so the position in this line does not matter.
+
---
-### 3. Write the error handler
+### 3. (Optional) Share live state across line types
-When `local_re` fails to match a line that `global_re` accepted, `__raise_error` is called with a descriptive message. The error handler inspects whatever the `global_re` _did_ capture to give a precise error:
+If your format has a header line that configures later reaction lines (like KROME's `@format:`), give both classes the **same** `state_key` and let the header seed it via `default_state()`:
```python
-def __handle_my_format_errors(self) -> None:
- self.__raise_error("Invalid MY_FORMAT reaction detected")
+@register
+class MyHeader(NetworkFormat):
+ priority = 15
+ name = "my_header"
+ state_key = "my_format" # shared namespace
+
+ def default_state(self) -> dict:
+ return {"ncols": 0}
+
+ def handle(self, match, ctx):
+ self.state(ctx)["ncols"] = ... # header writes shared state
+
+
+@register
+class MyFormatReaction(NetworkFormat):
+ priority = 55
+ name = "my_format"
+ state_key = "my_format" # same key → same dict
+
+ def _local_re(self, ctx):
+ ncols = self.state(ctx)["ncols"] # reaction reads live state
+ ...
```
-For richer diagnostics, inspect the `global_re` match groups (stored as `self.__matched_group`) to pinpoint _why_ the line is malformed — see `__handle_krome_format_errors` (line 197) for a detailed example.
+`build_state()` merges every format's `default_state()` into `ParseContext.state[state_key]`, and `self.state(ctx)` returns that live dict. A regex that reads state (like the reaction's `_local_re` above) must **not** be `@cache`d — it has to recompile when the state changes.
---
## Known Symbol Replacements
-After all lines are parsed, `__normalize_rates` lowercases every rate string. The `__set_known_replacements` method (line 743) pre-populates `self.__globals` with SymPy aliases for common shorthand symbols found in KROME/PRIZMO files:
+After all lines are parsed, `__normalize_rates` lowercases every rate string. The `__set_known_replacments` method in `core/parsers/network/_engine.py` pre-populates `self.__globals` with SymPy aliases for common shorthand symbols found in KROME/PRIZMO files:
| Shorthand | Canonical expansion |
| ------------ | --------------------- |
@@ -175,17 +263,21 @@ After all lines are parsed, `__normalize_rates` lowercases every rate string. Th
| `user_tdust` | `tdust` |
| `user_av` | `av` |
-If your format introduces additional shorthand symbols, add them to `__set_known_replacements` following the same pattern. Compound aliases (those that reference simpler ones) must be listed **before** the simpler aliases they depend on so that `resolve_symbolic_dependencies` substitutes correctly.
+If your format introduces additional shorthand symbols, add them to `__set_known_replacments` following the same pattern. Compound aliases (those that reference simpler ones) must be listed **before** the simpler aliases they depend on so that `resolve_symbolic_dependencies` substitutes correctly.
---
## Checklist
-- [x] `global_re` placed at the correct position in `__global_patterns_dict`
-- [x] `local_re` uses named groups for all fields (`reactants`, `products`, `tmin`, `tmax`, `rate`)
-- [x] Handler appends a valid `parsedListProps` dict with all six keys
-- [x] Error handler calls `self.__raise_error` with a descriptive message
-- [x] Format-specific symbols replaced with JAFF canonical names in the handler or via `__set_known_replacements`
+- [x] New subpackage under `core/parsers/network/_formats/` with a `@register`-ed `NetworkFormat` subclass
+- [x] `priority` chosen so the format matches at the correct point relative to others
+- [x] `_global_re` is a fast filter; `_local_re` uses named groups for all fields (`reactants`, `products`, `tmin`, `tmax`, `rate`)
+- [x] Static regexes are `@cache`d; any state-dependent `_local_re` is left uncached
+- [x] `handle` appends a valid `parsedListProps` dict (all seven keys, including `"type"`) to `ctx.parsed_list`
+- [x] Reaction `"type"` concluded structurally; agent pseudo-species (`_PHOTON`/`_CR`) injected when the format implies one
+- [x] `_handle_errors` calls `ctx.raise_error` with a descriptive message
+- [x] Subpackage added to the `from . import …` line in `_formats/__init__.py`
+- [x] Format-specific symbols replaced with JAFF canonical names in the handler or via `__set_known_replacments`
- [x] Tests added in `tests/` with at least one valid reaction line and one malformed line
## See Also
diff --git a/docs/development/adding-shielding-functions.md b/docs/development/adding-shielding-functions.md
index 5f05d7af..276cd6b0 100644
--- a/docs/development/adding-shielding-functions.md
+++ b/docs/development/adding-shielding-functions.md
@@ -7,73 +7,91 @@ icon: phosphor/sun
# Adding a Custom Shielding Function
A photo-reaction can attenuate its rate by a dimensionless **shielding factor**
-`S`. JAFF resolves that factor by loading a small Python module and calling its
-`get_shielding` function, which returns a [SymPy](https://www.sympy.org)
-expression that is multiplied into the photo-rate. Adding a new shielding model
-means writing one such module and placing it in the right directory.
+`S`. JAFF resolves that factor from a **registry** of shielding models: each
+model is a [`ShieldingFunction`](#the-shieldingfunction-contract) subclass that
+registers itself with the `@_register` decorator and exposes a `get_shielding`
+method returning a [SymPy](https://www.sympy.org) expression, which is
+multiplied into the photo-rate. Adding a new model means writing one such class
+and dropping it under `physics/photo_reactions/shielding/`.
-There are two flavours:
+There are two flavours, distinguished by the class's `reaction` attribute:
-| Flavour | Scope | Location |
-| ---------- | ---------------------------- | -------------------------------------------------------- |
-| **Local** | A single reaction | `physics/photo_reactions/shielding//.py` |
-| **Global** | Any reaction that selects it | `physics/photo_reactions/shielding/.py` |
+| Flavour | Scope | `reaction` attr | Location |
+| ---------- | ---------------------------- | ----------------------- | ------------------------------------------ |
+| **Local** | A single reaction | the serialized reaction | `shielding//.py` |
+| **Global** | Any reaction that selects it | `None` | `shielding/global_/.py` |
-In both cases the **file stem is the keyword** a reaction selects via the TOML
-`shielding.type` option.
+In both cases the class's `name` attribute is the keyword a reaction selects via
+the TOML `shielding.type` option (matched case-insensitively).
## How Shielding Is Resolved
-When a reaction carries a `[reaction..shielding]` block, the network parser
-copies it onto `reaction.metadata["shielding"]` and `Photochemistry.shielding`
-(`src/jaff/physics/photo_reactions/_photochemistry.py`) loads the module named by
-`type` and calls `get_shielding`. The returned expression is cached on
-`reaction.metadata["shielding"]["value"]` and folded into the rate.
+When a reaction carries a `[reaction."".shielding]` block, the
+network parser copies it onto `reaction._metadata["shielding"]`, and
+`Photochemistry.shielding` (`src/jaff/physics/photo_reactions/_photochemistry.py`)
+asks the registry for the model named by `type`. Lookup is keyed by
+`(name, reaction.serialized)` and prefers a reaction-specific (local) model,
+falling back to a global one registered with `reaction = None`. The resolved
+instance's `get_shielding` method is called; the returned expression is cached
+on `reaction._metadata["shielding"]["value"]` and folded into the rate.
```mermaid
flowchart TD
- A[shielding block in TOML\ntype selects a handler] --> B[Network parser\ncopies block onto\nreaction shielding metadata]
+ A[shielding block in TOML\ntype selects a model] --> B[Network parser\ncopies block onto\nreaction shielding metadata]
B --> C[Photochemistry.shielding]
- C --> D{type is a\nglobal keyword?}
- D -- yes --> E[Load\nshielding/TYPE.py]
- D -- no --> F{type is a\nlocal keyword?}
- F -- yes --> G[Load\nshielding/REACTION/TYPE.py]
- F -- no --> H[raise ParserError\nInvalid shielding type]
- E --> I[call get_shielding\nreaction, network]
- G --> I
- I --> J[Returned sympy.Expr\ncached on the\nshielding value metadata]
- J --> K[Multiplied into\nthe photo-rate]
+ C --> D[_get_shielding_function\ntype, reaction.serialized]
+ D --> E{local model registered\nfor this reaction?}
+ E -- yes --> F[use name, reaction model]
+ E -- no --> G{global model\nregistered for type?}
+ G -- yes --> H[use name, None model]
+ G -- no --> I[raise ParserError\nInvalid shielding type]
+ F --> J[call instance.get_shielding\nreaction, network]
+ H --> J
+ J --> K[Returned sympy.Expr\ncached on the\nshielding value metadata]
+ K --> L[Multiplied into\nthe photo-rate]
```
+The registry is populated by importing every (non-underscore) module under
+`shielding/`, which runs the `@_register` decorators — so a new model is
+discovered automatically once its file is in place; no central list to edit.
+
!!! note "Case-insensitive matching"
- String values in a `shielding` block are lower-cased when copied onto the
- reaction metadata, and file stems are matched lower-cased. So
- `type = "HG2015"`, `type = "hg2015"` and a file named `hg2015.py` all refer
- to the same handler. Pick a lower-case file stem to avoid surprises.
+ The `shielding.type` string is lower-cased when copied onto the reaction
+ metadata, and the registry lower-cases the `name` it matches against. So
+ `type = "HG2015"`, `type = "hg2015"`, and a class with `name = "hg2015"` all
+ refer to the same model. Pick a lower-case `name` to avoid surprises.
-## The `get_shielding` Contract
+## The `ShieldingFunction` Contract
-Every shielding module — local or global — must expose exactly this function:
+Every shielding model — local or global — subclasses `ShieldingFunction`
+(`shielding/_base.py`), sets two class attributes, and implements one method:
```python
from sympy import Expr
-from ..... import Network, Reaction # depth depends on the module's location
+from jaff.physics.photo_reactions.shielding import _register
+from jaff.physics.photo_reactions.shielding._base import ShieldingFunction
+
+@_register
+class MyModel(ShieldingFunction):
+ name = "my_model" # the shielding.type keyword (lower-case)
+ reaction = None # None = global; a serialized reaction = local
-def get_shielding(reaction: Reaction, network: Network) -> Expr:
- ...
- return shielding_expr
+ def get_shielding(self, reaction, network) -> Expr:
+ ...
+ return shielding_expr
```
-| Parameter | Description |
-| ---------- | ----------------------------------------------------------------------------------------- |
-| `reaction` | The reaction being shielded. Read model parameters from `reaction.metadata["shielding"]`. |
-| `network` | The owning network for property look-ups. Accept it even if unused. |
+| Member | Purpose |
+| --------------- | ----------------------------------------------------------------------------------------------- |
+| `name` | Shielding-type identifier, matched case-insensitively against `shielding.type`. |
+| `reaction` | Serialized reaction this model is bound to (local), or `None` for a global model. |
+| `get_shielding` | Returns the dimensionless `sympy.Expr`. Read model params off `reaction._metadata["shielding"]`. |
-**Return** a dimensionless `sympy.Expr`. It may reference free symbols that the
-code generator resolves at runtime, by convention:
+The returned `Expr` may reference free symbols the code generator resolves at
+runtime, by convention:
| Symbol | Meaning |
| ---------------- | ---------------------------------------------------- |
@@ -81,39 +99,49 @@ code generator resolves at runtime, by convention:
| `vdisp` | Velocity dispersion (cm s⁻¹) |
The `shielding` block from the TOML is available verbatim (lower-cased strings)
-on `reaction.metadata["shielding"]`, so any extra option you add — floors,
+on `reaction._metadata["shielding"]`, so any extra option you add — floors,
tolerances, a radiation-field selector — is read straight from there. **Validate
your inputs** and raise `jaff.errors.ParserError` with a reaction-tagged message
-on bad values; the existing handlers all do this.
+on bad values; the existing models all do this.
## Writing a Local Shielding Function
-A local function lives in a folder **named after the serialised reaction** and is
-only visible to that reaction. Serialisation joins reactants and products with
-`_`, separating the two sides with `__`. For example, `H2 -> H + H` serialises to
-`H2__H_H`, so its shielding folder is:
+A local model is bound to one reaction via its `reaction` attribute, set to that
+reaction's **serialized** form. Serialisation joins the species on each side
+with `.` and separates the two sides with `__`, and photo-reactions carry the
+`_PHOTON` agent. For example, `H2 + _PHOTON -> H + H` serialises to
+`H2._PHOTON__H.H`.
+
+Because a serialized key contains characters that are illegal in a Python
+package name (`.`, `+`, `-`), local models live in a folder whose name is the
+**sanitised** serialized key — `.` → `_`, `+` → `j`, `-` → `k`. So
+`H2._PHOTON__H.H` lives under `H2__PHOTON__H_H/`. The folder name is only a
+filesystem container; the binding comes from the `reaction` class attribute,
+which holds the real (unsanitised) serialized string.
```text
physics/photo_reactions/shielding/
-└── H2__H_H/
- ├── hg2015.py # type = "hg2015"
- ├── db1996.py # type = "db1996"
- └── _utils/ # shared helpers (leading "_" → not a handler)
+└── H2__PHOTON__H_H/ # sanitized folder for H2._PHOTON__H.H
+ ├── __init__.py
+ ├── hg2015.py # name = "hg2015"
+ ├── db1996.py # name = "db1996"
+ └── _utils/ # shared helpers (leading "_" → not imported as a model)
├── __init__.py
└── db_shielding_function.py
```
-A reaction selects one of them:
+A reaction selects one of them (note the **quoted** dotted key — TOML would
+otherwise read the dots as nested tables):
```toml
-[reaction.H2__H_H.shielding]
+[reaction."H2._PHOTON__H.H".shielding]
type = "hg2015"
min_vdisp = 1.0e-20
min_ncol = 1.0e-35
```
-The handler reads its options off the metadata, validates them, and returns the
-expression. Following `H2__H_H/hg2015.py`:
+The model reads its options off the metadata, validates them, and returns the
+expression. Following `H2__PHOTON__H_H/hg2015.py`:
```python
"""
@@ -125,70 +153,91 @@ from typing import Any
from sympy import Expr
-from ..... import Network, Reaction
-from .....errors import ParserError
+from jaff.errors import ParserError
+from jaff.physics.photo_reactions.shielding import _register
+from jaff.physics.photo_reactions.shielding._base import ShieldingFunction
+
from ._utils import shielding
-def get_shielding(reaction: Reaction, network: Network) -> Expr:
- """Return the Hartwig et al. (2015) H2 self-shielding factor."""
- sprops: dict[str, Any] = reaction.metadata["shielding"]
- if "min_ncol" in sprops and not isinstance(sprops["min_ncol"], (float, int)):
- raise ParserError(
- f"Minimum column density must be a float or int for: {reaction}"
- )
- if "min_vdisp" in sprops and not isinstance(sprops["min_vdisp"], (float, int)):
- raise ParserError(
- f"Minimum velocity dispersion must be a float or int for: {reaction}"
+@_register
+class HG2015(ShieldingFunction):
+ """Hartwig et al. (2015) H2 self-shielding (``shielding.type = "hg2015"``)."""
+
+ name = "hg2015"
+ reaction = "H2._PHOTON__H.H"
+
+ def get_shielding(self, reaction, network) -> Expr:
+ sprops: dict[str, Any] = reaction._metadata["shielding"]
+ if "min_ncol" in sprops and not isinstance(sprops["min_ncol"], (float, int)):
+ raise ParserError(
+ f"Minimum column density must be a float or int for: {reaction}"
+ )
+ if "min_vdisp" in sprops and not isinstance(sprops["min_vdisp"], (float, int)):
+ raise ParserError(
+ f"Minimum velocity dispersion must be a float or int for: {reaction}"
+ )
+
+ return shielding(
+ alpha=1.1,
+ min_ncol=sprops.get("min_ncol", 1e-50),
+ min_vdisp=sprops.get("min_vdisp", 1e-50),
)
-
- return shielding(
- alpha=1.1,
- min_ncol=sprops.get("min_ncol", 1e-50),
- min_vdisp=sprops.get("min_vdisp", 1e-50),
- )
```
-!!! tip "Share maths between handlers"
- When several handlers in a folder differ only by a parameter (here
- `db1996.py` and `hg2015.py` differ only in `alpha`), put the actual
- expression builder in an underscore-prefixed helper package (`_utils/`).
- Files and folders whose name starts with `_` are **not** treated as
- selectable handlers, so they make natural homes for shared code.
+!!! tip "Share maths between models"
+ When several models in a folder differ only by a parameter (here `db1996.py`
+ and `hg2015.py` differ only in `alpha`), put the actual expression builder
+ in an underscore-prefixed helper package (`_utils/`). Files and folders
+ whose name starts with `_` are **not** imported as selectable models, so
+ they make natural homes for shared code.
## Writing a Global Shielding Function
-A global function lives directly in the `shielding/` parent folder and is
-available to **any** reaction whose `type` matches its stem. The contract is
-identical; it simply builds the path from the reaction key itself rather than
-being scoped to one folder.
+A global model sets `reaction = None` and lives in `shielding/global_/`. It is
+available to **any** reaction whose `type` matches its `name`; it derives
+everything it needs from the reaction passed to `get_shielding` rather than being
+bound to one reaction.
```text
physics/photo_reactions/shielding/
-├── leiden.py # type = "leiden", usable by any reaction
-└── H2__H_H/
+├── global_/
+│ ├── __init__.py
+│ └── leiden.py # name = "leiden", reaction = None
+└── H2__PHOTON__H_H/
└── ...
```
```toml
-[reaction.CO__C_O.shielding]
+[reaction."CO._PHOTON__C.O".shielding]
type = "leiden"
radiation = "ISRF"
shielded_by = ["self", "H2"]
```
-The handler reads its options the same way and returns an `Expr`. See
-`shielding/leiden.py` for a full example that builds one interpolation call per
-shielding species.
+```python
+@_register
+class Leiden(ShieldingFunction):
+ name = "leiden"
+ reaction = None # global — usable by any photo-reaction
+
+ def get_shielding(self, reaction, network) -> Expr:
+ ...
+```
+
+See `shielding/global_/leiden.py` for a full example that builds one
+interpolation call per shielding species.
## Checklist
-- [x] Module placed correctly — `shielding//.py` (local) or
- `shielding/.py` (global)
-- [x] File stem (lower-case) equals the TOML `shielding.type` keyword
-- [x] Exposes `get_shielding(reaction, network) -> sympy.Expr`
-- [x] Reads model options from `reaction.metadata["shielding"]`
+- [x] Class subclasses `ShieldingFunction` and is decorated with `@_register`
+- [x] `name` (lower-case) equals the TOML `shielding.type` keyword
+- [x] `reaction` set to the serialized reaction (local) or left `None` (global)
+- [x] Local model placed under the **sanitised** folder
+ (`shielding//.py`); global under `shielding/global_/`
+- [x] Implements `get_shielding(self, reaction, network) -> sympy.Expr`
+- [x] Reads model options from `reaction._metadata["shielding"]`
- [x] Validates inputs and raises `ParserError` (reaction-tagged) on bad values
- [x] Returns a dimensionless expression using the `ncol_` / `vdisp`
symbol conventions
diff --git a/docs/development/codebase-structure.md b/docs/development/codebase-structure.md
index 1e337270..d1c1774a 100644
--- a/docs/development/codebase-structure.md
+++ b/docs/development/codebase-structure.md
@@ -17,9 +17,23 @@ src/jaff/
│ ├── reaction.py # Reaction + Reactions catalogue
│ ├── species.py # Specie + Species catalogue
│ ├── elements.py # Element + Elements catalogue
-│ ├── _network_engine.py # Multi-format network file parser
-│ ├── _auxiliary_engine.py # .jfunc auxiliary function parser
-│ └── _typing/ # TypedDicts for all core types
+│ ├── parsers/ # File parsers (network + auxiliary)
+│ │ ├── network/ # Multi-format network file parser
+│ │ │ ├── _engine.py # NetworkParser — drives format plugins
+│ │ │ ├── _typing/ # parsedListProps, krome/prizmoFormatProps
+│ │ │ └── _formats/ # One subpackage per format (plugins)
+│ │ │ ├── _base.py # NetworkFormat ABC (plugin contract)
+│ │ │ ├── _context.py # ParseContext — shared per-parse state
+│ │ │ ├── __init__.py # register / all_formats / build_state
+│ │ │ ├── krome/ # header.py · var.py · reaction.py
+│ │ │ ├── prizmo/ # vars.py · reaction.py
+│ │ │ ├── udfa/ # reaction.py
+│ │ │ ├── uclchem/ # reaction.py
+│ │ │ └── kida/ # reaction.py
+│ │ └── auxiliary_func/ # .jfunc auxiliary function parser
+│ │ ├── _engine.py # AuxiliaryFunctionParser
+│ │ └── _typing/ # AuxiliaryFunctionsDict
+│ └── _typing/ # Shared core TypedDicts (Network/Element/Reaction)
│
├── physics/ # Symbolic ODE/flux generation + physics helpers
│ ├── _equations.py # get_sfluxes, get_sodes, get_sradodes
@@ -27,14 +41,20 @@ src/jaff/
│ │ ├── _photochemistry.py # get_xsec / get_verner_xsec / shielding — lookups
│ │ ├── _radiation.py # Radiation moment equations
│ │ ├── _typing/ # TypedDicts (XsecsProps, ...)
-│ │ └── shielding/ # Shielding functions (dispatched by reaction metadata)
-│ │ ├── leiden.py # Leiden tabulated line shielding (global)
-│ │ └── H2__H_H/ # H2 self-shielding (db1996, hg2015) + shared _utils
+│ │ └── shielding/ # Shielding-function registry (@_register, by reaction metadata)
+│ │ ├── _base.py # ShieldingFunction ABC (name, reaction attrs)
+│ │ ├── global_/ # Global models, reaction=None (e.g. leiden.py)
+│ │ └── H2__PHOTON__H_H/ # Local H2 self-shielding (db1996, hg2015) + shared _utils
│ ├── _typing/ # TypedDicts (Numeric, ...)
│ └── constants.py # Physical constants (astropy Quantities)
│
-├── plotting/ # Publication-style matplotlib wrapper
-│ └── plotter.py # Plotter — plot / plot_xsec (house rcParams)
+├── plotting/ # Publication-style seaborn plotting
+│ ├── _api.py # plot_rates / plot_xsecs — free functions (reactions, exprs, arrays)
+│ ├── plotter.py # Plotter.render_series — seaborn-objects renderer
+│ ├── _theme.py # seaborn theme, palettes, scoped/global application
+│ ├── _frames.py # tidy DataFrame builders
+│ ├── _units.py # energy/xsec unit conversion + axis labels
+│ └── _xsec.py # trim / dynamic-scale helpers
│
├── codegen/ # Code generation pipeline
│ ├── codegen.py # SymPy → C/C++/Fortran/Python/Rust/Julia/R
@@ -97,7 +117,7 @@ src/jaff/
│ └── leiden.hdf5 # Leiden line shielding (one group per reaction)
│
├── db/ # Prebuilt SQLite database
-│ └── jaff.db # Reaction/species/mass + cross-section + shielding tables, built from data/
+│ └── jaff.db # Mass + photo cross-section (Leiden/NORAD + Verner) tables, built from data/
│
└── _utils/ # Standalone maintenance scripts
├── generate_mass_table.py # Build mass tables in jaff.db from data/atom_mass.csv
@@ -106,9 +126,7 @@ src/jaff/
├── split_xsecs_photodecay.py # Split source diss/ion datasets into the photodecay channel
├── generate_photo_xsecs_table.py # Build photo_reaction_cross_sections table in jaff.db
├── generate_ion_xsecs_table.py # Build verner_cross_sections table in jaff.db
- ├── build_shielding_hdf5.py # Collapse Leiden shielding tables into shielding/leiden.hdf5
- ├── build_shielding_table.py # Build photo_reaction_shielding table in jaff.db
- └── add_shielding_column.py # Add a shielding column to an existing jaff.db table
+ └── build_shielding_hdf5.py # Collapse Leiden shielding tables into shielding/leiden.hdf5
```
## Architecture Diagram
@@ -122,8 +140,8 @@ flowchart TD
CFG["jaff.toml / CLI"]
end
- subgraph parse_sg ["Parsing — core"]
- NE["NetworkParser\nauto-detect format\nregex → dicts"]
+ subgraph parse_sg ["Parsing — core.parsers"]
+ NE["NetworkParser\nauto-detect format\nformat plugins → dicts"]
AE["AuxiliaryParser\n@var / @function\nSymPy expressions"]
end
@@ -158,24 +176,24 @@ flowchart TD
The table below traces a single `jaffgen` invocation from command line to output files.
-| Step | Component | What happens |
-| ---- | --------------------------- | -------------------------------------------------------------------------------------------------- |
-| 1 | `cli/_jaffgen.py` | Parse CLI args, read `jaff.toml` via `_config_engine.py` |
-| 2 | `core/_network_engine.py` | Auto-detect format; convert each reaction line to a `parsedListProps` dict |
-| 3 | `core/_auxiliary_engine.py` | Parse `.jfunc` file (if present); resolve `@var`/`@function` blocks into SymPy expressions |
-| 4 | `core/network.py` | Build `Species`, `Reactions`, `Elements` catalogues; validate duplicates, sinks, isomers |
-| 5 | `physics/_equations.py` | Compute symbolic fluxes (`sfluxes`) and ODE RHS (`sodes`) using SymPy |
-| 6 | `codegen/codegen.py` | Translate SymPy expressions into assignment strings for the chosen language |
-| 7 | `codegen/preprocessor.py` | Walk template files; replace `!! PREPROCESS_KEY … !! PREPROCESS_END` blocks with generated strings |
-| 8 | `codegen/builder.py` | Invoke the named plugin's `#!python main()` to write final output files to the build directory |
+| Step | Component | What happens |
+| ---- | ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
+| 1 | `cli/_jaffgen.py` | Parse CLI args, read `jaff.toml` via `_config_engine.py` |
+| 2 | `core/parsers/network/_engine.py` | Auto-detect format via registered plugins; convert each reaction line to a `parsedListProps` dict |
+| 3 | `core/parsers/auxiliary_func/_engine.py` | Parse `.jfunc` file (if present); resolve `@var`/`@function` blocks into SymPy expressions |
+| 4 | `core/network.py` | Build `Species`, `Reactions`, `Elements` catalogues; validate duplicates, sinks, isomers |
+| 5 | `physics/_equations.py` | Compute symbolic fluxes (`sfluxes`) and ODE RHS (`sodes`) using SymPy |
+| 6 | `codegen/codegen.py` | Translate SymPy expressions into assignment strings for the chosen language |
+| 7 | `codegen/preprocessor.py` | Walk template files; replace `!! PREPROCESS_KEY … !! PREPROCESS_END` blocks with generated strings |
+| 8 | `codegen/builder.py` | Invoke the named plugin's `#!python main()` to write final output files to the build directory |
## Key Design Decisions
-**Regex-driven, format-agnostic parser.**
-`NetworkParser` uses an ordered dict of `(global_re, local_re, handler)` triples. The fast `global_re` filters candidate lines; `local_re` extracts named groups. Adding a new format means adding one entry — no branching in shared code. See [Adding a Parser](adding-parsers.md).
+**Plugin-based, format-agnostic parser.**
+Each network format is a `NetworkFormat` subclass living in its own subpackage under `core/parsers/network/_formats/`. A class registers itself with the `@register` decorator; `NetworkParser` discovers all formats via `all_formats()`, ordered by each format's `priority` (not file or import order). Every format exposes a fast `_global_re` filter and a detailed `_local_re` extractor, and writes results through a shared `ParseContext`. Adding a new format means adding one subpackage — no edits to the engine or shared code. See [Adding a Parser](adding-parsers.md).
**SymPy as the intermediate representation.**
-All rate expressions, fluxes, and ODEs live as SymPy objects inside `Network`. Code generation (`Codegen`) calls SymPy's language-specific printers (`ccode`, `cxxcode`, `fcode`, etc.), so adding a new target language is isolated to `LangModifier` token tables.
+All rate expressions, fluxes, and ODEs live as SymPy objects inside `Network`. Code generation (`Codegen`) calls SymPy's language-specific printers (`ccode`, `cxxcode`, `fcode`, etc.), so adding a new target language is a single `Language` subclass in `jaff/codegen/_languages.py`.
**Plugin-based code generation.**
`Builder` discovers plugins at `jaff.plugins..plugin` and calls their `#!python main()`. Each plugin owns its template files and knows nothing about the parser. This keeps solver-specific logic out of the core library.
@@ -194,17 +212,15 @@ The cross-section scripts are ordered as a pipeline: download raw NORAD data,
collapse the per-reaction files into combined HDF5 files, then build the
SQLite lookup tables that JAFF queries at runtime.
-| Script | Purpose |
-| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `generate_mass_table.py` | Read `data/atom_mass.csv` and (re)build the element mass tables inside `db/jaff.db`. |
-| `download_nahar_xsecs.py` | Download NORAD/OP (Nahar, OSU) ground-state photoionisation cross sections (Z = 1..26) into `data/xsecs/op/` using serialized reaction names. |
-| `collapse_xsecs_hdf5.py` | Merge the per-reaction Leiden and NORAD files into combined `leiden.hdf5` / `norad.hdf5` (one group per reaction, photon energy in eV, σ in cm²). |
-| `split_xsecs_photodecay.py` | Split the source dissociation/ionisation datasets into the single `photodecay` channel used by the collapsed HDF5 files. |
+| Script | Purpose |
+| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `generate_mass_table.py` | Read `data/atom_mass.csv` and (re)build the element mass tables inside `db/jaff.db`. |
+| `download_nahar_xsecs.py` | Download NORAD/OP (Nahar, OSU) ground-state photoionisation cross sections (Z = 1..26) into `data/xsecs/op/` using serialized reaction names. |
+| `collapse_xsecs_hdf5.py` | Merge the per-reaction Leiden and NORAD files into combined `leiden.hdf5` / `norad.hdf5` (one group per reaction, photon energy in eV, σ in cm²). |
+| `split_xsecs_photodecay.py` | Split the source dissociation/ionisation datasets into the single `photodecay` channel used by the collapsed HDF5 files. |
| `generate_photo_xsecs_table.py` | Build the `photo_reaction_cross_sections` table in `db/jaff.db` from the collapsed HDF5 files (`photo_absorption` flag, `decay_type` + `file.hdf5::` pointers). |
-| `generate_ion_xsecs_table.py` | Build the `verner_cross_sections` table in `db/jaff.db` from the Verner (1996) analytic-fit parameters in `data/xsecs/verner/`. |
-| `build_shielding_hdf5.py` | Collapse the per-species Leiden line-shielding tables into `data/shielding/leiden.hdf5` (one group per reaction). |
-| `build_shielding_table.py` | Build the `photo_reaction_shielding` table in `db/jaff.db` (global/local shielding-function names per reaction). |
-| `add_shielding_column.py` | Add a shielding column to an existing `db/jaff.db` table. |
+| `generate_ion_xsecs_table.py` | Build the `verner_cross_sections` table in `db/jaff.db` from the Verner (1996) analytic-fit parameters in `data/xsecs/verner_1996.csv`. |
+| `build_shielding_hdf5.py` | Collapse the per-species Leiden line-shielding tables into `data/shielding/leiden.hdf5` (one group per reaction). |
Run a script as a module from the project root, e.g.:
diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md
index 462ab498..c5fb7c65 100644
--- a/docs/getting-started/concepts.md
+++ b/docs/getting-started/concepts.md
@@ -151,8 +151,8 @@ an energy budget.
```python
rxn = net.reactions[0]
-rxn.verbatim # 'H -> H+ + e-' ← verbatim is an attribute
-rxn.rtype() # 'photo' ← rtype() is a method
+rxn.verbatim # 'H + _PHOTON -> H+ + e-' ← verbatim is an attribute
+rxn.type # 'photo' ← type is an attribute
rxn.rate # a SymPy expression for the rate coefficient
rxn.reactants # species consumed
rxn.products # species created
@@ -162,8 +162,8 @@ rxn.products # species created
- `rate` — the rate coefficient as a **SymPy expression**, not a number; it
still contains the temperature symbol so it can be differentiated and emitted
as code;
-- `rtype()` — the classification (`photo`, `cosmic_ray`, `photo_av`, `3_body`,
- `unknown`), read from the rate expression;
+- `type` — the classification (`photo`, `cosmic_ray`, `3_body`, `unknown`),
+ concluded by the network-format parser as it reads the file;
- `tmin` / `tmax` — the temperature window the rate is valid over (`None` means
unbounded);
- `verbatim` — the human-readable reaction string.
@@ -174,8 +174,10 @@ $$k(T) = \alpha \left(\frac{T}{300}\right)^\beta e^{-\gamma/T}$$
with $\alpha$ the pre-exponential factor, $\beta$ the temperature exponent,
$\gamma$ the activation parameter, and $T$ the temperature in Kelvin.
-Photo-reactions instead carry a `photorates(...)` call, which is what `rtype()`
-keys on. The [Reactions page](../user-guide/working-with-networks/reactions.md)
+Photo-reactions instead carry a `photorates(...)` call and a `_PHOTON`
+pseudo-reactant — the parser uses the latter to classify them as photo-reactions
+(the `type` attribute no longer inspects the rate). The
+[Reactions page](../user-guide/working-with-networks/reactions.md)
goes through every reaction type and the catalogue API.
---
@@ -334,7 +336,7 @@ for sp in net.species:
print(f"{sp.name}: {sp.mass:.3e} g charge {sp.charge:+d}")
for rxn in net.reactions:
- print(f"{rxn.verbatim} [{rxn.rtype()}]")
+ print(f"{rxn.verbatim} [{rxn.type}]")
```
The code-generation pass is a `jaffgen` invocation over your templates, exactly
diff --git a/docs/user-guide/advanced-code-generation/codegen.md b/docs/user-guide/advanced-code-generation/codegen.md
index bb93b4b7..daa4ede9 100644
--- a/docs/user-guide/advanced-code-generation/codegen.md
+++ b/docs/user-guide/advanced-code-generation/codegen.md
@@ -54,19 +54,21 @@ brackets, whether indices start at `0` or `1`, the comment marker, and the
assignment operator. Pick it with a name or any common alias.
```python
-Codegen(network, lang="c++", brac_format="", matrix_format="")
+Codegen(network, lang="c++")
```
-| Parameter | Type | Default | Description |
-| --------------- | --------- | ------- | --------------------------------------------------------------------------------------------- |
-| `network` | `Network` | — | Parsed chemical reaction network |
-| `lang` | `str` | `"c++"` | Target language — name or alias (see table) |
-| `brac_format` | `str` | `""` | Override the 1-D array brackets: `"[]"`, `"()"`, `"{}"`, `"<>"` |
-| `matrix_format` | `str` | `""` | Override the 2-D Jacobian brackets/separator (see [Jacobian](#the-jacobian-get_jacobian_str)) |
+| Parameter | Type | Default | Description |
+| --------------- | --------- | ------- | ------------------------------------------- |
+| `network` | `Network` | — | Parsed chemical reaction network |
+| `lang` | `str` | `"c++"` | Target language — name or alias (see table) |
-An unsupported `lang`, `brac_format`, or `matrix_format` raises `ValueError`
-listing what _is_ supported, so a typo fails loudly at construction rather than
-producing wrong code.
+An unsupported `lang` raises `InvalidLanguageError` listing what _is_ supported,
+so a typo fails loudly at construction rather than producing wrong code.
+
+Bracket and token formatting are **not** fixed at construction — each
+`get_*_str` method accepts per-call overrides (`brac_format`, `matrix_format`,
+`assignment_op`, `line_end`) that apply only to that call and reset afterwards.
+See [the Jacobian](#the-jacobian-get_jacobian_str) for a `matrix_format` example.
| Alias(es) | Canonical | Brackets | Index base | Comment | Assignment |
| ------------------- | --------- | -------- | ---------- | ------- | ---------- |
@@ -291,7 +293,7 @@ A few specifics:
| `"{,}"` | `J{i, j}` |
(`"()"`, `"[][]"`, `"<>"`, and their `,`-variants follow the same pattern.) An
- unsupported value raises `ValueError`.
+ unsupported value raises `InvalidLanguageError`.
---
@@ -345,19 +347,27 @@ for idx, expr in ir["expressions"]:
---
-## Inspecting the language tables
+## Inspecting the language tokens
-`get_language_tokens()` is a static method exposing the raw syntax table behind
-every language — useful if you're matching JAFF's output to a hand-written file.
+Each language's syntax lives on a `Language` singleton. Resolve one by alias and
+read its attributes — useful if you're matching JAFF's output to a hand-written
+file.
```python
-tokens = Codegen.get_language_tokens()
-tokens["python"]["comment"] # '#'
-tokens["fortran"]["idx_offset"] # 1
+from jaff.codegen import Language
+
+py = Language("python")
+py.comment # '#'
+Language("fortran").idx_offset # 1
+
+Language.registered() # every registered language singleton
+Language.comments() # {'#', '//', '!'}
```
-Each entry is a `LangModifier` with `brac`, `assignment_op`, `line_end`,
-`matrix_sep`, `code_gen`, `idx_offset`, `comment`, `types`, and `extras`.
+A `Language` carries `lb`/`rb`, `mlb`/`mrb`, `sep`, `assignment_op`, `line_end`,
+`code_gen`, `idx_offset`, `comment`, `types`, and `extras`. Add a new target
+language by subclassing `Language` with those class attributes — no central
+edits required.
---
diff --git a/docs/user-guide/code-generation/jaff-toml.md b/docs/user-guide/code-generation/jaff-toml.md
index 924c45ce..690fc0d9 100644
--- a/docs/user-guide/code-generation/jaff-toml.md
+++ b/docs/user-guide/code-generation/jaff-toml.md
@@ -123,15 +123,19 @@ and the original papers are in the
[Shielding](../designing-networks/photochemistry.md#shielding) section. The
reaction **must** be a photo-reaction or generation aborts.
+The serialized key contains `.` separators, so it **must be quoted** in the
+table header — otherwise TOML reads the dots as nested tables. Photo-reactions
+also carry the `_PHOTON` agent in their serialized form.
+
```toml
# Leiden tabulated line shielding
-[reaction.CO__C_O.shielding]
+[reaction."CO._PHOTON__C.O".shielding]
type = "leiden" # default if omitted
radiation = "ISRF"
shielded_by = ["self", "H2"]
# H2 self-shielding (Hartwig et al. 2015)
-[reaction.H2__H_H.shielding]
+[reaction."H2._PHOTON__H.H".shielding]
type = "hg2015"
min_ncol = 1.0e-35
min_vdisp = 1.0e-20
@@ -139,23 +143,23 @@ min_vdisp = 1.0e-20
Common key:
-| Key | Type | Default | Description |
-| ------ | ----- | ---------- | ---------------------------------------------------------------- |
+| Key | Type | Default | Description |
+| ------ | ----- | ---------- | --------------------------------------------------------------------------- |
| `type` | `str` | `"leiden"` | Shielding function: `"leiden"`, `"db1996"`, or `"hg2015"`. Case-insensitive |
`type = "leiden"` keys:
-| Key | Type | Default | Description |
-| ------------- | ------ | -------- | --------------------------------------------------------------------------------------------- |
+| Key | Type | Default | Description |
+| ------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------- |
| `shielded_by` | `list` | required | Shielding species; allowed: `"self"`, `"H2"`, `"H"`, `"C"`, `"N2"`, `"CO"`. Per-species factors are multiplied |
-| `radiation` | `str` | `"ISRF"` | Radiation-field subgroup in the Leiden table |
+| `radiation` | `str` | `"ISRF"` | Radiation-field subgroup in the Leiden table |
-`type = "db1996"` / `"hg2015"` keys (only on the `H2__H_H` reaction):
+`type = "db1996"` / `"hg2015"` keys (only on the `H2._PHOTON__H.H` reaction):
-| Key | Type | Default | Description |
-| ----------- | ------- | ------- | ----------------------------------------------------- |
-| `min_ncol` | `float` | `1e-50` | Lower floor used in the fit (cm⁻²) |
-| `min_vdisp` | `float` | `1e-50` | Lower floor used in the fit (cm s⁻¹) |
+| Key | Type | Default | Description |
+| ----------- | ------- | ------- | ------------------------------------ |
+| `min_ncol` | `float` | `1e-50` | Lower floor used in the fit (cm⁻²) |
+| `min_vdisp` | `float` | `1e-50` | Lower floor used in the fit (cm s⁻¹) |
## `[[table]]` section
diff --git a/docs/user-guide/designing-networks/photochemistry.md b/docs/user-guide/designing-networks/photochemistry.md
index 42a202d9..e1e3126c 100644
--- a/docs/user-guide/designing-networks/photochemistry.md
+++ b/docs/user-guide/designing-networks/photochemistry.md
@@ -115,8 +115,9 @@ JAFF will look up the matching cross section in its bundled database and integra
## Cross-Section Data
JAFF bundles cross sections from three sources. At network-load time the
-serialized reaction key (`Reactant1_Reactant2__Product1_Product2`, the
-[serialized form of the reaction](../../api/core/reaction/index.md)) is looked
+serialized reaction key (`Reactant1.Reactant2__Product1.Product2`, the
+[serialized form of the reaction](../../api/core/reaction/index.md); photo
+reactions carry the `_PHOTON` agent, e.g. `H._PHOTON__H+.e-`) is looked
up in `jaff.db`, and the cross-section arrays are attached to the reaction's
[`xsecs_dict`](../working-with-networks/reactions.md).
@@ -199,20 +200,21 @@ $$
### Enabling shielding
Shielding is opt-in per reaction, declared in `jaff.toml` under
-`[reaction..shielding]` (see the
+`[reaction."".shielding]` (see the
[configuration reference](../code-generation/jaff-toml.md#reactionserializedshielding-section)).
The reaction **must be a photo-reaction**; the `type` key selects the shielding
-function (default `"leiden"`).
+function (default `"leiden"`). The serialized key contains `.` separators and so
+**must be quoted** in the TOML header.
```toml
# Leiden tabulated line shielding for CO photodissociation
-[reaction.CO__C_O.shielding]
+[reaction."CO._PHOTON__C.O".shielding]
type = "leiden"
radiation = "ISRF" # radiation-field subgroup (default "ISRF")
shielded_by = ["self", "H2"] # shielding species; "self" = the reactant (CO)
# H2 self-shielding via the Hartwig et al. (2015) fit
-[reaction.H2__H_H.shielding]
+[reaction."H2._PHOTON__H.H".shielding]
type = "hg2015"
min_ncol = 1.0e-35 # optional floors (see below)
min_vdisp = 1.0e-20
@@ -223,8 +225,8 @@ min_vdisp = 1.0e-20
| `type` | Function | Reactions | Reference |
| ---------- | -------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"leiden"` | Leiden tabulated tables | any photo-reaction | [Leiden photodissociation database](https://home.strw.leidenuniv.nl/~ewine/photo/); [Heays et al. 2017, A&A 602, A105](https://ui.adsabs.harvard.edu/abs/2017A%26A...602A.105H/abstract) |
-| `"db1996"` | H2 self-shielding fit ($\alpha = 2$) | `H2__H_H` | [Draine & Bertoldi 1996, ApJ 468, 269](https://ui.adsabs.harvard.edu/abs/1996ApJ...468..269D/abstract) (DOI [10.1086/177689](https://doi.org/10.1086/177689)) |
-| `"hg2015"` | H2 self-shielding fit ($\alpha = 1.1$) | `H2__H_H` | [Hartwig et al. 2015, MNRAS 452, 1233](https://ui.adsabs.harvard.edu/abs/2015MNRAS.452.1233H/abstract) (DOI [10.1093/mnras/stv1368](https://doi.org/10.1093/mnras/stv1368)) |
+| `"db1996"` | H2 self-shielding fit ($\alpha = 2$) | `H2._PHOTON__H.H` | [Draine & Bertoldi 1996, ApJ 468, 269](https://ui.adsabs.harvard.edu/abs/1996ApJ...468..269D/abstract) (DOI [10.1086/177689](https://doi.org/10.1086/177689)) |
+| `"hg2015"` | H2 self-shielding fit ($\alpha = 1.1$) | `H2._PHOTON__H.H` | [Hartwig et al. 2015, MNRAS 452, 1233](https://ui.adsabs.harvard.edu/abs/2015MNRAS.452.1233H/abstract) (DOI [10.1093/mnras/stv1368](https://doi.org/10.1093/mnras/stv1368)) |
#### Leiden tabulated shielding (`type = "leiden"`)
@@ -242,11 +244,11 @@ For each species in `shielded_by`, JAFF emits a per-reaction
[interpolation call](../code-generation/table-interpolation.md)
`interp__shielding_(ncol_)`; the total factor is their
product. `"self"` resolves to the reaction's reactant, so it interpolates over
-that species' own column density (e.g. `ncol_CO` for `CO__C_O`).
+that species' own column density (e.g. `ncol_CO` for `CO._PHOTON__C.O`).
#### H2 self-shielding (`type = "db1996"` / `"hg2015"`)
-Both apply only to the `H2__H_H` (H2 → H + H) reaction and evaluate the
+Both apply only to the `H2._PHOTON__H.H` (H2 → H + H) reaction and evaluate the
standard three-term analytic fit
$$
@@ -270,7 +272,7 @@ inputs are the H2 column density `ncol_H2` and velocity dispersion `vdisp`.
!!! note "Shielding is exposed programmatically too"
`jaff.physics.Photochemistry.shielding(reaction, network)` returns the
symbolic factor and caches it on
- `reaction.metadata["shielding"]["value"]`; the radiation integrator reuses
+ `reaction._metadata["shielding"]["value"]`; the radiation integrator reuses
that cached value across bands.
---
diff --git a/docs/user-guide/working-with-networks/network.md b/docs/user-guide/working-with-networks/network.md
index 5554217c..57d02a5a 100644
--- a/docs/user-guide/working-with-networks/network.md
+++ b/docs/user-guide/working-with-networks/network.md
@@ -173,7 +173,7 @@ expanded into this form:
| Shorthand | Expands to |
| ------------------- | ---------------------------------------------------------------------- |
-| `ntot` | sum of `nden[i, 0]` over **all** species |
+| `ntot` | sum of `nden[i, 0]` over **all core** species |
| `nh` / `n_H` | sum over H-bearing species, **weighted** by atom count |
| `nhe` / `n_He` | sum over He-bearing species, weighted |
| `n_X` (e.g. `n_CO`) | `nden[idx_X, 0]` for that one species (`Xp`→`X+`, `Xm`→`X-`, `X0`→`X`) |
@@ -184,7 +184,7 @@ as a weighted `nden` sum (note `2*nden[3, 0]` — H₂ contributes two H atoms):
```python
g = Network("networks/GOW/GOW.jet")
-g.reactions.with_rtype("cosmic_ray")[0].rate
+g.reactions.with_type("cosmic_ray")[0].rate
# crate*(1.5*nden[0, 0]/(nden[0, 0] + nden[1, 0] + 2*nden[3, 0] + ...) + ...)
```
diff --git a/docs/user-guide/working-with-networks/reactions.md b/docs/user-guide/working-with-networks/reactions.md
index b6ba8f86..eaa2dd59 100644
--- a/docs/user-guide/working-with-networks/reactions.md
+++ b/docs/user-guide/working-with-networks/reactions.md
@@ -29,9 +29,19 @@ from jaff import Network
net = Network("networks/h_photoionization/h_photo.jet")
net.reactions.count # 2
-net.reactions.verbatim() # ['H -> H+ + e-', 'H+ + e- -> H']
+net.reactions.verbatim() # ['H + _PHOTON -> H+ + e-', 'H+ + e- -> H']
```
+
+!!! note "The `_PHOTON` pseudo-species"
+ The photo-ionization reads `H + _PHOTON -> H+ + e-`, not `H -> H+ + e-`.
+ `_PHOTON` is a **special pseudo-species** (its name starts with `_`) that
+ marks the driving photon; cosmic-ray reactions carry `_CR` likewise. These
+ pseudo-species give a photo/CR reaction its distinct identity and
+ serialization, but they are **not** real species — they are excluded from
+ the kinetics and the integrated ODE state, and never appear in
+ `net.species`. See [reaction types](#reaction-types).
+
---
## The two layers
@@ -47,7 +57,7 @@ attribute into a flat array for the solver and code generator.
```text
net.reactions ← the Reactions catalogue (the set)
- ├── net.reactions[0] → Reaction ← one transformation (H -> H+ + e-)
+ ├── net.reactions[0] → Reaction ← one transformation (H + _PHOTON -> H+ + e-)
└── net.reactions[1] → Reaction ← one transformation (H+ + e- -> H)
```
@@ -81,8 +91,9 @@ rec.get_sympy() # 1.65941781598291e-10/tgas**0.7
sympy.diff(rec.get_sympy(), "tgas") # analytic dk/dT, also symbolic
```
-A photo-reaction's rate is instead an unevaluated `photorates(...)` call, which
-is what marks it as photochemical (see [reaction types](#reaction-types)):
+A photo-reaction's rate is instead an unevaluated `photorates(...)` call. The
+reaction is classified as photochemical by the parser (via its `_PHOTON`
+reactant), not by inspecting the rate — see [reaction types](#reaction-types):
```python
net.reactions[0].rate # photorates(1, 13.6, 1.0e+99)
@@ -107,7 +118,7 @@ net.reactions[0].rate # photorates(1, 13.6, 1.0e+99)
| `index` | `int` | Zero-based position of this reaction inside `net.reactions` |
| `serialized` | `str` | Canonical **name-level** identity (isomer-sensitive) |
| `serialized_exploded` | `str` | Canonical **atom-level** identity (isomer-insensitive) |
-| `metadata` | `dict` | Key/value store; `metadata["type"]` holds the classified reaction type |
+| `type` | `str` | Reaction type concluded by the parser: `"photo"`, `"cosmic_ray"`, `"3_body"`, `"unknown"` |
| `custom_rad_rate` | `bool` | `True` when the radiation rate came from a `.jfunc`, not cross-sections |
| `xsecs_dict` | `XsecsProps or None` | Photo cross-section data for the reaction's single decay channel: `photon_energy` (eV) plus `photo_absorption` and `photodecay` (cm²); else `None` |
@@ -121,8 +132,8 @@ net.reactions[0].rate # photorates(1, 13.6, 1.0e+99)
```python
rxn = net.reactions[0]
-rxn.verbatim # 'H -> H+ + e-'
-rxn.reactants.names() # ['H']
+rxn.verbatim # 'H + _PHOTON -> H+ + e-'
+rxn.reactants.names() # ['H', '_PHOTON']
rxn.products.names() # ['H+', 'e-']
rxn.tmin, rxn.tmax # (None, None)
rxn.index # 0
@@ -150,12 +161,14 @@ Just as a [`Specie`](species.md) has a canonical `serialized` identity, so does
a reaction — but a reaction has **two**, and the difference is the whole point.
```python
-rxn.serialized # 'H__H+_e-' ← name-level (isomer-sensitive)
-rxn.serialized_exploded # 'H__+/H_e-' ← atom-level (isomer-insensitive)
+rxn.serialized # 'H._PHOTON__H+.e-' ← name-level (isomer-sensitive)
+rxn.serialized_exploded # 'H._PHOTON__+/H.e-' ← atom-level (isomer-insensitive)
```
-Both sort the species on each side and join reactants `__` products. They differ
-in _what_ they sort:
+Both sort the species on each side, join the species on a side with `.`, and
+separate reactants from products with `__`. (The `.` joiner — not `_` — is used
+because special pseudo-species names start with `_`.) They differ in _what_ they
+sort:
- **`serialized`** uses species **names**. `HCO+` and `HOC+` are different here.
This is the form used for `==`, hashing, and the catalogue's serialized
@@ -187,7 +200,7 @@ r0 == r1 # False — different serialized forms
r0 == net.reactions[0] # True — same reaction
r0 < r1 # compares serialized strings, not index order
-sorted([r1, r0]) # [ReactionObject(H+ + e- -> H), ReactionObject(H -> H+ + e-)]
+sorted([r1, r0]) # [ReactionObject(H+ + e- -> H), ReactionObject(H + _PHOTON -> H+ + e-)]
```
@@ -203,9 +216,9 @@ runs at load time.
Printing a reaction gives its human-readable equation; `repr` wraps it:
```python
-str(net.reactions[0]) # 'H -> H+ + e-' ← __str__ is the verbatim
-repr(net.reactions[0]) # 'ReactionObject(H -> H+ + e-)'
-print(net.reactions[0]) # H -> H+ + e-
+str(net.reactions[0]) # 'H + _PHOTON -> H+ + e-' ← __str__ is the verbatim
+repr(net.reactions[0]) # 'ReactionObject(H + _PHOTON -> H+ + e-)'
+print(net.reactions[0]) # H + _PHOTON -> H+ + e-
```
`str(reaction)` returning the verbatim equation is what lets you drop a
@@ -215,21 +228,23 @@ print(net.reactions[0]) # H -> H+ + e-
## Reaction types
-`rtype()` classifies a reaction by **inspecting its rate expression** — there is
-no separate type field in the file. The result is cached in `metadata["type"]`.
+The `type` attribute holds the type **concluded by the network-format parser**
+as it read the file — it does not inspect the rate expression. The parser decides
+structurally (a `_PHOTON` reactant → photo, a `_CR`/`_CRP`/`_CRPHOT` reactant →
+cosmic-ray, three or more real reactants → 3-body), so the type survives even
+custom rates.
-| Type | Trigger in the rate expression | Example rate |
-| -------------- | --------------------------------- | --------------------------- |
-| `"photo"` | a `photorates(...)` function call | `photorates(1, 13.6, 1e99)` |
-| `"cosmic_ray"` | contains the symbol `crate` | `0.46*crate` |
-| `"photo_av"` | contains the symbol `av` | `7.1e-7*exp(-0.5*av)` |
-| `"3_body"` | contains the symbol `ntot` | `k0 + k1*ntot` |
-| `"unknown"` | none of the above | `1.66e-10/tgas**0.7` |
+| Type | Meaning |
+| -------------- | ------------------------------------------------ |
+| `"photo"` | Radiation-driven (photodissociation/ionisation) |
+| `"cosmic_ray"` | Cosmic-ray driven |
+| `"3_body"` | Three-body reaction |
+| `"unknown"` | Unclassified |
```python
-net.reactions[0].rtype() # 'photo'
-net.reactions[1].rtype() # 'unknown'
-net.reactions.rtypes() # ['photo', 'unknown']
+net.reactions[0].type # 'photo'
+net.reactions[1].type # 'unknown'
+net.reactions.types() # ['photo', 'unknown']
```
### Photo-reactions, a special citizen
@@ -279,18 +294,18 @@ stoichiometry matrices) and can be looked up two ways.
### Two ways to find a reaction
```python
-net.reactions[0] # by index → Reaction
-net.reactions[-1] # negative index → last reaction
-net.reactions["H -> H+ + e-"] # by verbatim string
-net.reactions["H__H+_e-"] # by serialized form
+net.reactions[0] # by index → Reaction
+net.reactions[-1] # negative index → last reaction
+net.reactions["H + _PHOTON -> H+ + e-"] # by verbatim string
+net.reactions["H._PHOTON__H+.e-"] # by serialized form
```
The typed helpers do the same with an optional type filter:
```python
-net.reactions.from_verbatim("H -> H+ + e-")
-net.reactions.from_serialized("H__H+_e-")
-net.reactions.get("H -> H+ + e-", rtype="photo") # None if type mismatches
+net.reactions.from_verbatim("H + _PHOTON -> H+ + e-")
+net.reactions.from_serialized("H._PHOTON__H+.e-")
+net.reactions.get("H + _PHOTON -> H+ + e-", type="photo") # None if type mismatches
```
### Iteration and count
@@ -303,7 +318,7 @@ equals `rxn.reactants.count`.)
```python
for rxn in net.reactions:
- print(f"{rxn.index:>3} {rxn.verbatim:<16} {rxn.rtype()}")
+ print(f"{rxn.index:>3} {rxn.verbatim:<16} {rxn.type}")
net.reactions.count # 2
len(net.reactions) # 2 — identical
@@ -315,14 +330,14 @@ Each returns a `Vector` aligned to catalogue order.
| Method | Returns | h_photo result |
| ----------------------- | ----------------------- | ------------------------------------ |
-| `verbatim()` | `Vector[str]` | `['H -> H+ + e-', 'H+ + e- -> H']` |
-| `rtypes()` | `Vector[str]` | `['photo', 'unknown']` |
+| `verbatim()` | `Vector[str]` | `['H + _PHOTON -> H+ + e-', 'H+ + e- -> H']` |
+| `types()` | `Vector[str]` | `['photo', 'unknown']` |
| `rates()` | `Vector[Basic]` | the two symbolic rate expressions |
| `reactants()` | `Vector[Species]` | one `Species` catalogue per reaction |
| `products()` | `Vector[Species]` | one `Species` catalogue per reaction |
| `tmins()` / `tmaxes()` | `Vector[float or None]` | `[None, None]` / `[None, None]` |
| `dE()` / `dRad()` | `Vector[Basic]` | energy / radiation expressions |
-| `serialized()` | `Vector[str]` | `['H__H+_e-', 'H+_e-__H']` |
+| `serialized()` | `Vector[str]` | `['H._PHOTON__H+.e-', 'H+.e-__H']` |
| `serialized_exploded()` | `Vector[str]` | atom-level serialized strings |
@@ -334,12 +349,12 @@ Each returns a `Vector` aligned to catalogue order.
```python
net.reactions.photo_reactions() # the photo subset
-net.reactions.with_rtype("cosmic_ray") # cosmic-ray reactions
-net.reactions.with_rtype("unknown") # everything unclassified
+net.reactions.with_type("cosmic_ray") # cosmic-ray reactions
+net.reactions.with_type("unknown") # everything unclassified
```
-Note the type keys are `"photo"`, `"cosmic_ray"`, `"photo_av"`, `"3_body"`,
-`"unknown"` — there is no `"CR"`.
+Note the type keys are `"photo"`, `"cosmic_ray"`, `"3_body"`, `"unknown"` —
+there is no `"CR"`.
---
@@ -365,10 +380,10 @@ rec.has_any_species("e-") # True — on either side
```python
rxn = net.reactions[0]
-rxn.verbatim # 'H -> H+ + e-' (also rxn.get_verbatim())
-rxn.get_latex() # '${\\rm H}\\,\\to\\,{\\rm H^{+}} + {\\rm e^{-}}$'
-rxn.serialize() # 'H__H+_e-'
-rxn.serialize_exploded() # 'H__+/H_e-'
+rxn.verbatim # 'H + _PHOTON -> H+ + e-' (also rxn.get_verbatim())
+rxn.get_latex() # '${\\rm H} + {\\rm _PHOTON}\\,\\to\\,{\\rm H^{+}} + {\\rm e^{-}}$'
+rxn.serialize() # 'H._PHOTON__H+.e-'
+rxn.serialize_exploded() # 'H._PHOTON__+/H.e-'
```
### Code generation
@@ -399,8 +414,10 @@ rec.get_flux_expression(idx=1, rate_variable="k",
### Plotting
-Both plotters use the styled `jaff.plotting.Plotter` house style and return the
-`(fig, ax)` they drew on, so plots can be composed or saved.
+The `Reaction` plot methods are thin wrappers over the
+[`jaff.plotting`](../../api/plotting/index.md) free functions `plot_rates` and
+`plot_xsecs`, applying the house style. They return the `(fig, ax)` they drew
+on, so plots can be composed or saved.
```python
rec.plot_rate_coefficient() # rate vs temperature (log–log)
@@ -410,13 +427,36 @@ photo.plot_xsecs() # all processes, overlay, eV vs
photo.plot_xsecs(processes="photodecay") # one process only
photo.plot_xsecs(layout="subplots") # one stacked panel per process
photo.plot_xsecs(energy_unit="nm", xsec_unit="cm^2") # wavelength + cm² axes
+photo.plot_xsecs(shade=True, show_bands=True) # shade + band-averaged bars
photo.plot_xsecs(save=True, filename="h_xsec.pdf") # write to disk
```
`plot_rate_coefficient` spans `[tmin, tmax]`, defaulting to `2.73 K` and `1e6 K`
when a bound is `None`. `plot_xsecs` is a no-op (returns `None`) for non-photo
reactions (those with `xsecs_dict is None`) or when no requested process has
-data.
+data. `plot_rate_coefficient` returns `None` for photo reactions, whose rate
+carries a symbolic radiation-density variable that cannot be evaluated against
+temperature.
+
+#### Comparing several reactions
+
+To overlay multiple reactions on one axes, call the free functions (or the
+`Reactions` catalogue methods) with a list:
+
+```python
+from jaff.plotting import plot_rates, plot_xsecs
+
+plot_rates(list(net.reactions)) # every rate on shared axes
+net.reactions.plot_rates() # equivalent, via the catalogue
+
+# The free functions accept any list of reactions (photo_reactions() returns one).
+plot_xsecs(net.reactions.photo_reactions(), show_bands=True)
+net.reactions.plot_xsecs() # all reactions; non-photo skipped
+```
+
+Each curve gets a distinct line width (thinner in front) and a legend entry.
+See the [`jaff.plotting`](../../api/plotting/index.md) reference for the full
+options, palettes, and theming.
---
@@ -456,7 +496,7 @@ pathways(net, "H+")
```python
from collections import Counter
-Counter(net.reactions.rtypes()) # Counter({'photo': 1, 'unknown': 1})
+Counter(net.reactions.types()) # Counter({'photo': 1, 'unknown': 1})
```
### Export to CSV
@@ -469,7 +509,7 @@ with open("reactions.csv", "w", newline="") as f:
w.writerow(["index", "reaction", "type", "tmin", "tmax",
"n_reactants", "n_products"])
for rxn in net.reactions:
- w.writerow([rxn.index, rxn.verbatim, rxn.rtype(),
+ w.writerow([rxn.index, rxn.verbatim, rxn.type,
rxn.tmin, rxn.tmax,
len(rxn.reactants), len(rxn.products)])
```
diff --git a/docs/user-guide/working-with-networks/species.md b/docs/user-guide/working-with-networks/species.md
index 014d5b2b..55712abe 100644
--- a/docs/user-guide/working-with-networks/species.md
+++ b/docs/user-guide/working-with-networks/species.md
@@ -282,7 +282,8 @@ A few are easy to misread:
- **`normalized_names()`** makes each name a legal lowercase identifier:
`'+' → 'p'`, `'-' → 'n'`. So `H+` becomes `'hp'` and `e-` becomes `'en'`.
- (This is _not_ the same as `fidx`, which uses `j`/`k`.)
+ Pass `pos`/`neg` to override the replacement strings. (This is _not_ the same
+ as `fidx`, which uses `j`/`k`.)
- **`charge_truths()`** is a 0/1 mask, `1` where the specie is charged — useful
for charge-conservation terms.
- **`latex()`** on the catalogue defaults to `dollars=True` (wrapped in `$…$`),
diff --git a/examples/Demo.png b/examples/Demo.png
new file mode 100644
index 00000000..91587879
Binary files /dev/null and b/examples/Demo.png differ
diff --git a/examples/main.ipynb b/examples/main.ipynb
index 020b0297..c1a651d6 100644
--- a/examples/main.ipynb
+++ b/examples/main.ipynb
@@ -22,16 +22,16 @@
"░░█ ▄▀█ █▀▀ █▀▀\n",
"█▄█ █▀█ █▀░ █▀░\n",
"\n",
- "Just Another Flexible Format!\n",
+ "Just Another Focused Format!\n",
"\n",
- "INFO Loading network from /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/networks/demos/demo1.jet\n",
+ "INFO Loading network from /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/networks/demos/demo1.jet\n",
"INFO Network label: demo1\n"
]
},
{
"data": {
"application/vnd.jupyter.widget-view+json": {
- "model_id": "c0eb1fdb9b694575af361745d1fb9538",
+ "model_id": "c3dd32dd277f41ed96231621288c28e5",
"version_major": 2,
"version_minor": 0
},
@@ -55,7 +55,7 @@
{
"data": {
"application/vnd.jupyter.widget-view+json": {
- "model_id": "a49723f56dac4e568912e71704a68612",
+ "model_id": "2a005e0ea04146daac444332a3bb010a",
"version_major": 2,
"version_minor": 0
},
@@ -84,9 +84,9 @@
"INFO Loaded 15 reactions\n",
"INFO Loaded 2 photo-chemistry reactions\n",
"WARNING Found undefined functions photorates\n",
- "INFO Sink: H2\n",
"INFO Sink: N2+\n",
"INFO Sink: CH2\n",
+ "INFO Sink: H2\n",
"INFO Source: CH\n",
"WARNING Sink detected\n",
"WARNING Source detected\n",
@@ -199,9 +199,9 @@
"| `C + O -> CO` | $${\\rm C} + {\\rm O}\\,\\to\\,{\\rm CO}$$ |\n",
"| `CO -> C + O` | $${\\rm CO}\\,\\to\\,{\\rm C} + {\\rm O}$$ |\n",
"| `CO -> CO+ + e-` | $${\\rm CO}\\,\\to\\,{\\rm CO^{+}} + {\\rm e^{-}}$$ |\n",
- "| `H -> H+ + e-` | $${\\rm H}\\,\\to\\,{\\rm H^{+}} + {\\rm e^{-}}$$ |\n",
- "| `CH2 -> CH + H` | $${\\rm CH_{2}}\\,\\to\\,{\\rm CH} + {\\rm H}$$ |\n",
- "| `N2 -> N + N` | $${\\rm N_{2}}\\,\\to\\,{\\rm N} + {\\rm N}$$ |\n",
+ "| `H + _PHOTON -> H+ + e-` | $${\\rm H} + {\\rm _PHOTON}\\,\\to\\,{\\rm H^{+}} + {\\rm e^{-}}$$ |\n",
+ "| `CH2 + _PHOTON -> CH + H` | $${\\rm CH_{2}} + {\\rm _PHOTON}\\,\\to\\,{\\rm CH} + {\\rm H}$$ |\n",
+ "| `N2 + _CR -> N + N` | $${\\rm N_{2}} + {\\rm _CR}\\,\\to\\,{\\rm N} + {\\rm N}$$ |\n",
"| `N + N -> N2` | $${\\rm N} + {\\rm N}\\,\\to\\,{\\rm N_{2}}$$ |\n",
"| `CO + N2+ -> N2 + CO+` | $${\\rm CO} + {\\rm N_{2}^{+}}\\,\\to\\,{\\rm N_{2}} + {\\rm CO^{+}}$$ |\n",
"| `H2 + e- -> H + H + e-` | $${\\rm H_{2}} + {\\rm e^{-}}\\,\\to\\,{\\rm H} + {\\rm H} + {\\rm e^{-}}$$ |\n",
@@ -244,7 +244,7 @@
"output_type": "stream",
"text": [
"The first reaction in verbatim form: H+ + e- -> H\n",
- "The firse reaction in serealized form: H+_e-__H\n"
+ "The firse reaction in serealized form: H+.e-__H\n"
]
}
],
@@ -351,16 +351,25 @@
"Cross-sections of certain photo reactions can also be plotted"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Code generation\n",
+ "\n",
+ "Jaff also comes with code generation capabilities and comes with an in-built templating system to generate langauge specific code"
+ ]
+ },
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": 25,
"metadata": {},
"outputs": [
{
"data": {
- "image/png": "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sUTUoPTq1QunT5/GN998g59//hkFBQWwsbHB4sWLMXv27AoLmJo0aYJt27Zh2bJlmDRpEoqLizFy5Ei0a9cOysrKOHr0KL7//nvs3r0bx48fh6amJjp16oT58+ejbdu2Mq9dV1cXZ86cwapVq7Bnzx4cP34cQqEQ5ubmkmBdXY0bN8bFixexfPly/PXXX9i7dy9UVVXRuHFjdO7cGWPGjHnne+3evRuLFi3Cb7/9hlOnTsHOzg6LFy/G06dPERsbCw0NjUrvsbCwQL9+/XDw4MEqzcsFnk/7mDRpEr744gvcunWrwjSO6n5n7u7uaN26Nf777z98+OGHb3zCumPHDqipqWH06NFVqpuIpCMQv201ARERkQxMmTIF69atw7179yrNIwaAIUOG4ODBg7hz584bdxN4lSdPnsDR0REDBgzA2rVrZVXyW+Xn56Np06bw9/eXejcFIqoazoklIiKZKigoqNSWmpqKHTt2oGXLlq8MsOnp6Thw4AAGDRpU5QALPJ/HunTpUmzZsqXSfOeatGrVKggEgjfufkBENYNPYomISKa+/vprREVFoXv37jAxMcHNmzexefNmPH78GEePHpUc8AAA586dg0gkwvr163H+/HlcvHhRpgviiKj+4pxYIiKSqU6dOiEqKgqrV69GdnY2dHR04OXlhcDAQHTq1KnCtevXr8eOHTtgbW2N7du3M8AS0Tvjk1giIiIiUjicE0tERERECochloiIiIgUDkMsERERESkchlgiIiIiUjgMsURERESkcBhiiYiIiEjhMMQSERERkcJhiCUiIiIihcMQS0REREQK5/8AZdI8+DcADFMAAAAASUVORK5CYII=",
+ "image/png": "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",
"text/plain": [
- ""
+ ""
]
},
"metadata": {},
@@ -368,9 +377,9 @@
},
{
"data": {
- "image/png": "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2LNnj8a+vXv3QhAEODk5ad1fUzZ48GCLt7MpuHLlChYuXGjxELtlyxacPHkSL730kkWvY6jevXujT58+mD9/vrWbQkQGYIglIrvg4uKC2NhYnD59Gjk5OWr7kpKSEBISgqFDhyIpKUnj2L179yIgIACdOnVqrOaSAT799FNERESgX79+1m6KyuTJk3H8+HEcPXrU2k0honowxBKR3Rg8eDAEQdAIqklJSRg4cCAGDhyIw4cPo6ysTLXv7NmzyMzMxKBBgwAAhYWFePPNN9G7d2/4+/tDIpEgPDwczz//PPLy8lTHyeVyuLu7Y9iwYVrb8t1330EkEmHNmjWqMkEQsHr1avTq1Qvu7u5wd3dHnz59sGXLFoNf499//41x48YhICAAEokEEREReO2111BSUqJWb8qUKRCJRJDL5Zg1axZatmwJZ2dn3HXXXUhMTNQ4ryAI+N///od27drB2dkZkZGReOedd1TTNL7++msAwIIFC1R9NXXqVIhEIohEIq3zd3fs2IHevXvD1dUV/v7+ePrpp1FcXGzQ68zJycGePXtw//33QyQSqe37+uuvIRKJkJSUhA8//BBRUVFwdnZGmzZtsHz5co1zDRw4EOHh4bh+/ToeeeQR+Pj4wNPTEw8//DCys7MBAF999RU6deoEFxcXREREYO3atVrb9cADDwAAEhISDHodRGQ9jtZuABGRoQYPHox58+Zhz549GD9+PAAgOzsbZ86cwZw5c9ClSxeUl5fj0KFDqqkDNYG3ZvvmzZv44osv8NBDD2HChAlwcXHB0aNH8fnnn+PAgQP466+/4OTkBE9PT4wdOxY//vgjrl+/jlatWqm15euvv4abm5uqHUB16Pvmm28wevRoPProowCATZs2YezYsfjss88wc+ZMva/v999/x5gxY9CqVSvMmjULgYGB+Oeff7B8+XIcPHgQSUlJcHRU/9iOi4uDt7c3Xn/9dZSUlODDDz/Egw8+iIsXL6J169aqeq+88go++OAD9OrVC8888wzKy8uxdu1abN68We18Dz30ECorK/H222/jqaeeUo2SBgYGqtXbsWMHVqxYgaeffhpTpkzB7t278cUXX0AkEmHVqlV6XydQPToOVH+Fr8sbb7wBuVyOqVOnwsPDA9988w3++9//Ijg4GBMnTlSrW1xcjAEDBqBv3754++23ce7cOXz66afIzMzE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",
+ "image/png": "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IMvaTttF9fgOOuggz+0aHmrYp2vN63P94osvPBVzGthpRZ46/PDDzfF5t8ZqjvLjjz/K1KlTfY413O2QRBVrf//73+WMM85odv3KlSvlhRdekLffflumTJlifrG0lDLUPmgAAJAsGZwtLgI+AAAQPg2kbrrpJhOIedNim//85z9y1llnyfr1601oNWbMGL/7WLx4sdnHmWee6fd2rQarqakxgxKfe+45UzFm0XDs/PPPl6qqKlM5p9VjWimn7ZLe22s4plVwWtm22267NXuMwYMHy4033uhz3YsvvmgqxHRNM62ma9rSqmus6b512Svdv+YdF198sSfMs9pNr7766mbrzWkWosfofZyRbIe25Rv9RrFiTcs1/YVqSksX77zzTvNLoJVs2kf93nvvRftQAAAAAABAG9EKLW339Gf48OFmnTWlLZOBaGCmLZiB8oZvv/3WXNbWR+9QzZsuOWVVxI0fP97nNq2WUxqA+QvVAvnkk0/M+fHHHx9wnTgdenDSSSeZy5pzWLQyToM8a224YIW7HRI8WLPKJ4cMGdLqfQ888EATsClNigEAQGIKupXT3M/PnSlWAwAg4elSUBkZGQFvt9ZW03bPQDTsCjToUCvGrIEBTdcw86YFPLvvvnuzx9q4caNnfTTvNs7WaNWYTie1hiNoa6v36aeffjInbcu0LFu2zHNZw7Gjjz7aXNYM5IorrjAVbVqdp+2egYS7HRK8FdQaS6trqAXj5JNPNj3RS5cuNR9UHcMLAABiyxZmkhXWWmgAAKBDaNqqGOj27du3h7UP75yhsLCwxceybvee3GlNKVX+hhoEUlpa6rmsy1rpqTXl5eU+P99+++2mRfW7777znKwqP63kO/XUU+V3v/tdsyWywt0OCRys6eKB+oZqH7O++VrG2Zqdd97Z9FnrYn8EawAAdCQNSR15HQAAia+urq7F260pnrpOWSDeAwOa8t6utceybveuoPO+XFtb2+L2gY5J20CtgqKWWC2cFp3kqWvL6zp0Go7pkIO5c+eaCjptb9XTEUccIY899pjPtuFuhwQO1vRNHzFihMyePdtM/bz77rtb3cZKq61fMgAA0Hai8u+b7uDv5vfxwkjW+HdZAADii07sbInVHtm9e/ew9t+tWzczJVTbIHVfOikzEO2KUz169PBcp4+r4ZNmD4sWLZIjjzwyqMctLi42y17p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YwbvCgAAMRdBjRphFgAASW/IkCGmSKZpZ9qf//xnE7DpaeHChbLzzju32zECSTu8AAAAdEDtUrIGAADaSkpKilx99dWen2fNmsWLD8SiYi0rK0ueeOKJkLdLTY1JVyoAAIhyiuV3+ECQ3O5A1XJ6PekaAADxrHv37p7LFRUVfu+jraNTpkyRn376SbZt22aWhDrssMNkzz33bHHf2m6qa7wtX75c6urqpFu3bnLAAQfI3nvv3ey+n3/+uUycONGsr6b+85//SJcuXTy377777nLeeeeFvf+m5s2bJ999951s3rxZOnXqJCNHjjTtssGorq6WSZMmmX3o66GDIHbZZRcZNWqUzzp1/ujz09dSt926dasUFBSYllfdtnPnzs3ur22xkydPlhUrVsjGjRvNMl06bOKII46Qnj17Nrv//Pnz5dVXX5XMzEy5/fbbzRJd/uh+7733XnG73XLNNddIjx49gnruHUXU0yx944466qho7xYAAESRLU6CNgAAkDgWLFjguVxYWNjs9g0bNphF/DWw8fbkk0/KOeecY9Zhb0rDGp1GqtM1de02b7qO27777isPP/ywz+P98ssvZmiARUM2b1VVVZ5gLZz9W+rr603g9M477/hc/+yzz8puu+0mF154obREA0Ad9KCBXFMajOnARw29/NG17DTM8rftXXfdJf/3f/8nZ555puc6fZw33nij2XNUup/LL79c/vSnP/lcP3DgQBk/frwJzo488kg59NBD/R7Lhx9+KO+9954J6QjVmqNMDAAABM0d5QzNCuWivV8AABBda9euNeGNVVCj1V5NXX/99VJZWSknn3yyqcrSyjCtEtO20ddff91Uep1wwgk+2zzwwAMm9FJaPaaFOtoJN3fuXPnoo4/kxx9/lDFjxphwy6rwOvbYY00odOedd5qqrnHjxvkML+jVq1dE+7do8GWFalqhpifttps+fbp8/fXXJuAKRCel/u1vfzPB3q677mpCK62S06Dsm2++MdNXr7rqKvnvf/9rXhdvb731lgnOlFaajR492jw/XdN+5cqVJgzTc28aNmpFm1YH6jZaKaiPpRVv+vprZ2FRUZEJOC36fE855RR55ZVX5M033wwYrOltSodEoDmCtUa6+OKjjz4qwdCEVhNqb3/961/l119/bXG7M844Qy666KKgHgMAgEQSSjBGhRsAoL056kSc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qeaNpERcAAAkg5dfflk2bdrkc53dbpfc3Fzp27ev7LvvvtKlS5dm2z388MPidDrloosuksLCwjY84vjFaxK6J598UiorK+X3v/+99OnTRzpMsFZTUyMPPvhgs+s1YPN3vT/aBnrhhRdGeigAACBRucNY9408DwCAqHr33Xc9XWWBvrufe+65csMNN/gs5/Tss8+aYO2kk05q02Dt8ccfN5nEeeedJz169JB40l6vSbx6PIj36tVXXzUFWIccckjHCtY0vS4uLvb87Ha7TcJts9mkW7duAbdLSUmRTp06yS677CIXXHCBOQcAAHE+viBAK2d7rGsW6CH1WGzMZwAAIGwjR46Uww47zFzWcGj9+vXy3Xffme/6//3vf01V0d///vd2f4W1e668vFyOOuqouAvW0HHeq4iDNS0JnTRpkufn7du3yz777NPsegAAkPj8hVnRyLAoPgMAIH4MHz5cLrvssmaDB8eNGyfTpk0zlW1jx46V/v37t9sxIvmMGzdOqqqqEqpaLSrBWlPp6emmHzYrKyvauwYAAAnOVJNFbW+UpQEA0Fb0O/71118vp59+uulU++mnnwjWEFXnn3++JKKoB2uZmZly1113RXu3AAAgHvgNxtzhrZHWZBcAACC+9evXz3O5rKws4P1++eUXE7xt27bNrC928MEH+2zrT0VFhUydOlWWL19uhiHq0lL777+/3+20O+7HH3/0DE/83//+J+PHj/fcvtNOO5m1zcLdv7f6+nr5+eefZeXKlbJx40aztpxW6h144IGSl5cnwdAgUvcxZ84ccxzaCnn44YdLUVFRwG22bNlijnf16tVm+KMuwaXHqm26uiRXIOE8z6aDFubNmyezZs2S0tJS81xPOeWUZvfr2rVrUM9pUgjvVTDDC6Lx/ML5fLZpsAYAANC+i6zx+gMAEAvLli3zXO7cuXOz2zWo0BbRpstC6RrrV155pVx11VV+9/vaa6+Z8EPX4PKma7ePHj1a7r77brPclEWDGh0OYPnwww99tjvmmGN8grVQ92957LHHzIL6/kJEvf91111nhjm0pKSkRG6//XYTQnnLyMgwFYBjxozxu9D/U089JQ6Ho9ltGmDp8eoC/02F+zytQQsaFv7lL38xr6/liCOO8ARr1v0OOOAAc4zBPKcfQ3ivWhteEOnz03UDb7rpppA/n+0SrM2YMUMuvvhi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rAAAoO0F2cQZdwLlZ+RqAAAA0adDAHSR/2CHHHQUMQnW3n33XZk6dapkZ2fLk08+aUap3n777X7vu++++5rzadOmxeJQAABA1OOnptu6SbMAAACS3NixY+Wyyy6TLl26tPehdIxgTf31r3+VI4880tP2GWjkqlq2bFksDgUAAES5FdS7lTOc7f2lcGHNKKdkDQAAAMkWrGnr56+//mqCtBNPPLHV+xcVFZlzHWwAAADag7tDtpICAAAAcResVVdXi8PhkKysLMnNzfVcH6hizbo+0O0AACD6ov23bigVZ9F8bKaCAgAAIKmCNV1XLS0tzQRslZWVrd5/7dq15rxz587RPhQAABBnwmr5BAAAADrSGmtDhgwxLaETJ05s9b7fffedOd9tt91icSgAACCa/K6RFoW0zB29kI7wDgAAAAkdrB199NHm/IEHHvBUpPmzdOlSeeWVV8zlY489NhaHAgAAoihWBWcUsgEAACARxSRYO//886Vbt24mVDv55JPlmWeeMQMNLBs3bpTXXntNzjvvPKmqqpK9995bDjvssFgcCgAAiLKma6TplNCgq8R0qigxGgAAAJJEaix2mpeXJ0888YSMHTtWysrK5MEHH/TcVl5eLoceeqjn5379+snDDz8ci8MAAADRHiYQYWlZwM0pWQMAAEACiknFmho+fLi89957ctxxx0lKSkqz23XAwZlnnilvvvmmFBcXx+owAABAsgpQ/cYaawAAAEjoijVLr169TDWaVqnNmjVLSkpKxOVymTZRbf/Mzc2N5cMDAICgtGG5mLvdjwAAAABIjGDNuzXUu/0TAAC0r+iuc6aLrEX62LZwHjXgIxDVAQAAIGmCtURxyy23yIIFC1q8z2mnnWaGMwSyZs0aeeedd2T+/PlmMEOPHj1k1KhRZuqp3R6zzlsAANqEtllGEsrRpgkAAIBk0ubBmtPplDfeeEMmT55sgqajjjpKTj31VLHZQv+X6mhbtmyZ/PLLLy3e58ADDwx42+effy4333yzCdS8ffLJJ/L666/Lk08+SfsrACDpRKU+zB3FbegrBQAAQCIHazNmzJAxY8bIyJEj5eWXX/a57dZbbzVDDSzffPONzJ07V+644w6JF5dffrn87ne/83ubrg/njz6HG264QRwOhxnc8Ic//EG6du0qM2fONIHajz/+KNdff7089dRTMT56AAA6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cazu3bvbtddea3369In78OVf//qXjR071gUSPH3nySef7I5vyQb2ajsdl8aMGeNu4LVcYikoc9VVV7kHVIkCozrGaF0G6dikdTRy5MgqmXZro2unt956y23TwWOTaHv87W9/a3/7299Cg2SVmdZ755133DLXA7Ggfv362U033VRuIGhroeDkbbfd5q6hYo9NoiD8n/70J/vDH/7gHlQmQ+f+v/71r+78If/+97/jLk8dk+644w575JFH3IMYT4Hm4447zq3HBg0apPz3AQCSkxWJPaMCW9Cnn37qbkxk2223dTc2unCX+++/3wW4Enn88cfdjafookNP6HQTqgscXWDo6aluhsKynioz7dbon//8p7th8stDF9S6GVVA65tvvok73RtvvGHnnnuuG69z587uZl83ObrA1M3Pxo0b3XgXX3xxaABBgbQzzzzTLXdRlpqyeZSN4IM7o0aNsiuvvNIyxYgRI1yGkm5wdIOqZan/K7B25513VuizJk+ebKeccoq7yNYFuNaNgqDxblqPPfZYtx8oO0pBtEaNGrnxNQ/6jAceeMA9Pc8Es2bNsiOOOMK9b968uTtGvPfee+5GVIG1ww8/POH02rYvueQSFyATZfPoGKdl67OulC31wgsvlJl23Lhx7obIP2QYPHiwewDwwQcfuMwSZUIoEK6btkygAKcC+KK/XZk1Wn6iTEJlGye6SdUxREFqbcNalgoUK0tZ5yCtJ60TBUCVYRukda3j25tvvhkNGihjUA+ClF0r2kZuvvlmywQKQCrAqeWlc+QOO+zgstUWLFjg1ocCAXog8vDDD7ssslg6jugYo4dqyuLRMaZZs2ZuPegYp8CDggQ61qVz2q2R/n5lNG2//fYuyK91Icpy1gMv0fFF1zFaR+maVt5++20755xz3PrWvqgMXp3rdXzKzc11v9ODS+1nWztdpxxzzDFu+1OWvh4m6u/XeVTHJv/AUQ8oFXQuj87Tevilh1w+kK/A2iGHHBI6/uWXX27PP/+8e6/sOGUMan/87LPP3O/23Xdf92An2aAeACBFCqwB1WX+/PmRp556KjJnzhz3//Xr10e6dOnifiZOnJhw2kWLFkV69erlxr300ksjhYWF0WEzZ86M9O/f3w27+eab0zrt1uq9996LvPHGG5Hly5e7/48bN84tg+7duyecbvbs2ZHx48dHVq1aVWbYkiVLIscdd5z7nJ49e0YWLFhQZpyxY8e64V27do28/vrrpYaNHj06uj18/vnnkUzxxBNPRD755JNIbm6u+/8555zjloFeK2LTpk2R/fbbL9K7d+/Itdde6z5jjz32iDv+aaed5sYZPnx4ZOHChdHfaz5OPfVUN2zvvfd2n5sJtP1qXXz77beR4uJi9zvtD1oO2j/Kc9ddd7lxtfxffvnlMsNXrFhRZpv3v+/Xr190nefn50eH6Vg5ZMgQN+zKK6+MZIrJkydHXn31VbdOROcHf2zQeSORUaNGufEGDx4cPdd4s2bNigwcONAN1zYeS9/pv+fZZ58tNeyZZ56JDtPxMxPo7/zwww8jeXl5ZYZNnz49MmjQILc8Ro4cGd1ngs4//3w3fOjQoaXWhbbxs88+2w3TZ6xbty6t026NdO7U9htLy/3RRx+Nbps33XRTWqfV/uaPQdpn/HlKdN7QOULD/vrXv0Yywc8//xx57rnnIr/88kuZYWvWrImcddZZ0eU5ZcqUcj/vwQcfjC57P52uscJ88MEH0XEefvjhUsN0btF1lYaFnX8AAOlF5wWoVspMUn2cVGoGKeNMmRu+CUiwqaCyDs4++2z3XlkIeoKarmm3Vnvvvbd70l/RmhzKWtCT1LAmI3oKrietelKqLEA1HYmlzAZR9s+BBx5Yapiaye2+++7u/ejRoy1T6Mm2smoq23xDT6n15FoZgcrySURNHT/88MPoE/BgM2zNh5r2KINOWT6vvvqqZQJtv1oXygCo6NN+1RpU1q1cc801rmlVLGWbxW7z8vTTT7vm0cqWVSZpsJmhjpUXXnihe69Mt9jmpVsrNX89+OCDo5k1ydJx3jcbVLPy2HONatb5TFqfvRbkjzvKIlVWSpCyp3xWtZpvZ8p5QhmrYTWjlI152WWXuffz5s1zGZ9BOnb4Wo/ahoPrQtu4tnVlHirD57nnnkvbtFur3/3ud277jaVjlZod7r///tGs8nROq2xaHXe0Dei8EDxP6byh84coy1OZuVs7ZeUpw0xZarGUUalm5D7rL2x5Bqk+mzJz9Zn+OjQRf/2k6yRlpgfp3OJLcGTK8QkAqhOBNdRaEydOdK+HHnpoaABCdXfq1KnjAmO+mWE6pkXFqBmbmkaI75jCU1OH+fPnu/exN62erwem9RCsNwYrN1Cmi241P1RgrTw+6Kkm2Wo6EktNhnRT7YsqIzE1g1IwWctfx5OK8OtC9aLCmmEpSKfmdmom9O6777Iqygms+WCZmqqH8UEarS+N76mp3Ndff53w+OR/r+BdpgQ5E/EPQsKO9zrvqmmtzrk694YFIXwzTjU1TNe0mb4ufAH8dE3rj086H+i8EEvnDz1o0/oKe5iWafSARHUck1kX//jHP9x1p4KTiZq3y7p166LNPcs7PqmWXiYEOQGgOhFYQ62kHt30RFxU9yiMLuz8jZS/OarstKg4XVz7OmuqUxXkl63qHqnodZgBAwa4VxXKVrAIyS3zv//97y5QoNdkeiTz62K33XZzQeVE60J1eJCYrxWpm0xlgahDEGUrKOCmOm26KQqjYJkvsB/v+KSgmi8Oz7oo/6ZWwWKfRRjG/15BAtUO84LH/njrwu8TqjUV1jFCplGmpRfveK/eQ7UNh/HLWdt1sARwZabN9HWhoGM6p/XrIt4+oRYAqkUYHDfT+eUZu08EKSNTNeqGDRuWVJ1AHW98DbZ460Lnc98ig3MFAFQtAmuolebOnRt9r4yQeHxzNh9Iq+y0qDg1L/S9Gqo5V5BfF2reFS/4o2w3f2HIukjOU0895Qqr6+JcF+nJ8MtWzbPj8c1J9dTdB0tRlgKa3333nXuvTIX//Oc/LrtDRfDVCYd6iFOxb/0+tumhirD7nt0SHZ/8umCfKJ/KDYh6zYsNaKrp4KOPPlpqPM8vWwUXfHAuln7vswqD55ZM9fLLL0cDmiqkHrY8k9mudXwJ9rBYmWkzkY4rvuls7Hm3MtMqK3PNmjXlniu4fvqVOjrxHUDFWxfqmEa9dytonEwHB8F9QtdHahkQRtdVvvk85woAqFr1qvjzgSoRvDlK1B28HxYcvzLTomJ0g6PaN/6CUk9Pg/yyTbQelM2mmzRdzOviE4ktW7bM1bVTb55XXHFF0ovLL9tk9gm/7vQdKEvbqg+OKcip3hJVu1D7gG501GxQte9Ug021o9TDaOx6SHZdsE+U789//rPr6fCtt95ywWbVhPS9gr7yyisuuKbfK+BZ0X3CZ6Fof8j0c4Uyih977DH3/rTTTivzsCSZ433sMcYHBSozbSZSsFjrQ0GX2O26MtNy/WQVDlKqjq8yKNX7arxMtFtvvdWdu9XTaqKAZdjxSYH9YJ3gsP1CWbmcKwCgahFYQ60UrLWVqJmbbzIS7ICgMtMiebqQVCFrPSXVhd/1119fZhy/LsprquiHU2OtfApk6gL6b3/7W4VuLNXUtrx1EWyCxbpIrjmcgmrKTlNBah+I1M3WddddZ2PHjrWXXnrJFZhWQfjgekh2XbAeyqdldfvtt9tdd93lgpljxoyJDlMzXQXedEMb2wQ6mX0iuC4y+VyhYLKKratGnZopq6OIVI73wWNM2Hk7lWkzjTrh0MMV+b//+7/QTgpSnZbrp4pRwGzKlCmugw09QNGDwljTpk2zZ555Jul6qLHrItnjE+cKAKhaNAVFrRS8kPCZIWF8IergBXdlpkXFAjyqKaXlfeedd5bqZTJ2XSRaD8HhrIvy63q9/vrrrqfWk08+uUKbq+95Mpl9gnWRWPAYo+V67bXXlsruU3bBpZde6rKm5MUXXwydNpl1kUz9vEynDlKOOOIIu++++1wNtb322ssV9dar/n/vvfe6DiZii3sne3zK9HOFMpOV2aSHKGqSpt6Igz3Zxi7P4HEk2WNMZabNJGpy+Je//MXV3lKvkHqfzmm5fqpY5p86EFLwXg9S1GtuLB1bfEZbsvVQY9cFxycAqBnIWEOtpKaBnrJz4mXmqKOC2PErMy2Soyfejz/+uHs6qzpSe+yxR8L1mKgJlYqC+wwg1kV8ytBQj2K6iNeFetiT8US0bNUUJVFzkeB6Yl3EF+zJUxkfYUFl9W6o+nfqzCBY4DvYE1wy64L1kJiCLcoCUdBn0KBBdscdd1iLFi1K1YxS7Tv1rqfMNQU5/b7jl215TagyeV0oq0+ZTarpqM4fVMfOB4xTOd7HO8ZUZtpMoV62Tz31VLcchg8f7rKl4nVEk+q0weWaaF1k+vWTSgDceOON7r0CZvF6hh49erSrx1mReqieX7Za1grM6dwfxq+n8noZBQBUDoE11ErBGhQ//fST7brrrqHjaViwqHFlp0X51OTtgQcecBflavqw3377lbseVetIzePC6oSoUL5/Isu6iE89TarwvZqTqLew2B4K1dzEB+CeeOIJ937w4MHR7V/rQsXX9Rnx+H1CBdsz9YYpGVo2Ct4oaKPON+Jp06ZNmcCNtnHtB9oftLx9r5Px1kWy9Xgyeb/wRbt1PAoG1UT/1+8VTNAN7qRJk1xHE8HjjTpfUXA/7MZUN7Wq0ZaJ60JBS2U1ffLJJ+6YoAyd8joXmD59enTbDeOHBYuuV3baTKBj9x//+Ee3rQ4ZMsRliYdlDVZ22pYtW7rsW2UpJrMuMvGcreD8Nddc495ffPHFdtJJJ8Ud98knn4weO/x5OcxHH33k1o86UlHpAD+N6PpI10lhAW2dR3R9FRwfAFA1CKyhVtLNUKtWrVzPX7rY3meffcqMowBCsGe+dEyLxB588EFXx0hPTlVTTUXCE/H1W3SDNmvWLFebJ5bWkSjYoCaOiJ/Z55u9qdlJPAoQ+OG6+PeBNW3nakrql3cYP4x9onw9evRwN0PKAozHDwsGKRUUUJFrZZB89dVXdvjhh4feLPksN9ZFYuq0wBfw9oHMWPq9hitApiCDD6wFl63WRViPfvq9V5FaVrWdbuaV6aden3WzryZvnTt3TjiNluf48ePdthvvQYpfnjrWB4dXZtqtnZow/+EPf3DHk379+rkmz8k2ha3otDq3d+3a1WUo6nzw+9//PnS8TD1XaBu9/PLLXQaZag6efvrpSZ23de2UyAsvvOB+dG7wgTUdb7Q+9F1a3mGBNTXv9Q8mM21dAMCWRo011Fo+IKZu4f3FSdCbb77pLih0ga1aOumaFuHUG5yaj/imD0cffXS5i6p3796u+ZCod754F6oycODAUk3sUFrHjh3dk/F4Pz7zSTWl/O+C2ZrK2JElS5a4YsthAbl3333XvQ8LRqO0ESNGuFdlDirTIJaCA345x97w+HWh41BYTSllYfmmVn5chFOTW0nUq7CG6cfvH8F9Sj/y8ssvh07rj1sKpMZrArm10bZ74YUX2sSJE10W30MPPeT+/vL444aapmkbjqVtXdt8cNx0TLs1U2+PCowpY0kPprQuku2tOdVp/fLV+g921OLpuKbPDI6bCbT9qdMgXVMqoKYOUcqj66RE521Pndvo/z6oJuppWtdQyVw/qRxBvNYZAIA0iQA1yPr16yNdunRxPxMnTkw47qxZsyJdu3Z14959992lhi1ZsiQybNgwN+ziiy9O67SZYty4cW4ZdO/evdxxx44dG11vDz/8cIW+56677nLT9erVKzJ9+vRSwyZMmBD93DfffDOSqc455xy3DPSaqkceecR9xh577BE6vLi4OHLkkUe6cQ4//PDIunXrSg277LLL3LABAwZEVq1aFclU2h+0HLR/JKLlN3jwYDfupZde6pZh0EMPPRTdtt9+++1Sw+bPnx/p2bOnG3bjjTeWGrZixYrIyJEj3bA//elPkUyl84NffjpvxDNz5szoeDqeFxQUlBqu/1944YXRcebMmVNq+GOPPRY9Dn788celhk2ePDm6PTz77LORTFBUVBRdXn379o1MmTKlQtOPGjXKTatteOXKlaWG3XDDDW5Ynz593Hk4ndNujfR37rfffu7vPuywwyp0XK7MtEuXLnXrXtNefvnlpY5tOu7p8zTsxBNPjGTS8cgfs6+77rq0fa4/Lo0fPz50+IsvvuiG63o29jyi6yldV6VyXQYAqLgs/ZOuIB2Qiueff941vRRlid10003u/e9+97tST9gOPfRQ11wntufJMWPGuPdqvrP77ru77JBx48bZihUrXD0QfX5YJkFlpt0aLV++3PXi6c2YMcMtC9VKu+KKK6K/V9ZYsHnaBx984IqD61CiJiLHHXdc3O9QjY/YDEA98T7++OPt+++/d9ki+myNN3v2bHv11VddD2V66n3//fdbplAzm5kzZ0b//9xzz7mmsspsUm+Gnv4frwZXLNU/UjFlbdeqIxXmyy+/tFGjRrnsD/Xup6fjymBQZoLmSdTE99hjj7VMoUylYKaTjhvKSFAvkz5bQEaOHOmamAdp+1Vmj/YNZYTsu+++rqmnalKpCZ3sv//+ri5hLDWp9r9X3aM999zTZalpn1Q2iJrfqeMDn1G1tdM6CGaN6XihAuGiHlZ9TSg1Y4vdPtVk0R/bVANM2YTKlFXzt3feeccWLFjghh111FHRguOe9oUTTzzRHQ+17rRPdOrUyX744QeXJaLhOncoYzcTmh+qYxrV0BQVXFeHEPFoueicEKTjus7tqtGlOmjaj9QUWsck7RdyySWX2GmnnVbm8yoz7dbo4IMPdtuhtruzzjqrTP3AIG3bwWy0ykwbPJ9I//793Tla60X7qOp0avyxY8dmRPNoZSXrmKNjgZqVa/uL15GAlrOWfbL8/qP9Lqy0hs5Fqo/36aefuk5XNI6m0THtpZdectfWPXv2dMdKepAGgKpFYA3VTjeMCuqURzcxsTW21CTl5ptvdgGy2BixalHcfvvtcS/sKjPt1khBFQW4yqOg14QJE0IvsMujGzEVRo6l5ofnn39+tMB+0IEHHmg33HBDRvVopSa15dVcEfXipqYn6Qqs+UCpAhUKLgfpolyFmE8++WTLJNr+VHerPCpCrebKsRQIU00733TTU8BaQVI1mw674dExST1Y/ve//3XHqiAFPdXbbt++fS1TKAiQzA2pHr7oJjO250oFRBWgjl2WohvSE044we1LYUXbtS9cdNFFNnny5DLD1BRXnR80b97cMoF6TlUwMhnatsNqcKmpoJanjvux60F1qdTLaDyVmXZrExu0TET1M4MPCSszradjk87nvoaXpwcM//rXv9wDgUzw9ttvu048kqEgl2qlpSuw5h866NilB2Cx1EHRbbfdFi25AQCoOgTWUO10wRB70xlGT1VjM0KCBXh1UaGLbT0pVXaIalIk0ytWZabdmqgXLxWgLo+euOoGxtNNrK9tUx5lWMXLdlIgQTdNn3/+ubtQVP0QrYdMLLirG9dEwS9vjz32SNjrapBfT8rwuOCCCxKOq8wD7RPKClLGoHp20/coKJdp7r333qQC/6ecckrcHvBUF0rrVAE6nw04bNiwhD0oBusgaVq9ql6YalkpwzbTsg+0DrQuyqNjuAIvYZTpp/1KHXxoG9e4eoiihzvJ3Hgqa1P7kTo5UC+Y6sygT58+lkmeeeaZMj0Ox3PQQQe5rLUwCnaqZqM+y+8TOsYk05tnZabdmlx77bVJj3veeee5LNd0TBukTqAUWNL1g66ZFAhSRqivbZgJtB1qv0iGeokur0ODIL+eVItNQblE1LGHgv/qjVqBfu17ymiPlz0HAEgvAmsAAAAAAABACugVFAAAAAAAAEgBgTUAAAAAAAAgBQTWAAAAAAAAgBQQWAMAAAAAAABSQGANAAAAAAAASAGBNQAAAAAAACAFBNYAAAAAAACAFBBYAwAAAAAAAFJAYA0AAAAAAABIAYE1AAAAAAAAIAUE1gAAAAAAAIAUEFgDAAAAAAAAUlAvlYkAAEBqIpGIvfPOO+59nz59rFWrVhm5KPPy8uzDDz907wcOHGjbbLNNlX7fnDlzbN68ebbzzjvbLrvsYrXJ0qVL7auvvrIWLVpY//79q3t2AAAAEJAV0RU+AABp8sEHH1h+fr5tv/321rdv37jjLVy40GbPnu3ed+zY0Tp37hx33G+//dZ++ukn937EiBFWp07tTbguLCy0nj17uvf33HOP7bfffpaJlixZYnvvvbd7/+STT7rgWlXR9njQQQfZokWLbNy4cdatWzerTdavX2/77ruvbdy40V577TVr3759dc8SAAAANiNjDQCQVqNHj7ZPPvnEdtppJ3vrrbfijvfggw/a008/7d7vs88+dv/998cd95///Kd99tln7jMzNRBVWxQUFNj777/v3u+2227WsmXL6p4le+SRR1xgVsG12hZUkyZNmthpp51mt912m9166612xx13VPcsAQAAYLPa+8gfAFAjDRo0yL3Onz/ffvnll7jjTZkyJfr+888/t+Li4rjZRtOnTy/12ai5cnNz7S9/+Yv7mTFjRnXPjq1cudIeeOAB9/7ss8+22ur3v/+9ay77xhtv2JdfflndswMAAIDNCKwBANJq8ODB0ffKMguzYsUK+/HHHy07O9vatWtn69ats1mzZoWOq6Ca6nEJgTVU1HPPPWcbNmywfv36JWxuXNM1atTIDj74YPf+scceq+7ZAQAAwGYE1gAAaaWC/A0aNCiTlRbkA26qwTZ06NCkxo0N2gHlURbkU0895d4fccQRtX6BHX744e51woQJtnz58uqeHQAAAFBjDQCQbspCU20t1Vn79NNPEwbLVLBeHRc8++yzbtw//vGPccfVeDvssEOZ4Zs2bbLFixe7ZqfqGEA1vXbddVerVy+8jOjMmTPt559/tsaNG0eDevGo2L3PpNtrr73c3xarqKjIvv/+exfoqFu3rsvAS1dx+Yp+dryeNtVRwNy5cy0rK8vVGEu2B05lFWoZNG3a1GV7qdaXqImnlrd6NFUgNdikV+vCU5NF/Q1Bw4YNs5ycnLjfmeq8hvn444/d/KuziwMPPLBKllm86fW9ag6tZdelS5fQv1nNVLWMVZeua9eurtfPRLRftW3bNtoJw+mnn17BJQIAAIB0o/MCAEDaqcmmAmvz5s2zpUuXugBMkM9O03gKmMnUqVNdhlGwx0/VV/P1pGKbgT7//POuh0QF5BSYCFLQ7JhjjrHzzjvPNaGL7WH08ssvd4Gq9957r8y8Bd1+++328ssvu0BdbKcJa9ascR0uqKnh2rVrSw1TIOXCCy+04cOHWypS/exVq1a52ma+p81OnTrZlVdeaRMnTjTfCbj+bmVvXXXVVdHMwlhad9dff70L6gWX6amnnuo+/+GHH3bL/oADDrA777wzOo6K60+bNi36/7AOKdSxQevWrUODTKnMayIfffSRe91ll13iBsYqu8xip1fw87LLLrPJkydHx1EPuddcc010G1Ivn+qQQ9uWgsH+O0488UQ3rd7H079/fxdYmzRpEoE1AACAGoDA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",
"text/plain": [
- ""
+ ""
]
},
"metadata": {},
@@ -378,27 +387,18 @@
}
],
"source": [
- "a = net.reactions.get(\n",
- " \"H -> H+ + e-\", rtype=\"photo\"\n",
+ "a1 = net.reactions.get(\n",
+ " \"H + _PHOTON -> H+ + e-\", type=\"photo\"\n",
") # get reaction by verbatim name\n",
- "a.plot_xsecs(processes=\"photo_ionization\")\n",
+ "a.plot_xsecs(processes=\"photodecay\", shade=True, xsecs_log=False)\n",
"\n",
- "a = net.reactions.get(\"CH2 -> CH + H\") # get photodissociation reaction\n",
- "_ = a.plot_xsecs(energy_unit=\"nm\", energy_log=False, xsecs_log=False, processes=\"photo_dissociation\") # plot cross-sections"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Code generation\n",
- "\n",
- "Jaff also comes with code generation capabilities and comes with an in-built templating system to generate langauge specific code"
+ "a2 = net.reactions.get(\"CH2 + _PHOTON -> CH + H\") # get photodissociation reaction\n",
+ "_ = a.plot_xsecs(energy_unit=\"nm\", energy_log=False, xsecs_log=False, processes=[\"photodecay\", \"photo_absorption\"], shade=True) # plot cross-sections"
]
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": 20,
"metadata": {},
"outputs": [
{
@@ -409,35 +409,35 @@
"░░█ ▄▀█ █▀▀ █▀▀ █▀▀ █▀▀ █▄░█\n",
"█▄█ █▀█ █▀░ █▀░ █▄█ ██▄ █░▀█\n",
"\n",
- "Just Another Fragile Format Generator!\n",
+ "Just Another Fortunate Format Generator!\n",
"\n",
"WARNING No output directory has been supplied.\n",
- "WARNING Files will be generated at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO Loading network from /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/networks/demos/demo1.jet\n",
+ "WARNING Files will be generated at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO Loading network from /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/networks/demos/demo1.jet\n",
"INFO Network label: demo1\n",
"INFO Variables found: av, crate, tgas\n",
"INFO Loaded 15 reactions\n",
"INFO Loaded 2 photo-chemistry reactions\n",
"WARNING Found undefined functions photorates\n",
- "INFO Sink: H2\n",
"INFO Sink: N2+\n",
"INFO Sink: CH2\n",
+ "INFO Sink: H2\n",
"INFO Source: CH\n",
"WARNING Sink detected\n",
"WARNING Source detected\n",
"WARNING Electron recombination not found for N2+\n",
"INFO Network loaded successfully!\n",
- "INFO commons.f90 created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO fluxes.f90 created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO ode.f90 created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO reactions.f90 created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO Makefile created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO main.f90 created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO opkda1.f created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO opkda2.f created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
- "INFO opkdmain.f created at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n",
+ "INFO commons.f90 created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO fluxes.f90 created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO ode.f90 created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO reactions.f90 created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO Makefile created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO main.f90 created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO opkda1.f created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO opkda2.f created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
+ "INFO opkdmain.f created at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n",
"INFO Successfully generated files\n",
- "INFO Generated files can be found at /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/generated\n"
+ "INFO Generated files can be found at /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/generated\n"
]
}
],
@@ -454,7 +454,7 @@
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": 21,
"metadata": {},
"outputs": [
{
@@ -465,16 +465,16 @@
"░░█ ▄▀█ █▀▀ █▀▀\n",
"█▄█ █▀█ █▀░ █▀░\n",
"\n",
- "Just Another Flashy Format!\n",
+ "Just Another Fantastic Format!\n",
"\n",
- "INFO Loading network from /home/anish/External/programming/research/jaff/worktrees/feature_photo_dissociation/networks/demos/demo2.jet\n",
+ "INFO Loading network from /home/anish/External/programming/research/jaff/worktrees/bug_fix_photo_reaction_detection/networks/demos/demo2.jet\n",
"INFO Network label: very small network\n"
]
},
{
"data": {
"application/vnd.jupyter.widget-view+json": {
- "model_id": "d5382a4c6f804cce83b1737558c6d4c3",
+ "model_id": "4fe415b76e3d45adbeadcd7ebebf1ee7",
"version_major": 2,
"version_minor": 0
},
@@ -498,7 +498,7 @@
{
"data": {
"application/vnd.jupyter.widget-view+json": {
- "model_id": "ac6ca078ed8a448d9830ca28013cee57",
+ "model_id": "bc1a4b8f8cae4dde9282e52e7b053d6e",
"version_major": 2,
"version_minor": 0
},
@@ -536,7 +536,7 @@
},
{
"cell_type": "code",
- "execution_count": 11,
+ "execution_count": 22,
"metadata": {},
"outputs": [
{
@@ -548,17 +548,18 @@
" \n",
"\n",
"INFO Reactions not present in very small network:\n",
- "H -> H+ + e-\n",
- "C+ + e- -> C\n",
- "N2 -> N + N\n",
- "CO -> C + O\n",
- "CH2 -> CH + H\n",
- "C -> C+ + e-\n",
"H2 + e- -> H + H + e-\n",
+ "H+ + e- -> H\n",
"C + O -> CO\n",
+ "N2 + _CR -> N + N\n",
"N + N -> N2\n",
- "H+ + e- -> H\n",
- "CO + N2+ -> N2 + CO+ \n",
+ "C -> C+ + e-\n",
+ "CO + N2+ -> N2 + CO+\n",
+ "H -> H+ + e-\n",
+ "H + _PHOTON -> H+ + e-\n",
+ "CH2 + _PHOTON -> CH + H\n",
+ "C+ + e- -> C\n",
+ "CO -> C + O \n",
"\n",
"INFO Reactions present in both demo1 and very small network:\n",
"CO+ + e- -> CO\n",
@@ -566,7 +567,7 @@
"\n",
"INFO 2 reactions are common in both networks\n",
"INFO 0 reactions are missing in \"demo1\"\n",
- "INFO 11 reactions are missing in \"very small network\"\n"
+ "INFO 12 reactions are missing in \"very small network\"\n"
]
}
],
@@ -576,7 +577,7 @@
},
{
"cell_type": "code",
- "execution_count": 12,
+ "execution_count": 23,
"metadata": {},
"outputs": [
{
@@ -588,10 +589,10 @@
" \n",
"\n",
"INFO Species not present in very small network:\n",
- "H, N2, H2, CH2, N, N2+, CH, C, O, H+, C+ \n",
+ "H, N2+, CH2, H2, C, N, N2, H+, C+, O, CH \n",
"\n",
"INFO Species present in both demo1 and very small network:\n",
- "CO, e-, CO+ \n",
+ "e-, CO+, CO \n",
"\n",
"INFO 3 species are common in both networks\n",
"INFO 0 species are missing in \"demo1\"\n",
@@ -612,14 +613,14 @@
},
{
"cell_type": "code",
- "execution_count": 14,
+ "execution_count": 24,
"metadata": {},
"outputs": [
{
"data": {
- "image/png": "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",
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAA6UAAAI9CAYAAADCY97cAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAXEgAAFxIBZ5/SUgAAfpdJREFUeJzt3Qd829W9//+3bEvyyE6cvffeCTMhq2wKpIt1WygdQKGlk9JSenv/7W1/veXSFi4FSmmBtmwokDITsskgzt5k7x3HiYf2/3GOkWM5duwk+lpf2a/n4+GHZOlr6UhOjr9vnXM+xxOLxWICAAAAACAFMlLxpAAAAAAAGIRSAAAAAEDKEEoBAAAAAClDKAUAAAAApAyhFAAAAACQMoRSAAAAAEDKEEoBAAAAAClDKAUAAAAApAyhFAAAAACQMoRSAAAAAEDKEEoBAAAAAClDKAUAAAAApAyhFAAAAACQMoRSAAAcMnfuXH3xi1/UgQMHeI8BAKgBoRQAAIccOXJEK1asUDAY5D0GAKAGhFIAAAAAQMoQSgEAAAAAKUMoBQA0Wr/+9a81evRoXXTRRXU6/rvf/a49/vrrr3e8bXCnwsJC+2/AfP3lL3+p9fgNGzZUHP/WW2/VSxsBIN1kpboBAACkSmlpqY4fP65AIFCn40tKSuzxJ06cOOW+NWvW6Be/+EXCbUePHrWXd999t3w+X8J9zz//vDIzM9WQPPfcc3UOXsOHD9dPfvITpZtoNGr/DRh1+XdT+fhQKOR4+wAgHRFKAQBIAhNUTVGj6qxbt+6U22KxWIN73/fs2VPje1BV06ZNHW8PACA9EEoBAEiCQYMG6cUXX0y4bd68eXrkkUf06KOPKj8/P/EPcNa5/wnev3+/rrrqKnt90aJFKR95/Y//+A9ddtlldTqWUAoAiCOUAgCQBE2aNLFTUivbvn27vRwwYIA6d+6c9PfZjLbGp4a6YeS1Y8eO9gsAgDNBKAUAAAkikYjeeecdvf/++7ZQT1FRkR3ZHDhwoKZOnarx48fzjgEAkoZQCgAAKuzevdsWZlq7dm3Cu3LkyBE78mvC6rXXXqtf/epX8nq9afHO7d27Vw8++KDuv/9+9ezZM9XNAQBUwZYwAADAOnbsmL785S/bQJqbm6t7771Xb775phYsWGCr6n7961+361bfeOMNPfTQQ2nzrpmqyHPmzNGNN96oZcuWpbo5AIAqGCkFADR6wWDQ7iNZly1kzsS4ceNs8aO2bdumxXv8hz/8Qbt27bIjoM8884yGDh1acV+rVq30gx/8QN26ddMDDzygZ599Vrfccosja2WTzQTob3/727bw1K233qqHH35YkyZNOufHffzxx/X000/XOhUaAHB6jJQCACDZgkG1fYXD4TN6r0yQM8WPqu5R6kZmz81XX33VXv/KV76SEEgr+8IXvqAePXpUrDtNB3l5eTZAXn/99SorK7PTk19++eWkvGe1/Zsxe9sCAE6PkVIAQKNnQuP8+fNrfR+++93v2tG2+mQKDd18883V3le54u75559f42O89957at269WmfZ+XKlTawGbUVMhoxYoS2bt2qNWvWqL49+eST9utcmEBtRnvNtjwmqJ6tO+64Q7fffvtpj9m4cWONvz8AQDlCKQAAkpo1a1br+5CMvUXPVDQardj25XROd4x5jLoUA4q755575PF4qg3A5jIeXo8ePar6Fh+dTNYa2nPh9/tr/XdjRmkBAKdHKAUAwMX69u2rjz/+uNr7Dhw4oKuuuspeX7hwoS1CVB2znUttQqHQGYc1sxa3vn3jG9+w04vPxh//+Ec999xzFaOcZn0pACD1CKUAALiYCZo1jcZVXq9ogue5jOS2adOm4vrbb7+t/Pz8OrWtvpnRSfN1JsxaYLMljFkzm5GRYafuMqUWANyDUAoAAGxBJhMyzXpLs770XNZaus0Pf/hDG7RNmP3d736nSy+9NNVNAgBUQvVdAACg5s2b67LLLquY5nro0KEG866YKb+9e/e227cQSAHAfQilAACgYkTRTOPds2ePpk6dqpdeesmuW6281tRUk/373/9uq86movru2RgwYICmTZtWp71oAQD1j+m7AADA6tixo/72t7/ZfTy3bdumn/3sZxVb5piqu5WLIcWr9KaLqtWEAQDuQSgFAAAV+vTpozfffFOvvfaa3nnnHa1atcoWVDKhrkWLFmrfvr3Gjh2rSy65RIMGDeKdAwCcM0+s8s7bAAA0Ima/TbOtiQlcddk2xYQzU8nVVHBt0qSJUs38CY/v2VmXfVbPVvw98nq9auwqv+fZ2dl2FPl0TOGo4uLiOh8PAI0RoRQAAAAAkDIUOgIAAAAApAyhFAAAAACQMoRSAAAAAEDKEEoBAAAAAClDKAUAAAAApAyhFAAA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zp3Vv39/eTwevfnmm3r33Xf185//XDfddBO/JABpyxMzH7EBAOASq1at0uc///mz/vk+ffpo2rRpSW0Tzs6JEye0cOFCe/28885T06ZNG81buXHjRl133XX2Nc+YMcOOllb205/+VK+88opyc3O1bNmyGh/nxRdf1H/+538qGo3aMPr000+rdevW9r5NmzbpmmuuUYsWLfT+++83qvcXQMPCSCkAwFWaN2+uyZMnV3vf9u3b7Ym4cfHFF8vv959yTMeOHR1vI+pmz549+ta3vmWv/+tf/9KAAQMazVv329/+VpFIRF/96ldPCaR19Ze//MU+jjFixAg9+eSTatasWcX9vXv31uWXX663335bTzzxhH7wgx8krf0AUJ8IpQAAV+natasee+yxau8zJ+UPPfSQvf6rX/1K7du3r+fWAbXbvHmz5s6da6fmTp069azesj/84Q8V/w8uvPBC/d///Z8dVa3KzCowodSMqN59993Kzs7mVwQg7VB9FwAAIImef/75ijCZn59/Rj9rVlX98pe/rAikU6ZMsaOg1QVSw6yfbteunV2L/e9//zsJrQeA+sdIKQCgQQqFQnZd3+HDh+Xz+dSjRw978l5bpVTjoosuUk5Ojr2+bds27dq1y67jM9MlvV7vKT974MABbd261V4fNGhQjdM1a3oO87Om6I0JHmaKa/z2+nyd5vl37Nih4uJijR8/3j5WZeb2vXv3av/+/fb7tm3bqlevXsrIqP7z7Xnz5mnLli0V3y9atMg+R2UmcMWn+a5du9Y+1qRJk2psuymAZcJXt27d7Nrhc31N8WPM7zcQCNgA2bdv33OqZGt+H2+88Ya9ftVVV53Rz5rpvg888IBee+01+71ZL/qb3/xGWVk1n66Z9+yyyy7Ts88+q5dfflmf+9znzrrtAJAqhFIAQINiQpOZ6vjWW2+ppKQk4b7hw4frRz/6kUaNGnXKz5lgGF//aArTlJWV6f7779fKlSsrjjFhyEwbHjNmjP3eVET9xS9+oenTp9sRLsMEnzvuuKPisU73HKYQkCl4s3r16opjTKC65ZZb9O1vf7vaEJXs12nC2IMPPqglS5ZUHGOmnprQafztb3+zr2/p0qU2NFVmCuyYtprXWzWsm8esHEJ//etfn9KWDRs22EtTDMm81+b1mkJXNTEBbc2aNXad5n333XfWr8n8rl599VU9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",
"text/plain": [
- ""
+ ""
]
},
"metadata": {},
@@ -630,6 +631,13 @@
"net.reactions[0].rate # verbatim name of first reaction in COthin network\n",
"_ = net.reactions[0].plot_rate_coefficient()"
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
}
],
"metadata": {
@@ -648,7 +656,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.14.5"
+ "version": "3.14.6"
}
},
"nbformat": 4,
diff --git a/pyproject.toml b/pyproject.toml
index 424152f0..f85c4041 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,7 @@ dependencies = [
"pygments>=2.19.2",
"rich>=15.0.0",
"scipy>=1.13.0",
+ "seaborn>=0.13",
"sympy>=1.14.0",
]
diff --git a/src/jaff/_utils/add_shielding_column.py b/src/jaff/_utils/add_shielding_column.py
deleted file mode 100644
index 4a53c7c2..00000000
--- a/src/jaff/_utils/add_shielding_column.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""Add the ``shielding`` column to ``photo_reaction_cross_sections`` in ``jaff.db``.
-
-Adds a single ``TEXT`` column named ``shielding`` to the existing
-``photo_reaction_cross_sections`` table. No default is supplied, so every
-existing row receives ``NULL`` for the new column.
-
-The operation is idempotent: if the column already exists the script logs a
-message and exits without modifying the table.
-"""
-
-from jaff.drivers.sqlite import JaffDb
-from jaff.io import JaffLogger
-
-#: Target table and the column to add.
-TABLE: str = "photo_reaction_cross_sections"
-COLUMN: str = "shielding"
-COLUMN_TYPE: str = "TEXT"
-
-
-def main() -> None:
- logger = JaffLogger().get_logger()
-
- with JaffDb() as jdb:
- # Guard: skip if the column is already present (idempotent rerun).
- existing = jdb.query(
- f"SELECT 1 FROM pragma_table_info('{TABLE}') WHERE name = '{COLUMN}'"
- )
- if existing:
- logger.info(
- f"'{COLUMN}' column already exists in '{TABLE}'; nothing to do\n"
- )
- return
-
- table = jdb.table(TABLE)
- table.add_column(COLUMN, COLUMN_TYPE) # default=None -> existing rows NULL
- logger.info(
- f"'{COLUMN}' column added to '{TABLE}' in {jdb.db_path}\n"
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/jaff/_utils/build_shielding_hdf5.py b/src/jaff/_utils/build_shielding_hdf5.py
deleted file mode 100644
index e14c773a..00000000
--- a/src/jaff/_utils/build_shielding_hdf5.py
+++ /dev/null
@@ -1,250 +0,0 @@
-"""Collapse the per-species line-shielding tables into a single HDF5 file.
-
-``line_shielding_functions//_`` holds the Leiden
-shielding functions (https://home.strw.leidenuniv.nl/~ewine/photo/), one plain
-text table per species, photo-channel and radiation field. This utility merges
-them into ``data/shielding/leiden.hdf5`` with **one group per reaction**, keyed
-exactly like ``data/xsecs/leiden.hdf5`` (e.g. ``"CO__C_O"``).
-
-Schema
-------
-Root attrs: ``database`` ("leiden"), ``description``, ``created``.
-
-::
-
- /
- attrs: reactants, products, cross_section ("photodissociation" |
- "photoionisation")
- N dataset (cm^-2), shared by all radfields
- / e.g. "ISRF", "Ly-alpha", "bb-10000"
- attrs: radiation_field, and (HF only) wavelength_begin/end/step
- (nm), unshielded_rate (s^-1)
- one shielding-factor dataset per source
- column (H2, H, self, C, N2, CO, ... or the
- Zn variant H2, H, dust, self, combined)
-
-Reaction keys
--------------
-The ``N`` (column-density) grid is identical across a reaction's radfields, so
-it is stored once at group level (verified equal, else the build aborts).
-Columns that are entirely NaN (some ``*_Lyalpha`` tables) are omitted.
-
-- **photodissociation** -> the Leiden reaction whose sole reactant is the
- species (a dissociation channel, e.g. ``CO__C_O``).
-- **photoionisation** -> the Leiden reaction if Leiden keyed that species by its
- ionisation channel (e.g. ``Al__Al+_e-``, ``Ca+__Ca++_e-``, ``H-__H_e-``);
- otherwise the key is constructed as ``"___e-"`` (neutral ``X`` -> ``X+``,
- cation ``X+`` -> ``X++``, anion ``X-`` -> neutral ``X``).
-
-Values are copied verbatim from the source tables -- no numerical transform.
-"""
-
-from __future__ import annotations
-
-from datetime import date
-from pathlib import Path
-
-import h5py
-import numpy as np
-
-from jaff.io import JaffLogger
-
-_STR_DT = h5py.string_dtype(encoding="utf-8")
-
-#: Lossless dataset compression (gzip level 4 + chunking), matching the
-#: ``collapse_xsecs_hdf5`` convention.
-COMPRESSION_KW: dict = {"compression": "gzip", "compression_opts": 4, "chunks": True}
-
-#: Source radfield token -> output subgroup/attr name. Blackbody temperatures
-#: become ``bb-``; year-suffixed parametrizations get a hyphen; ``Lyalpha``
-#: becomes ``Ly-alpha``. Unlisted fields (ISRF, solar, TW-Hya) are unchanged.
-_RADFIELD_RENAME: dict[str, str] = {
- "4000K": "bb-4000",
- "10000K": "bb-10000",
- "20000K": "bb-20000",
- "Lyalpha": "Ly-alpha",
- "mathis1983": "mathis-1983",
- "habing1968": "habing-1968",
- "gondhalekar1980": "gondhalekar-1980",
-}
-
-#: HF tables carry extra ``# key: value`` provenance lines -> radfield attrs.
-_HF_META_KEYS: dict[str, str] = {
- "wavelength_begin (nm)": "wavelength_begin",
- "wavelength_end (nm)": "wavelength_end",
- "wavelength_step (nm)": "wavelength_step",
- "unshielded_rate (s-1)": "unshielded_rate",
-}
-
-
-def _ionize(species: str) -> str:
- """Singly-ionise a species name: ``X`` -> ``X+``, ``X+`` -> ``X++``,
- anion ``X-`` -> neutral ``X`` (photodetachment)."""
- if species.endswith("-"):
- return species[:-1]
- return species + "+"
-
-
-def _load_leiden_map(leiden_h5: Path) -> dict[str, dict]:
- """Map each single-reactant species to its Leiden reaction key/products."""
- out: dict[str, dict] = {}
- with h5py.File(leiden_h5, "r") as f:
- for key, grp in f.items():
- reactants = [r.decode() if isinstance(r, bytes) else str(r)
- for r in grp.attrs["reactants"]]
- products = [p.decode() if isinstance(p, bytes) else str(p)
- for p in grp.attrs["products"]]
- if len(reactants) == 1:
- out[reactants[0]] = {
- "key": key,
- "reactants": reactants,
- "products": products,
- "is_ionisation": "e-" in products,
- }
- return out
-
-
-def _resolve_reaction(
- species: str, channel: str, leiden_map: dict[str, dict]
-) -> tuple[str, list[str], list[str]]:
- """Return ``(reaction_key, reactants, products)`` for a species + channel."""
- entry = leiden_map.get(species)
- if channel == "photodissociation":
- if entry is None or entry["is_ionisation"]:
- raise ValueError(
- f"no Leiden dissociation reaction for {species!r} "
- f"(Leiden entry: {entry['key'] if entry else None})"
- )
- return entry["key"], entry["reactants"], entry["products"]
-
- # photoionisation
- if entry is not None and entry["is_ionisation"]:
- return entry["key"], entry["reactants"], entry["products"]
- ion = _ionize(species)
- return f"{species}__{ion}_e-", [species], [ion, "e-"]
-
-
-def _parse_table(path: Path) -> tuple[list[str], np.ndarray, dict]:
- """Parse one shielding file -> ``(column_names, data, hf_meta)``.
-
- ``column_names[0]`` is ``"N"``; the rest are shielding species. ``data`` is
- shaped ``(n_rows, n_cols)``. ``hf_meta`` holds any HF provenance attrs.
- """
- lines = path.read_text().splitlines()
- hf_meta: dict = {}
- header: str | None = None
- for ln in lines:
- if ln.lstrip().startswith("#"):
- body = ln.lstrip().lstrip("#").strip()
- # the column header is the last comment line before the data
- if body.split()[:1] == ["N"]:
- header = body
- for raw_key, attr in _HF_META_KEYS.items():
- if body.startswith(raw_key):
- hf_meta[attr] = float(body.split(":", 1)[1])
- if header is None:
- raise ValueError(f"no column header found in {path}")
- columns = header.split()
-
- data = np.genfromtxt(path, comments="#")
- if data.shape[1] != len(columns):
- raise ValueError(
- f"{path}: header has {len(columns)} columns but data has "
- f"{data.shape[1]}"
- )
- return columns, data, hf_meta
-
-
-def build_shielding(src_dir: Path, leiden_h5: Path, out_path: Path, logger) -> int:
- """Merge the per-species shielding tables into a single HDF5 file."""
- leiden_map = _load_leiden_map(leiden_h5)
-
- # reaction_key -> {reactants, products, cross_section, radfields:{name:{N,cols,meta}}}
- reactions: dict[str, dict] = {}
- n_files = 0
- for species_dir in sorted(p for p in src_dir.iterdir() if p.is_dir()):
- species = species_dir.name
- for f in sorted(species_dir.iterdir()):
- channel, _, radfield = f.name.partition("_")
- radfield = _RADFIELD_RENAME.get(radfield, radfield)
- key, reactants, products = _resolve_reaction(species, channel, leiden_map)
- columns, data, hf_meta = _parse_table(f)
-
- cols = {
- name: data[:, i]
- for i, name in enumerate(columns[1:], start=1)
- if not np.isnan(data[:, i]).all() # drop all-NaN columns
- }
- entry = reactions.setdefault(
- key,
- {
- "reactants": reactants,
- "products": products,
- "cross_section": channel,
- "radfields": {},
- },
- )
- if radfield in entry["radfields"]:
- raise ValueError(f"duplicate {radfield!r} for reaction {key!r}")
- entry["radfields"][radfield] = {
- "N": data[:, 0],
- "cols": cols,
- "meta": hf_meta,
- }
- n_files += 1
-
- out_path.parent.mkdir(parents=True, exist_ok=True)
- with h5py.File(out_path, "w") as h5:
- h5.attrs["database"] = "leiden"
- h5.attrs["description"] = (
- "Leiden line-shielding functions, one group per reaction (keyed as in "
- "xsecs/leiden.hdf5); shielding factors are dimensionless, column "
- "density N in cm^-2."
- )
- h5.attrs["created"] = date.today().isoformat()
-
- for key in sorted(reactions):
- entry = reactions[key]
- grp = h5.create_group(key)
- grp.attrs["reactants"] = np.array(entry["reactants"], dtype=_STR_DT)
- grp.attrs["products"] = np.array(entry["products"], dtype=_STR_DT)
- grp.attrs["cross_section"] = entry["cross_section"]
-
- radfields = entry["radfields"]
- # N is shared across radfields -> verify identical, store once.
- ref_N = next(iter(radfields.values()))["N"]
- for name, rf in radfields.items():
- if not np.array_equal(rf["N"], ref_N):
- raise ValueError(
- f"reaction {key!r}: radfield {name!r} has a different N grid"
- )
- n_ds = grp.create_dataset("N", data=ref_N, **COMPRESSION_KW)
- n_ds.attrs["unit"] = "cm-2"
-
- for radfield in sorted(radfields):
- rf = radfields[radfield]
- rgrp = grp.create_group(radfield)
- rgrp.attrs["radiation_field"] = radfield
- for attr, val in rf["meta"].items():
- rgrp.attrs[attr] = val
- for name, arr in rf["cols"].items():
- rgrp.create_dataset(name, data=arr, **COMPRESSION_KW)
-
- logger.info(
- f"Wrote {len(reactions)} reactions ({n_files} source files) to {out_path}"
- )
- return len(reactions)
-
-
-def main() -> None:
- """Build ``data/shielding/leiden.hdf5`` from ``line_shielding_functions/``."""
- logger = JaffLogger().get_logger()
- repo = Path(__file__).parent.parent.parent.parent
- src_dir = repo / "line_shielding_functions"
- leiden_h5 = Path(__file__).parent.parent / "data" / "xsecs" / "leiden.hdf5"
- out_path = Path(__file__).parent.parent / "data" / "shielding" / "leiden.hdf5"
- build_shielding(src_dir, leiden_h5, out_path, logger)
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/jaff/_utils/build_shielding_table.py b/src/jaff/_utils/build_shielding_table.py
deleted file mode 100644
index fc5a8d1b..00000000
--- a/src/jaff/_utils/build_shielding_table.py
+++ /dev/null
@@ -1,92 +0,0 @@
-"""Build the ``photo_reaction_shielding`` table in ``jaff.db``.
-
-One row per reaction that has shielding data, indexing the shielding handlers
-available for it. Two TEXT columns hold JSON arrays of handler keywords:
-
-- ``global`` -- names of the shielding HDF5 files (``data/shielding/*.hdf5``)
- that contain the reaction as a group, e.g. ``["leiden"]``. A global handler
- script (``physics/photo_reactions/shielding/.py``) builds the HDF5
- path from the reaction key itself.
-- ``local`` -- stems of the reaction-specific scripts under
- ``physics/photo_reactions/shielding//*.py`` (lower-cased), e.g.
- ``["db1996", "hg2015"]``.
-
-A keyword is exactly a (lower-cased) file stem, so the TOML ``type`` selects a
-handler by name with no stored paths. Rows are the union of reactions found in
-the shielding HDF5 files and the local script folders; reactions absent from
-both get no row (``S = 1`` no-op at runtime).
-
-This table is a regenerable index of the filesystem + HDF5 -- rerun after adding
-a shielding file or script. ``reaction`` is the primary key; no foreign key is
-declared because a few shielded reactions (e.g. ``HCl+``/``SH+`` ionisation)
-legitimately have no cross-section entry.
-"""
-
-import json
-from pathlib import Path
-
-import h5py
-import pandas as pd
-
-from jaff.drivers.sqlite import JaffDb
-from jaff.io import JaffLogger
-
-#: Package root (``src/jaff``).
-PKG_ROOT: Path = Path(__file__).parent.parent
-#: Directory of global (tabulated) shielding HDF5 files.
-SHIELDING_DATA_DIR: Path = PKG_ROOT / "data" / "shielding"
-#: Directory of shielding handler scripts (global files + per-reaction folders).
-SHIELDING_SCRIPT_DIR: Path = PKG_ROOT / "physics" / "photo_reactions" / "shielding"
-
-TABLE: str = "photo_reaction_shielding"
-
-
-def collect_global() -> dict[str, list[str]]:
- """Map each reaction to the shielding HDF5 stems that contain it."""
- out: dict[str, list[str]] = {}
- for h5_path in sorted(SHIELDING_DATA_DIR.glob("*.hdf5")):
- keyword = h5_path.stem.lower()
- with h5py.File(h5_path, "r") as f:
- for reaction in f:
- out.setdefault(reaction, []).append(keyword)
- return out
-
-
-def collect_local() -> dict[str, list[str]]:
- """Map each reaction to its per-reaction shielding script stems."""
- out: dict[str, list[str]] = {}
- for folder in sorted(p for p in SHIELDING_SCRIPT_DIR.iterdir() if p.is_dir()):
- if folder.name.startswith("_") or folder.name == "__pycache__":
- continue
- stems = sorted(
- f.stem.lower()
- for f in folder.glob("*.py")
- if not f.name.startswith("_")
- )
- if stems:
- out[folder.name] = stems
- return out
-
-
-def main() -> None:
- logger = JaffLogger().get_logger()
- global_map = collect_global()
- local_map = collect_local()
-
- reactions = sorted(set(global_map) | set(local_map))
- df = pd.DataFrame(
- {
- "reaction": reactions,
- "global": [json.dumps(sorted(global_map.get(r, []))) for r in reactions],
- "local": [json.dumps(local_map.get(r, [])) for r in reactions],
- }
- ).set_index("reaction")
-
- with JaffDb() as jdb:
- table = jdb.table_from_dataframe(TABLE, df)
- logger.info(f"'{TABLE}' table created in {jdb.db_path} ({len(df)} rows)\n")
- print(pd.DataFrame(table.all_rows()))
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/jaff/_utils/collapse_xsecs_hdf5.py b/src/jaff/_utils/collapse_xsecs_hdf5.py
index 66f934fa..96d3bebf 100644
--- a/src/jaff/_utils/collapse_xsecs_hdf5.py
+++ b/src/jaff/_utils/collapse_xsecs_hdf5.py
@@ -3,7 +3,7 @@
The Leiden (``data/xsecs/leiden/*.h5``) and NORAD/OP (``data/xsecs/op/*.dat``)
folders each hold one file per reaction. This utility merges each folder into a
single HDF5 file -- ``data/xsecs/leiden.h5`` and ``data/xsecs/op.h5`` -- with one
-group per reaction (group name = the serialized stem, e.g. ``"CH__C_H"``).
+group per reaction (group name = the serialized stem, e.g. ``"CH__C.H"``).
Schema
------
@@ -20,8 +20,8 @@
Each source Leiden file bundles both decay channels, so it is split into one
group per channel: the dissociation reaction keeps the serialized stem, while
-the ionisation reaction is keyed ``___e-``. NORAD files are
-photoionisation only.
+the ionisation reaction is keyed ``._PHOTON__.e-`` (the ``_PHOTON``
+agent is injected on the reactant side). NORAD files are photoionisation only.
Every dataset has a ``unit`` attr ("eV" or "cm2").
@@ -45,6 +45,7 @@
import h5py
import numpy as np
+from jaff.config import XSECS_DATA_DIR
from jaff.io import JaffLogger
from jaff.physics import constants
@@ -64,10 +65,10 @@
def split_reaction(stem: str) -> tuple[list[str], list[str]]:
"""Split a serialized stem into ``(reactants, products)`` string lists.
- ``"CH__C_H"`` -> ``(["CH"], ["C", "H"])``.
+ ``"CH__C.H"`` -> ``(["CH"], ["C", "H"])``.
"""
react, _, prod = stem.partition("__")
- return react.split("_"), prod.split("_")
+ return react.split("."), prod.split(".")
def wavelength_nm_to_eV(wavelength_nm: np.ndarray) -> np.ndarray:
@@ -143,8 +144,14 @@ def collapse_leiden(leiden_dir: Path, out_path: Path, logger) -> int:
pd_xs = src["photodissociation"][:].astype(float)[order]
if _has_signal(pd_xs):
_write_channel(
- h5, stem, reactants, products, "dissociation",
- energy, pd_xs, photoabs,
+ h5,
+ stem,
+ reactants,
+ products,
+ "dissociation",
+ energy,
+ pd_xs,
+ photoabs,
)
emitted += 1
@@ -152,12 +159,21 @@ def collapse_leiden(leiden_dir: Path, out_path: Path, logger) -> int:
if "photoionisation" in src:
pi_xs = src["photoionisation"][:].astype(float)[order]
ion_products = sorted([_ionize(reactant), "e-"])
- ion_key = f"{reactant}__{'_'.join(ion_products)}"
+ ion_key = (
+ f"{'.'.join(sorted([reactant, '_PHOTON']))}"
+ f"__{'.'.join(ion_products)}"
+ )
if _has_signal(pi_xs) and ion_key not in ionis_seen:
ionis_seen.add(ion_key)
_write_channel(
- h5, ion_key, reactants, ion_products, "ionization",
- energy, pi_xs, photoabs,
+ h5,
+ ion_key,
+ reactants,
+ ion_products,
+ "ionization",
+ energy,
+ pi_xs,
+ photoabs,
)
emitted += 1
logger.info(f"Wrote {emitted} Leiden channel groups to {out_path}")
@@ -225,7 +241,7 @@ def collapse_op(op_dir: Path, out_path: Path, logger) -> int:
def main() -> None:
"""Build ``leiden.h5`` and ``op.h5`` in ``data/xsecs/``."""
logger = JaffLogger().get_logger()
- xsecs = Path(__file__).parent.parent / "data" / "xsecs"
+ xsecs = XSECS_DATA_DIR
collapse_leiden(xsecs / "leiden", xsecs / "leiden.h5", logger)
collapse_op(xsecs / "op", xsecs / "op.h5", logger)
diff --git a/src/jaff/_utils/download_nahar_xsecs.py b/src/jaff/_utils/download_nahar_xsecs.py
index fb83a1a6..b65078b0 100644
--- a/src/jaff/_utils/download_nahar_xsecs.py
+++ b/src/jaff/_utils/download_nahar_xsecs.py
@@ -4,7 +4,7 @@
photoionisation cross section from the NORAD-Atomic-Data archive at Ohio State
(S. N. Nahar), reformats it, and writes one text file per ion into
``data/xsecs/op/`` using the serialized reaction naming convention
-``___e-.dat`` (e.g. ``H__H+_e-.dat``, ``He+__He++_e-.dat``).
+``__.e-.dat`` (e.g. ``H__H+.e-.dat``, ``He+__He++.e-.dat``).
Source URL scheme
-----------------
@@ -62,6 +62,7 @@
import numpy as np
+from jaff.config import XSECS_DATA_DIR
from jaff.io import JaffLogger
#: Rydberg energy in eV (CODATA), for Ry -> eV photon-energy conversion.
@@ -142,12 +143,14 @@ def serialized_name(symbol: str, charge: int) -> str:
Returns
-------
str
- e.g. ``charge=0`` -> ``"He__He+_e-"``, ``charge=1`` ->
- ``"He+__He++_e-"``.
+ e.g. ``charge=0`` -> ``"He__He+.e-"``, ``charge=1`` ->
+ ``"He+__He++.e-"``.
"""
ion = symbol + "+" * charge
product = symbol + "+" * (charge + 1)
- return f"{ion}__{'_'.join(sorted([product, 'e-']))}"
+ return (
+ f"{'.'.join(sorted([ion, '_PHOTON']))}__{'.'.join(sorted([product, 'e-']))}"
+ )
def fetch(url: str) -> str | None:
@@ -454,7 +457,7 @@ def main(download: bool = True, do_parse: bool = True) -> None:
Parse the local raw store into ``op/.dat`` files.
"""
logger = JaffLogger().get_logger()
- op_dir = Path(__file__).parent.parent / "data" / "xsecs" / "op"
+ op_dir = XSECS_DATA_DIR / "op"
raw_dir = op_dir / "raw"
if download:
diff --git a/src/jaff/_utils/generate_ion_xsecs_table.py b/src/jaff/_utils/generate_ion_xsecs_table.py
index ff6aa50f..176794ab 100644
--- a/src/jaff/_utils/generate_ion_xsecs_table.py
+++ b/src/jaff/_utils/generate_ion_xsecs_table.py
@@ -1,8 +1,7 @@
-from pathlib import Path
-
import pandas as pd
from sympy import Expr, Piecewise, Symbol, srepr
+from jaff.config import XSECS_DATA_DIR
from jaff.drivers.sqlite import JaffDb
from jaff.io import JaffLogger
@@ -34,13 +33,14 @@ def F(x, y):
def main():
- verner_data = (
- Path(__file__).parent.parent / "data" / "xsecs" / "verner" / "verner_1996.csv"
- )
+ verner_data = XSECS_DATA_DIR / "verner_1996.csv"
df = pd.read_csv(verner_data, sep=r"\s+", index_col=0)
rows = [
{
- "reaction": f"{ion}__{'_'.join(sorted([f'{ion}+', 'e-']))}",
+ "reaction": (
+ f"{'.'.join(sorted([ion, '_PHOTON']))}"
+ f"__{'.'.join(sorted([f'{ion}+', 'e-']))}"
+ ),
"Z": row["Z"],
"N": row["N"],
"xsecs": srepr(
diff --git a/src/jaff/_utils/generate_mass_table.py b/src/jaff/_utils/generate_mass_table.py
index 5ebe975e..07230803 100644
--- a/src/jaff/_utils/generate_mass_table.py
+++ b/src/jaff/_utils/generate_mass_table.py
@@ -1,13 +1,12 @@
-from pathlib import Path
-
import pandas as pd
+from jaff.config import DATA_DIR
from jaff.drivers.sqlite import JaffDb
from jaff.io import JaffLogger
def main():
- masses = Path(__file__).parent.parent / "data" / "atom_mass.csv"
+ masses = DATA_DIR / "atom_mass.csv"
df = pd.read_csv(
masses,
sep=r"\s+",
diff --git a/src/jaff/_utils/generate_photo_xsecs_table.py b/src/jaff/_utils/generate_photo_xsecs_table.py
index 84956fc5..f317baeb 100644
--- a/src/jaff/_utils/generate_photo_xsecs_table.py
+++ b/src/jaff/_utils/generate_photo_xsecs_table.py
@@ -2,7 +2,7 @@
One row per serialized reaction found in the collapsed cross-section HDF5
files (``data/xsecs/leiden.hdf5`` and ``data/xsecs/norad.hdf5``), keyed by the
-HDF5 group name (the serialized reaction stem, e.g. ``"CO__C_O"``).
+HDF5 group name (the serialized reaction stem, e.g. ``"CO__C.O"``).
Each HDF5 group is a single decay channel carrying one ``photodecay`` dataset
(plus an optional ``photoabsorption``) and a ``decay_type`` attribute, so a
@@ -11,7 +11,7 @@
Columns
-------
- ``reaction`` -- PK, serialized reaction / HDF5 group name.
-- ``photo_absorption`` -- 1 only for the H2 dissociation ``H2__H_H``, else 0.
+- ``photo_absorption`` -- 1 only for the H2 dissociation ``H2._PHOTON__H.H``, else 0.
- ``decay_type`` -- ``"dissociation"`` or ``"ionization"`` (from the
group's ``decay_type`` attribute).
- ``leiden`` -- ``data/xsecs/leiden.hdf5::`` if present, else NULL.
@@ -23,14 +23,15 @@
import h5py
import pandas as pd
+from jaff.config import JAFF_DIR
from jaff.drivers.sqlite import JaffDb
from jaff.io import JaffLogger
#: Reaction stem flagged as photo-absorption (H2 dissociation).
-H2_DISSOCIATION: str = "H2__H_H"
+H2_DISSOCIATION: str = "H2._PHOTON__H.H"
#: Package root (``src/jaff``) -- HDF5 paths are stored relative to this.
-PKG_ROOT: Path = Path(__file__).parent.parent
+PKG_ROOT: Path = JAFF_DIR
#: source name -> collapsed HDF5 file (relative to ``PKG_ROOT``).
XSEC_FILES: dict[str, Path] = {
diff --git a/src/jaff/_utils/split_xsecs_photodecay.py b/src/jaff/_utils/split_xsecs_photodecay.py
index f1d0e7cd..f5942f04 100644
--- a/src/jaff/_utils/split_xsecs_photodecay.py
+++ b/src/jaff/_utils/split_xsecs_photodecay.py
@@ -5,13 +5,13 @@
----------
The collapsed ``data/xsecs/leiden.hdf5`` bundled **both** decay channels
(``photodissociation`` + ``photoionization``) under one dissociation-keyed
-group (e.g. ``CH__C_H``). Dissociation and ionisation are physically distinct
+group (e.g. ``CH__C.H``). Dissociation and ionisation are physically distinct
reactions with different products and rates, so a single bundled row made the
molecular-ionisation reaction invisible to the network. This migration splits
each bundled group into one reaction per decay channel:
- ``__`` -- ``photodecay`` = photodissociation xsec
-- ``___e-`` -- ``photodecay`` = photoionisation xsec
+- ``._PHOTON__.e-`` -- ``photodecay`` = photoionisation xsec
emitted only for channels that carry signal. ``photoabsorption`` (shared
between a species' channels) and ``photon_energy`` are copied into each group.
@@ -41,13 +41,14 @@
import h5py
import numpy as np
+from jaff.config import XSECS_DATA_DIR
from jaff.io import JaffLogger
_STR_DT = h5py.string_dtype(encoding="utf-8")
COMPRESSION_KW: dict = {"compression": "gzip", "compression_opts": 4, "chunks": True}
#: Package data directory holding the collapsed xsec files.
-XSECS_DIR: Path = Path(__file__).parent.parent / "data" / "xsecs"
+XSECS_DIR: Path = XSECS_DATA_DIR
def _ionize(species: str) -> str:
@@ -61,7 +62,7 @@ def _ionize(species: str) -> str:
def _ionis_key(reactant: str) -> tuple[str, list[str]]:
"""Return ``(serialized_key, products)`` for the photoionisation channel."""
products = sorted([_ionize(reactant), "e-"])
- return f"{reactant}__{'_'.join(products)}", products
+ return f"{'.'.join(sorted([reactant, '_PHOTON']))}__{'.'.join(products)}", products
def _has_signal(dataset: h5py.Dataset) -> bool:
@@ -118,7 +119,8 @@ def split_leiden(h5_path: Path, logger) -> int:
reactant = g.attrs["reactants"][0]
reactant = reactant.decode() if isinstance(reactant, bytes) else str(reactant)
stem_products = [
- p.decode() if isinstance(p, bytes) else str(p) for p in g.attrs["products"]
+ p.decode() if isinstance(p, bytes) else str(p)
+ for p in g.attrs["products"]
]
energy = g["photon_energy"][:]
photoabs = (
@@ -170,8 +172,14 @@ def split_leiden(h5_path: Path, logger) -> int:
)
for e in emitted:
_write_group(
- f, e["key"], e["reactants"], e["products"], e["decay_type"],
- e["energy"], e["photodecay"], e["photoabsorption"],
+ f,
+ e["key"],
+ e["reactants"],
+ e["products"],
+ e["decay_type"],
+ e["energy"],
+ e["photodecay"],
+ e["photoabsorption"],
)
logger.info(f"Split {h5_path} into {len(emitted)} per-channel reactions")
return len(emitted)
@@ -188,10 +196,12 @@ def split_norad(h5_path: Path, logger) -> int:
root_attrs = dict(f.attrs)
for name, g in f.items():
reactants = [
- r.decode() if isinstance(r, bytes) else str(r) for r in g.attrs["reactants"]
+ r.decode() if isinstance(r, bytes) else str(r)
+ for r in g.attrs["reactants"]
]
products = [
- p.decode() if isinstance(p, bytes) else str(p) for p in g.attrs["products"]
+ p.decode() if isinstance(p, bytes) else str(p)
+ for p in g.attrs["products"]
]
groups.append(
{
@@ -201,7 +211,8 @@ def split_norad(h5_path: Path, logger) -> int:
"energy": g["photon_energy"][:],
"photodecay": g["photoionization"][:],
"attrs": {
- k: v for k, v in g.attrs.items()
+ k: v
+ for k, v in g.attrs.items()
if k not in ("reactants", "products")
},
}
@@ -212,8 +223,14 @@ def split_norad(h5_path: Path, logger) -> int:
f.attrs[k] = v
for grp in groups:
_write_group(
- f, grp["key"], grp["reactants"], grp["products"], "ionization",
- grp["energy"], grp["photodecay"], None,
+ f,
+ grp["key"],
+ grp["reactants"],
+ grp["products"],
+ "ionization",
+ grp["energy"],
+ grp["photodecay"],
+ None,
)
for k, v in grp["attrs"].items():
f[grp["key"]].attrs[k] = v
diff --git a/src/jaff/cli/_jaffgen.py b/src/jaff/cli/_jaffgen.py
index 6dc396a5..65f8714a 100644
--- a/src/jaff/cli/_jaffgen.py
+++ b/src/jaff/cli/_jaffgen.py
@@ -55,11 +55,10 @@
from .. import Network
from ..cli import ConfigTable
-from ..codegen import Codegen as cg
-from ..codegen import TemplateParser
+from ..config import JAFF_DIR, NETWORK_DIR, TEMPLATES_DIR
+from ..codegen import Language, TemplateParser
from ..common import motd
from ..drivers import HDF5, Toml
-from ..errors import ParserError
from ..io import JaffLogger, jaff_progress
from ..types import HDF5Dict
from ._typing import JaffgenProps
@@ -134,12 +133,10 @@ def __init__(self):
# Locate JAFF package directory and built-in template directories.
# Templates are stored inside jaff/templates/{generator,preprocessor}/.
- self.jaff_dir: Path = Path(__file__).parent.parent
- self.network_dir: Path = self.jaff_dir.parent.parent / "networks"
- self.generator_template_dir: Path = self.jaff_dir / "templates" / "generator"
- self.preprocessor_template_dir: Path = (
- self.jaff_dir / "templates" / "preprocessor"
- )
+ self.jaff_dir: Path = JAFF_DIR
+ self.network_dir: Path = NETWORK_DIR
+ self.generator_template_dir: Path = TEMPLATES_DIR / "generator"
+ self.preprocessor_template_dir: Path = TEMPLATES_DIR / "preprocessor"
self.files: list[Path] = []
self.jaffgen_config: JaffgenProps = {"netprops": {}} # type: ignore
self.jaffgen_config_raw: Toml | None = None
@@ -422,10 +419,10 @@ def __set_default_lang(self, default_lang: str | None) -> None:
Raises
------
ValueError
- If *default_lang* is not recognised by
- :meth:`~jaff.codegen.Codegen.get_language_tokens`.
+ If *default_lang* is not a recognised
+ :class:`~jaff.codegen.Language` alias.
"""
- if default_lang and default_lang not in cg.get_language_tokens():
+ if default_lang and default_lang not in Language.LOOKUP:
raise ValueError(f"Unsupported language specified: {default_lang}")
self.jaffgen_config["default_lang"] = default_lang
diff --git a/src/jaff/codegen/__init__.py b/src/jaff/codegen/__init__.py
index 809e1437..b1ac37b2 100644
--- a/src/jaff/codegen/__init__.py
+++ b/src/jaff/codegen/__init__.py
@@ -1,3 +1,4 @@
+from ._languages import Language
from ._template_engine import TemplateParser
from ._typing import CommandProps, ExtrasDict, IdxSpanResult, IndexedReturn
from .builder import Builder
@@ -7,6 +8,7 @@
__all__ = [
Builder,
Codegen,
+ Language,
IndexedReturn,
Preprocessor,
TemplateParser,
diff --git a/src/jaff/codegen/_languages.py b/src/jaff/codegen/_languages.py
new file mode 100644
index 00000000..0296f6ed
--- /dev/null
+++ b/src/jaff/codegen/_languages.py
@@ -0,0 +1,338 @@
+from __future__ import annotations
+
+import copy
+import functools
+import inspect
+from collections.abc import Callable
+from typing import Any, ClassVar
+
+import sympy as sp
+
+from ..errors import InvalidLanguageError
+
+
+class Language:
+ """Base class and factory for target-language code-generation config.
+
+ Each supported language is a subclass that declares its syntax conventions
+ as class attributes (brackets, assignment operator, line terminator, SymPy
+ printer, index offset, …). Defining a subclass auto-registers a singleton
+ instance plus its aliases, so adding a language requires only a new subclass
+ with no central edits.
+
+ Resolve a language by alias with the base factory::
+
+ cxx = Language("c++") # -> the registered Cxx singleton
+ cxx.code_gen(expr) # sp.cxxcode(expr)
+ """
+
+ _register: ClassVar[dict[str, "Language"]] = {}
+ LOOKUP: ClassVar[dict[str, str]] = {}
+
+ # Override vocabulary consumed by derive(). "[,]" -> J[i, j]; "[]" -> J[i][j].
+ BRACKET_FORMATS: ClassVar[tuple[str, ...]] = ("()", "{}", "[]", "<>")
+ MATRIX_FORMATS: ClassVar[dict[str, dict[str, str]]] = {
+ "()": {"brac": "()", "sep": ")("},
+ "()()": {"brac": "()", "sep": ")("},
+ "(,)": {"brac": "()", "sep": ", "},
+ "[]": {"brac": "[]", "sep": "]["},
+ "[][]": {"brac": "[]", "sep": "]["},
+ "[,]": {"brac": "[]", "sep": ", "},
+ "{}": {"brac": "{}", "sep": "}{"},
+ "{}{}": {"brac": "{}", "sep": "}{"},
+ "{,}": {"brac": "{}", "sep": ", "},
+ "<>": {"brac": "<>", "sep": "><"},
+ "<><>": {"brac": "<>", "sep": "><"},
+ "<,>": {"brac": "<>", "sep": ", "},
+ }
+
+ # Immutable per-language config set by each subclass.
+ name: ClassVar[str]
+ aliases: ClassVar[tuple[str, ...]] = ()
+ brac: ClassVar[str]
+ matrix_sep: ClassVar[str]
+ code_gen: ClassVar[Callable[..., str]]
+ idx_offset: ClassVar[int]
+ comment: ClassVar[str]
+ types: ClassVar[dict[str, str]]
+ extras: ClassVar[dict[str, Any]]
+
+ # Overridable tokens: class value is the default, derive() shadows them on
+ # an ephemeral copy. Plain annotations, not ClassVar (which bans instance
+ # assignment). lb/rb/mlb/mrb/sep derived by __init_subclass__ from `brac`.
+ assignment_op: str
+ line_end: str
+ lb: str
+ rb: str
+ mlb: str
+ mrb: str
+ sep: str
+
+ def __init_subclass__(cls, **kwargs: dict) -> None:
+ """Register the subclass singleton and its aliases on definition."""
+ super().__init_subclass__(**kwargs)
+
+ if not getattr(cls, "name", None):
+ raise ValueError(f"{cls.__name__} must define a 'name' class attribute")
+
+ for alias in (cls.name, *cls.aliases):
+ Language.LOOKUP[alias] = cls.name
+
+ # Matrix brackets default to the 1-D brackets + the language separator.
+ cls.lb, cls.rb = cls.brac[0], cls.brac[1]
+ cls.mlb, cls.mrb = cls.lb, cls.rb
+ cls.sep = cls.matrix_sep
+
+ Language._register[cls.name] = cls()
+
+ def derive(
+ self,
+ *,
+ brac_format: str = "",
+ matrix_format: str = "",
+ assignment_op: str = "",
+ line_end: str = "",
+ ) -> "Language":
+ """Return a bracket/token-overridden view of this language.
+
+ When no override is supplied, returns ``self`` (the registered
+ singleton) unchanged. Otherwise returns a shallow, *unregistered*
+ copy with the overridden attributes shadowing the class defaults, so
+ the singleton is never mutated and the registry never grows.
+
+ Parameters
+ ----------
+ brac_format : str, optional
+ 1-D bracket style from :attr:`BRACKET_FORMATS` (e.g. ``"()"``).
+ matrix_format : str, optional
+ 2-D bracket/separator format key from :attr:`MATRIX_FORMATS`.
+ assignment_op : str, optional
+ Assignment operator override.
+ line_end : str, optional
+ Statement terminator override.
+
+ Returns
+ -------
+ Language
+ ``self`` if nothing was overridden, else an ephemeral copy.
+
+ Raises
+ ------
+ InvalidLanguageError
+ If *brac_format* or *matrix_format* is not a supported format.
+ """
+ if not (brac_format or matrix_format or assignment_op or line_end):
+ return self
+
+ view = copy.copy(self)
+
+ if brac_format:
+ if brac_format not in self.BRACKET_FORMATS:
+ raise InvalidLanguageError(
+ f"Unsupported bracket format: '{brac_format}'. "
+ f"Supported: {', '.join(self.BRACKET_FORMATS)}"
+ )
+ view.lb, view.rb = brac_format[0], brac_format[1]
+
+ if matrix_format:
+ if matrix_format not in self.MATRIX_FORMATS:
+ raise InvalidLanguageError(
+ f"Unsupported matrix format: '{matrix_format}'. "
+ f"Supported: {', '.join(self.MATRIX_FORMATS)}"
+ )
+ fmt = self.MATRIX_FORMATS[matrix_format]
+ view.mlb, view.mrb = fmt["brac"][0], fmt["brac"][1]
+ view.sep = fmt["sep"]
+
+ if assignment_op:
+ view.assignment_op = assignment_op
+ if line_end:
+ view.line_end = line_end
+
+ return view
+
+ @classmethod
+ def registered(cls) -> tuple["Language", ...]:
+ """Return every registered language singleton, one per canonical name."""
+ return tuple(Language._register.values())
+
+ @classmethod
+ def comments(cls) -> set[str]:
+ """Return the set of single-line comment prefixes across all languages."""
+ return {lang.comment for lang in Language._register.values()}
+
+ def __new__(cls, lang: str | None = None) -> "Language":
+ # Subclasses instantiate normally; only Language(...) acts as a factory.
+ if cls is not Language:
+ return super().__new__(cls)
+
+ if lang not in cls.LOOKUP:
+ supported = ", ".join(sorted(set(cls.LOOKUP.values())))
+ raise InvalidLanguageError(
+ f"{lang} is not a supported language.\n"
+ f"Supported languages are: {supported}"
+ )
+
+ return cls._register[cls.LOOKUP[lang]]
+
+ def __repr__(self) -> str:
+ return f""
+
+
+class Cxx(Language):
+ name = "cxx"
+ aliases = ("c++", "cpp")
+ brac = "[]"
+ assignment_op = "="
+ line_end = ";"
+ matrix_sep = "]["
+ code_gen = staticmethod(sp.cxxcode)
+ idx_offset = 0
+ comment = "//"
+ types = {"int": "int ", "float": "float ", "double": "double ", "bool": "bool "}
+ extras = {"type_qualifier": "const ", "class_specifier": "static "}
+
+
+class C(Language):
+ name = "c"
+ brac = "[]"
+ assignment_op = "="
+ line_end = ";"
+ matrix_sep = "]["
+ code_gen = staticmethod(sp.ccode)
+ idx_offset = 0
+ comment = "//"
+ types = {"int": "int ", "float": "float ", "double": "double ", "bool": "_Bool "}
+ extras = {"type_qualifier": "const ", "class_specifier": "static "}
+
+
+class Fortran(Language):
+ name = "fortran"
+ aliases = ("f90",)
+ brac = "()"
+ assignment_op = "="
+ line_end = ""
+ matrix_sep = ", "
+ code_gen = staticmethod(sp.fcode)
+ idx_offset = 1
+ comment = "!"
+ types: ClassVar[dict[str, str]] = {}
+ extras = {"class_specifier": "save "}
+
+
+class Python(Language):
+ name = "python"
+ aliases = ("py",)
+ brac = "[]"
+ assignment_op = "="
+ line_end = ""
+ matrix_sep = "]["
+ code_gen = staticmethod(sp.pycode)
+ idx_offset = 0
+ comment = "#"
+ types: ClassVar[dict[str, str]] = {}
+ extras: ClassVar[dict[str, Any]] = {}
+
+
+class Rust(Language):
+ name = "rust"
+ aliases = ("rs",)
+ brac = "[]"
+ assignment_op = "="
+ line_end = ";"
+ matrix_sep = "]["
+ code_gen = staticmethod(sp.rust_code)
+ idx_offset = 0
+ comment = "//"
+ types = {"int": "i32 ", "float": "f32 ", "double": "f64 ", "bool": "bool "}
+ extras = {"type_qualifier": "const ", "class_specifier": ""}
+
+
+class Julia(Language):
+ name = "julia"
+ aliases = ("jl",)
+ brac = "[]"
+ assignment_op = "="
+ line_end = ""
+ matrix_sep = ", "
+ code_gen = staticmethod(sp.julia_code)
+ idx_offset = 1
+ comment = "#"
+ types = {
+ "int": "Int64 ",
+ "float": "Float32 ",
+ "double": "Float64 ",
+ "bool": "Bool ",
+ }
+ extras = {"type_qualifier": "const ", "class_specifier": ""}
+
+
+class R(Language):
+ name = "r"
+ brac = "[]"
+ assignment_op = "<-"
+ line_end = ""
+ matrix_sep = ", "
+ code_gen = staticmethod(sp.rcode)
+ idx_offset = 1
+ comment = "#"
+ types: ClassVar[dict[str, str]] = {}
+ extras: ClassVar[dict[str, Any]] = {}
+
+
+def scoped_tokens(lang_attr: str = "lang") -> Callable[[Callable], Callable]:
+ """Decorator: apply a method's bracket/token overrides to its owner's language.
+
+ Wraps a code-generation method whose owner exposes a :class:`Language` on
+ the attribute named *lang_attr*. Before the call, the override keyword
+ arguments the method declares (``brac_format``, ``matrix_format``,
+ ``assignment_op``, ``line_end``) are harvested and used to swap the owner's
+ language for a :meth:`Language.derive`-d view; the original language is
+ restored afterwards. The wrapped method body reads the tokens straight
+ off ``self.`` (e.g. ``self.lang.lb``) with no per-method
+ fallback boilerplate.
+
+ Methods that do not declare a given override keyword are unaffected — the
+ harvester falls back to an empty override for absent parameters.
+
+ Parameters
+ ----------
+ lang_attr : str, optional
+ Name of the attribute on the wrapped method's owner that holds the
+ :class:`Language` instance. Default ``"lang"``.
+
+ Returns
+ -------
+ Callable
+ A decorator that wraps a code-generation method.
+ """
+
+ def decorator(func: Callable) -> Callable:
+ sig = inspect.signature(func)
+
+ @functools.wraps(func)
+ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
+ bound = sig.bind(self, *args, **kwargs)
+ bound.apply_defaults()
+ supplied = bound.arguments
+
+ lang: Language = getattr(self, lang_attr)
+ original = lang
+ setattr(
+ self,
+ lang_attr,
+ lang.derive(
+ brac_format=supplied.get("brac_format", ""),
+ matrix_format=supplied.get("matrix_format", ""),
+ assignment_op=supplied.get("assignment_op", ""),
+ line_end=supplied.get("line_end", ""),
+ ),
+ )
+ try:
+ return func(self, *args, **kwargs)
+ finally:
+ setattr(self, lang_attr, original)
+
+ return wrapper
+
+ return decorator
diff --git a/src/jaff/codegen/_template_engine.py b/src/jaff/codegen/_template_engine.py
index 1c8b507d..ea01926c 100644
--- a/src/jaff/codegen/_template_engine.py
+++ b/src/jaff/codegen/_template_engine.py
@@ -37,6 +37,7 @@
from ..errors import ParserError
from ..types import IndexedList
+from ._languages import Language
from ._typing import CommandProps, IdxSpanResult, IndexedReturn
from .codegen import Codegen
@@ -202,10 +203,7 @@ def __parse_line(self, line: str) -> None:
line : str
Line of text to parse.
"""
- valid_comments: set[str] = {
- self.cg.get_language_tokens()[lang]["comment"]
- for lang in self.cg.get_language_tokens().keys()
- } | {"--", "%"}
+ valid_comments: set[str] = Language.comments() | {"--", "%"}
# Extract indentation from the original line
self.indent = line[: len(line) - len(line.lstrip(" "))]
@@ -224,7 +222,7 @@ def __parse_line(self, line: str) -> None:
self.modified += self.og_line
return
- comment = tokens[0] if tokens else self.cg.comment
+ comment = tokens[0] if tokens else self.cg.lang.comment
# Preserve the original line and process the command if JAFF is found
self.modified += self.og_line
diff --git a/src/jaff/codegen/builder.py b/src/jaff/codegen/builder.py
index 84613936..a728d87c 100644
--- a/src/jaff/codegen/builder.py
+++ b/src/jaff/codegen/builder.py
@@ -15,6 +15,8 @@
import sys
from pathlib import Path
+from jaff.config import TEMPLATES_DIR
+
class Builder:
"""Orchestrate plugin-based code generation for a chemical network.
@@ -81,7 +83,7 @@ def build(
print("Building network with template:", template)
# Resolve the template directory bundled with the jaff.codegen package
- templates_dir = Path(__file__).parent.parent / "templates" / "preprocessor"
+ templates_dir = TEMPLATES_DIR / "preprocessor"
path_template = str(templates_dir / template)
# Resolve the output directory (default: current working directory)
diff --git a/src/jaff/codegen/codegen.py b/src/jaff/codegen/codegen.py
index 77a62cf6..e9bb15f0 100644
--- a/src/jaff/codegen/codegen.py
+++ b/src/jaff/codegen/codegen.py
@@ -17,23 +17,23 @@
4. Insert those strings into template files via the
:class:`~jaff.codegen.preprocessor.Preprocessor`.
-The module also defines :class:`LangModifier`, a :class:`~typing.TypedDict`
-that captures all syntax differences between languages (bracket style,
-assignment operator, line terminator, index offset, etc.).
+Per-language syntax conventions live on :class:`~jaff.codegen._languages.Language`;
+per-method bracket/token overrides are applied by the
+:func:`~jaff.codegen._languages.scoped_tokens` decorator.
"""
from __future__ import annotations
import re
-from collections.abc import Callable
from functools import cache, reduce
from itertools import product
-from typing import TYPE_CHECKING, Any, List, Set, Tuple, TypedDict, cast
+from typing import TYPE_CHECKING, List, Set, Tuple, cast
import sympy as sp
from ..io._logger import JaffLogger, jaff_progress
from ..types import IndexedList, IndexedValue
+from ._languages import Language, scoped_tokens
from ._typing import IndexedReturn
if TYPE_CHECKING:
@@ -42,59 +42,6 @@
from ..core.network import Network
-class LangModifier(TypedDict):
- """Language-specific syntax and code-generation parameters.
-
- Each field captures a syntax convention that differs between target
- languages. Instances are produced by :meth:`Codegen.get_language_tokens`
- and stored on the :class:`Codegen` instance during construction.
-
- Attributes
- ----------
- brac : str
- Two-character string containing the left and right brackets used for
- 1-D array indexing, e.g. ``"[]"`` for C/C++/Python or ``"()"`` for
- Fortran.
- assignment_op : str
- Assignment operator string, e.g. ``"="`` or ``"<-"`` (R).
- line_end : str
- Statement terminator appended after each assignment, e.g. ``";"``
- for C/C++ or ``""`` for Python/Fortran.
- matrix_sep : str
- Separator between the row and column indices in 2-D array access,
- e.g. ``"]["`` for C-style ``J[i][j]`` or ``", "`` for Julia/Fortran
- ``J[i, j]``.
- code_gen : Callable[..., str]
- SymPy printer function used to serialise expressions into target-
- language syntax, e.g. :func:`sympy.cxxcode` or :func:`sympy.fcode`.
- idx_offset : int
- Base index added to all array subscripts. ``0`` for 0-based
- languages (C, Python, Rust), ``1`` for 1-based languages (Fortran,
- Julia, R).
- comment : str
- Single-line comment prefix, e.g. ``"//"`` or ``"!"`` or ``"#"``.
- types : dict[str, str]
- Mapping from generic type names (``"int"``, ``"float"``,
- ``"double"``, ``"bool"``) to their language-specific spellings,
- e.g. ``{"double": "f64 "}`` for Rust. Empty for dynamically typed
- languages (Python, R, Fortran).
- extras : dict[str, Any]
- Language-specific miscellaneous tokens such as ``"type_qualifier"``
- (``"const "`` in C/C++) or ``"class_specifier"`` (``"static "`` in
- C/C++, ``"save "`` in Fortran).
- """
-
- brac: str
- assignment_op: str
- line_end: str
- matrix_sep: str
- code_gen: Callable[..., str]
- idx_offset: int
- comment: str
- types: dict[str, str]
- extras: dict[str, Any]
-
-
class Codegen:
"""Generate rates, fluxes, ODEs, and Jacobians from a Network in multiple languages.
@@ -109,10 +56,10 @@ class Codegen:
* **Energy derivative** — ``dE/dt`` (optional, with EOS coupling)
* **Radiation ODEs** — moment-equations for radiation fields (optional)
- All ``get_*_str()`` methods accept formatting overrides (bracket style,
- assignment operator, line terminator, index offset) so the same
- :class:`Codegen` object can be used to produce code for slightly
- non-standard target conventions without re-instantiation.
+ Per-method ``get_*_str()`` overrides (bracket style, assignment operator,
+ line terminator) are applied by the :func:`~jaff.codegen._languages.scoped_tokens`
+ decorator, which temporarily swaps ``self.lang`` for a
+ :meth:`~jaff.codegen._languages.Language.derive`-d view for the call.
Common subexpression elimination (CSE) is performed via
:func:`sympy.cse` when ``use_cse=True`` (the default for most methods).
@@ -128,107 +75,23 @@ class Codegen:
``"cxx"``, ``"c"``, ``"fortran"``, ``"f90"``, ``"python"``,
``"py"``, ``"rust"``, ``"rs"``, ``"julia"``, ``"jl"``, ``"r"``.
Default is ``"c++"``.
- brac_format : str, optional
- Override the 1-D array bracket style. One of ``"()"``, ``"[]"``,
- ``"{}"`` or ``"<>"``. When empty the language default is used.
- matrix_format : str, optional
- Override the 2-D array bracket/separator style. Accepted values:
- ``"()"``, ``"(,)"``, ``"[]"``, ``"[,]"``, ``"{}"`` ``"{,}"``,
- ``"<>"``, ``"<,>"``, and their doubled equivalents (``"()()"``,
- etc.). When empty the language default is used.
Raises
------
- ValueError
- If *lang*, *brac_format* or *matrix_format* is not in the set of
- supported values.
+ InvalidLanguageError
+ If *lang* is not a supported language.
"""
def __init__(
self,
network: Network,
lang: str = "c++",
- brac_format: str = "",
- matrix_format: str = "",
) -> None:
- # Resolve static lookup tables once — they are @cache'd so the cost
- # is paid only on the very first instantiation.
- __lang_aliases = self.__get_language_aliases()
- __lang_tokens = self.get_language_tokens()
- __matrix_formats = self.__get_matrix_formats()
- __brack_formats = self.__get_bracket_formats()
-
- # ------------------------------------------------------------------ #
- # Input validation #
- # ------------------------------------------------------------------ #
-
- # Check if language is supported
- if lang and lang not in __lang_aliases.keys():
- raise ValueError(
- f"\n\nUnsupported language: '{lang}'"
- f"\nSupported languages: {[key for key in __lang_aliases]}\n"
- )
-
- # Check if 2D array format is supported
- if matrix_format and matrix_format not in __matrix_formats.keys():
- raise ValueError(
- f"\n\nUnsupported matrix format: '{matrix_format}'"
- f"\nSupported matrix formats: {[key for key in __matrix_formats]}\n"
- )
-
- # Check if 1D array format is supported
- if brac_format and brac_format not in __brack_formats:
- raise ValueError(
- f"\n\nUnsupported bracket format: '{brac_format}'"
- f"\nSupported bracket formats: {[key for key in __brack_formats]}\n"
- )
-
- # ------------------------------------------------------------------ #
- # Language token resolution #
- # ------------------------------------------------------------------ #
-
- # Normalise alias (e.g. "c++" -> "cxx", "py" -> "python")
- language = __lang_aliases.get(lang, "cxx")
-
- # Resolve 1-D bracket pair (lb, rb) — caller override takes priority
- bracs: str = (
- brac_format
- if brac_format in __brack_formats
- else __lang_tokens[language]["brac"]
- )
-
- # Resolve 2-D bracket pair used for matrix/Jacobian indexing
- mbracs: str = (
- __matrix_formats[matrix_format]["brac"]
- if matrix_format
- else __lang_tokens[language]["brac"]
- )
-
- # Separator between row and column indices in 2-D access (e.g. "][" or ", ")
- self.matrix_sep: str = (
- __matrix_formats[matrix_format]["sep"]
- if matrix_format
- else __lang_tokens[language]["matrix_sep"]
- )
-
- # Store language-specific syntax tokens as instance attributes so
- # every get_*_str() method can access them without extra lookup.
- self.assignment_op: str = __lang_tokens[language]["assignment_op"]
- self.line_end: str = __lang_tokens[language]["line_end"]
- self.code_gen: Callable[..., str] = __lang_tokens[language]["code_gen"]
- self.ioff: int = __lang_tokens[language]["idx_offset"]
- self.comment: str = __lang_tokens[language]["comment"]
- self.types: dict[str, str] = __lang_tokens[language]["types"]
- self.extras: dict[str, Any] = __lang_tokens[language]["extras"]
- self.lang = language
-
- # Unpack bracket pairs for convenient use in f-strings below
- self.lb, self.rb = bracs
- self.mlb, self.mrb = mbracs
-
+ self.lang = Language(lang)
self.net: Network = network
self.logger: logging.Logger = JaffLogger().get_logger()
+ @scoped_tokens("lang")
def get_commons(
self,
idx_offset: int = -1,
@@ -255,16 +118,16 @@ def get_commons(
----------
idx_offset : int, optional
Base index added to each species position. ``-1`` uses the
- language default stored in ``self.ioff``.
+ language default stored in ``self.lang.idx_offset``.
idx_prefix : str, optional
String prepended to each species index name, e.g. ``"idx_"``.
definition_prefix : str, optional
String prepended to each definition line, e.g. ``"const int "``
for C/C++.
assignment_op : str, optional
- Assignment operator override. Empty string uses ``self.assignment_op``.
+ Assignment operator override. Empty string uses ``self.lang.assignment_op``.
line_end : str, optional
- Line terminator override. Empty string uses ``self.line_end``.
+ Line terminator override. Empty string uses ``self.lang.line_end``.
Returns
-------
@@ -272,22 +135,16 @@ def get_commons(
Multi-line string of index definitions followed by the size
constants ``nspecs`` and ``nreactions``.
"""
- ioff = idx_offset if idx_offset >= 0 else self.ioff
- assign_op = assignment_op or self.assignment_op
- lend = line_end or self.line_end
+ ioff = idx_offset if idx_offset >= 0 else self.lang.idx_offset
scommons = ""
# One definition per species: =
for i, s in enumerate(self.net.species):
- scommons += (
- f"{definition_prefix}{idx_prefix}{s.fidx} {assign_op} {ioff + i}{lend}\n"
- )
+ scommons += f"{definition_prefix}{idx_prefix}{s.fidx} {self.lang.assignment_op} {ioff + i}{self.lang.line_end}\n"
# Append network-size constants used by solver loops
- scommons += (
- f"{definition_prefix}nspecs {assign_op} {self.net.species.count}{lend}\n"
- )
- scommons += f"{definition_prefix}nreactions {assign_op} {self.net.reactions.count}{lend}\n"
+ scommons += f"{definition_prefix}nspecs {self.lang.assignment_op} {self.net.species.count}{self.lang.line_end}\n"
+ scommons += f"{definition_prefix}nreactions {self.lang.assignment_op} {self.net.reactions.count}{self.lang.line_end}\n"
return scommons
@@ -368,14 +225,16 @@ def get_indexed_rates(
for var, expr in replacements:
match = pattern.search(str(var))
idx: int = int(match.group(0)) if match is not None else 0
- expr = self.code_gen(
+ expr = self.lang.code_gen(
expr, strict=False, allow_unknown_functions=True
)
out["extras"]["cse"].append(IndexedValue([idx], expr))
# Overwrite the original symbolic rates with their CSE-reduced forms
for key, expr in zip(cse_dict.keys(), reduced_exprs):
- expr = self.code_gen(expr, strict=False, allow_unknown_functions=True)
+ expr = self.lang.code_gen(
+ expr, strict=False, allow_unknown_functions=True
+ )
cse_dict[key] = expr
# Build the final expression list for all reactions.
@@ -383,11 +242,12 @@ def get_indexed_rates(
# their get_code() representation, which handles $IDX$ substitution
# and string-rate passthrough.
for i, rea in enumerate(self.net.reactions):
- rate = cse_dict[i] if cse_dict.get(i, "") else rea.get_code(self.lang)
+ rate = cse_dict[i] if cse_dict.get(i, "") else rea.get_code(self.lang.name)
out["expressions"].append(IndexedValue([i], rate))
return out
+ @scoped_tokens("lang")
def get_rates_str(
self,
idx_offset: int = -1,
@@ -441,15 +301,12 @@ def get_rates_str(
Multi-line string of rate-coefficient assignments, including any
CSE temporary definitions.
"""
- ioff = idx_offset if idx_offset >= 0 else self.ioff
+ ioff = idx_offset if idx_offset >= 0 else self.lang.idx_offset
# Construct the type prefix for CSE temporary declarations
prefix = (
var_prefix
- or f"{self.extras.get('type_qualifier', '')}{self.types.get('double', '')}"
+ or f"{self.lang.extras.get('type_qualifier', '')}{self.lang.types.get('double', '')}"
)
- lb, rb = brac_format or (self.lb, self.rb)
- assign_op = assignment_op or self.assignment_op
- lend = line_end or self.line_end
rates = ""
rate_expressions = self.get_indexed_rates(use_cse=use_cse, cse_var=cse_var)
@@ -459,7 +316,7 @@ def get_rates_str(
if use_cse:
for idx, expression in rate_expressions["extras"]["cse"]:
_idx = idx[0]
- rates += f"{prefix}{cse_var}{_idx} {assign_op} {expression}{lend}\n"
+ rates += f"{prefix}{cse_var}{_idx} {self.lang.assignment_op} {expression}{self.lang.line_end}\n"
for idx, expression in rate_expressions["expressions"]:
_idx = idx[0]
@@ -467,9 +324,7 @@ def get_rates_str(
# the actual zero/one-based reaction index.
if "$IDX$" in expression:
expression = expression.replace("$IDX$", str(ioff + _idx))
- rates += (
- f"{rate_variable}{lb}{ioff + _idx}{rb} {assign_op} {expression}{lend}\n"
- )
+ rates += f"{rate_variable}{self.lang.lb}{ioff + _idx}{self.lang.rb} {self.lang.assignment_op} {expression}{self.lang.line_end}\n"
return rates
@@ -496,20 +351,16 @@ def get_indexed_flux_expressions(
"""
out = IndexedList()
for i, rea in enumerate(self.net.reactions):
- # Build the flux string by iterating over all reactants.
- # The loop body overwrites `flux` on each iteration so only the
- # last reactant's contribution survives — this is intentional
- # because `rea.reactants` always yields the same reactant list and
- # we need the full product expression, not individual terms.
- for rr in rea.reactants:
- flux = f"k{self.lb}$IDX${self.rb} * " + " * ".join(
- [f"y{self.lb}{x.fidx}{self.rb}" for x in rea.reactants]
- )
+ # Rate coefficient times the product of all reactant densities.
+ flux = f"k{self.lang.lb}$IDX${self.lang.rb} * " + " * ".join(
+ [f"y{self.lang.lb}{r.fidx}{self.lang.rb}" for r in rea.reactants.core]
+ )
out.append(IndexedValue([i], flux))
return out
+ @scoped_tokens("lang")
def get_flux_expressions_str(
self,
rate_var: str = "k",
@@ -557,10 +408,7 @@ def get_flux_expressions_str(
str
Multi-line string of flux assignments, one per reaction.
"""
- ioff = idx_offset if idx_offset >= 0 else self.ioff
- lb, rb = brac_format or (self.lb, self.rb)
- assign_op = assignment_op or self.assignment_op
- lend = line_end or self.line_end
+ ioff = idx_offset if idx_offset >= 0 else self.lang.idx_offset
fluxes = ""
for i, rea in enumerate(self.net.reactions):
@@ -570,10 +418,10 @@ def get_flux_expressions_str(
idx=ioff + i,
rate_variable=rate_var,
species_variable=species_var,
- brackets=f"{self.lb}{self.rb}",
+ brackets=f"{self.lang.lb}{self.lang.rb}",
idx_prefix=idx_prefix,
)
- fluxes += f"{flux_var}{lb}{ioff + i}{rb} {assign_op} {flux}{lend}\n"
+ fluxes += f"{flux_var}{self.lang.lb}{ioff + i}{self.lang.rb} {self.lang.assignment_op} {flux}{self.lang.line_end}\n"
return fluxes
@@ -608,11 +456,15 @@ def get_indexed_ode_expressions(self) -> IndexedList:
ode = {specie.index: "" for specie in self.net.species}
for i, rea in enumerate(self.net.reactions):
# Consumption: each reactant loses density at the reaction flux rate
- for rr in rea.reactants:
- ode[rr.index] += f" - flux{self.lb}{i + self.ioff}{self.rb}"
+ for rr in rea.reactants.core:
+ ode[rr.index] += (
+ f" - flux{self.lang.lb}{i + self.lang.idx_offset}{self.lang.rb}"
+ )
# Production: each product gains density at the reaction flux rate
- for pp in rea.products:
- ode[pp.index] += f" + flux{self.lb}{i + self.ioff}{self.rb}"
+ for pp in rea.products.core:
+ ode[pp.index] += (
+ f" + flux{self.lang.lb}{i + self.lang.idx_offset}{self.lang.rb}"
+ )
out = IndexedList()
for idx, expr in ode.items():
@@ -620,6 +472,7 @@ def get_indexed_ode_expressions(self) -> IndexedList:
return out
+ @scoped_tokens("lang")
def get_ode_expressions_str(
self,
idx_offset: int = -1,
@@ -648,7 +501,7 @@ def get_ode_expressions_str(
----------
idx_offset : int, optional
Base index for flux array subscripts. ``-1`` uses the language
- default stored in ``self.ioff``.
+ default stored in ``self.lang.idx_offset``.
flux_var : str, optional
Name of the pre-computed flux array. Default ``"flux"``.
species_var : str, optional
@@ -675,32 +528,29 @@ def get_ode_expressions_str(
str
Multi-line string of derivative assignments, one per active species.
"""
- ioff = idx_offset if idx_offset >= 0 else self.ioff
+ ioff = idx_offset if idx_offset >= 0 else self.lang.idx_offset
# Construct the derivative variable name (e.g. "dy") unless overridden
derivative_var = derivative_var or f"{derivative_prefix}{species_var}"
- assign_op = assignment_op or self.assignment_op
- lend = line_end or self.line_end
- lb, rb = brac_format or (self.lb, self.rb)
# Accumulate signed flux contributions into a dict keyed by species fidx
ode = {}
for i, rea in enumerate(self.net.reactions):
- for rr in rea.reactants:
+ for rr in rea.reactants.core:
rrfidx = idx_prefix + rr.fidx
if rrfidx not in ode:
ode[rrfidx] = ""
# Reactants are consumed: negative contribution
- ode[rrfidx] += f" - {flux_var}{self.lb}{ioff + i}{self.rb}"
- for pp in rea.products:
+ ode[rrfidx] += f" - {flux_var}{self.lang.lb}{ioff + i}{self.lang.rb}"
+ for pp in rea.products.core:
ppfidx = idx_prefix + pp.fidx
if ppfidx not in ode:
ode[ppfidx] = ""
# Products are created: positive contribution
- ode[ppfidx] += f" + {flux_var}{self.lb}{ioff + i}{self.rb}"
+ ode[ppfidx] += f" + {flux_var}{self.lang.lb}{ioff + i}{self.lang.rb}"
sode = ""
for name, expr in ode.items():
- sode += f"{derivative_var}{lb}{name}{rb} {assign_op} {expr}{lend}\n"
+ sode += f"{derivative_var}{self.lang.lb}{name}{self.lang.rb} {self.lang.assignment_op} {expr}{self.lang.line_end}\n"
return sode
@@ -793,7 +643,7 @@ def get_dedt(self, specific_eint: bool = False, norm: int = 0) -> str:
str
Single-expression code string (no assignment or line terminator).
"""
- expr = self.code_gen(
+ expr = self.lang.code_gen(
self.__gen_sdedt(specific_eint, norm),
strict=False,
allow_unknown_functions=True,
@@ -865,7 +715,9 @@ def get_indexed_odes(
for var, expr in replacements:
match = pattern.search(str(var))
idx: int = int(match.group(0)) if match is not None else 0
- expr = self.code_gen(expr, strict=False, allow_unknown_functions=True)
+ expr = self.lang.code_gen(
+ expr, strict=False, allow_unknown_functions=True
+ )
ir["extras"]["cse"].append(IndexedValue([idx], expr))
# Switch to CSE-reduced forms for the main expression list
@@ -874,11 +726,12 @@ def get_indexed_odes(
for i, expr in enumerate(
jaff_progress.track(ode_symbols, description="Generating ode code")
):
- expr = self.code_gen(expr, strict=False, allow_unknown_functions=True)
+ expr = self.lang.code_gen(expr, strict=False, allow_unknown_functions=True)
ir["expressions"].append(IndexedValue([i], expr))
return ir
+ @scoped_tokens("lang")
def get_ode_str(
self,
idx_offset: int = 0,
@@ -924,14 +777,11 @@ def get_ode_str(
str
Multi-line string of ODE assignments, including any CSE temporaries.
"""
- ioff = idx_offset if idx_offset >= 0 else self.ioff
+ ioff = idx_offset if idx_offset >= 0 else self.lang.idx_offset
prefix = (
def_prefix
- or f"{self.extras.get('type_qualifier', '')}{self.types.get('double', '')}"
+ or f"{self.lang.extras.get('type_qualifier', '')}{self.lang.types.get('double', '')}"
)
- lb, rb = brac_format or (self.lb, self.rb)
- assign_op = assignment_op or self.assignment_op
- lend = line_end or self.line_end
ode_code: str = ""
ode_expressions = self.get_indexed_odes(use_cse=use_cse, cse_var=cse_var)
@@ -940,11 +790,11 @@ def get_ode_str(
if use_cse:
for idx, expression in ode_expressions["extras"]["cse"]:
_idx = idx[0]
- ode_code += f"{prefix}{cse_var}{_idx} {assign_op} {expression}{lend}\n"
+ ode_code += f"{prefix}{cse_var}{_idx} {self.lang.assignment_op} {expression}{self.lang.line_end}\n"
for idx, expression in ode_expressions["expressions"]:
_idx = idx[0]
- ode_code += f"{ode_var}{lb}{ioff + _idx}{rb} {assign_op} {expression}{lend}\n"
+ ode_code += f"{ode_var}{self.lang.lb}{ioff + _idx}{self.lang.rb} {self.lang.assignment_op} {expression}{self.lang.line_end}\n"
return ode_code
@@ -1030,7 +880,9 @@ def get_indexed_rhs(
for var, expr in replacements:
match = pattern.search(str(var))
idx: int = int(match.group(0)) if match is not None else 0
- expr = self.code_gen(expr, strict=False, allow_unknown_functions=True)
+ expr = self.lang.code_gen(
+ expr, strict=False, allow_unknown_functions=True
+ )
ir["extras"]["cse"].append(IndexedValue([idx], expr))
rhs_symbols = reduced_exprs
@@ -1038,11 +890,12 @@ def get_indexed_rhs(
for i, expr in enumerate(
jaff_progress.track(rhs_symbols, description="Generating RHS code")
):
- expr = self.code_gen(expr, strict=False, allow_unknown_functions=True)
+ expr = self.lang.code_gen(expr, strict=False, allow_unknown_functions=True)
ir["expressions"].append(IndexedValue([i], expr))
return ir
+ @scoped_tokens("lang")
def get_rhs_str(
self,
idx_offset: int = 0,
@@ -1102,14 +955,11 @@ def get_rhs_str(
str
Multi-line string of all RHS assignments including CSE temporaries.
"""
- ioff = idx_offset if idx_offset >= 0 else self.ioff
+ ioff = idx_offset if idx_offset >= 0 else self.lang.idx_offset
prefix = (
def_prefix
- or f"{self.extras.get('type_qualifier', '')}{self.types.get('double', '')}"
+ or f"{self.lang.extras.get('type_qualifier', '')}{self.lang.types.get('double', '')}"
)
- lb, rb = brac_format or (self.lb, self.rb)
- assign_op = assignment_op or self.assignment_op
- lend = line_end or self.line_end
rhs_code = ""
rhs_expressions = self.get_indexed_rhs(
@@ -1125,11 +975,11 @@ def get_rhs_str(
if use_cse:
for idx, expression in rhs_expressions["extras"]["cse"]:
_idx = idx[0]
- rhs_code += f"{prefix}{cse_var}{_idx} {assign_op} {expression}{lend}\n"
+ rhs_code += f"{prefix}{cse_var}{_idx} {self.lang.assignment_op} {expression}{self.lang.line_end}\n"
for idx, expression in rhs_expressions["expressions"]:
_idx = idx[0]
- rhs_code += f"{ode_var}{lb}{ioff + _idx}{rb} {assign_op} {expression}{lend}\n"
+ rhs_code += f"{ode_var}{self.lang.lb}{ioff + _idx}{self.lang.rb} {self.lang.assignment_op} {expression}{self.lang.line_end}\n"
return rhs_code
@@ -1181,7 +1031,9 @@ def get_indexed_radodes(
for var, expr in replacements:
match = pattern.search(str(var))
idx: int = int(match.group(0)) if match is not None else 0
- expr = self.code_gen(expr, strict=False, allow_unknown_functions=True)
+ expr = self.lang.code_gen(
+ expr, strict=False, allow_unknown_functions=True
+ )
ir["extras"]["cse"].append(IndexedValue([idx], expr))
radode_symbols = reduced_exprs
@@ -1191,11 +1043,12 @@ def get_indexed_radodes(
radode_symbols, description="Generating radiaton ode code"
)
):
- expr = self.code_gen(expr, strict=False, allow_unknown_functions=True)
+ expr = self.lang.code_gen(expr, strict=False, allow_unknown_functions=True)
ir["expressions"].append(IndexedValue([i], expr))
return ir
+ @scoped_tokens("lang")
def get_radode_str(
self,
idx_offset: int = 0,
@@ -1242,15 +1095,11 @@ def get_radode_str(
Multi-line string of radiation ODE assignments including any CSE
temporaries.
"""
- # Set overrides
- ioff = idx_offset if idx_offset >= 0 else self.ioff
+ ioff = idx_offset if idx_offset >= 0 else self.lang.idx_offset
prefix = (
def_prefix
- or f"{self.extras.get('type_qualifier', '')}{self.types.get('double', '')}"
+ or f"{self.lang.extras.get('type_qualifier', '')}{self.lang.types.get('double', '')}"
)
- lb, rb = brac_format or (self.lb, self.rb)
- assign_op = assignment_op or self.assignment_op
- lend = line_end or self.line_end
radode_code: str = ""
radode_expressions = self.get_indexed_radodes(order, use_cse, cse_var)
@@ -1258,13 +1107,11 @@ def get_radode_str(
if use_cse:
for idx, expression in radode_expressions["extras"]["cse"]:
_idx = idx[0]
- radode_code += f"{prefix}{cse_var}{_idx} {assign_op} {expression}{lend}\n"
+ radode_code += f"{prefix}{cse_var}{_idx} {self.lang.assignment_op} {expression}{self.lang.line_end}\n"
for idx, expression in radode_expressions["expressions"]:
_idx = idx[0]
- radode_code += (
- f"{radode_var}{lb}{ioff + _idx}{rb} {assign_op} {expression}{lend}\n"
- )
+ radode_code += f"{radode_var}{self.lang.lb}{ioff + _idx}{self.lang.rb} {self.lang.assignment_op} {expression}{self.lang.line_end}\n"
return radode_code
@@ -1458,7 +1305,7 @@ def get_indexed_jacobian(
def _replace_y(match: re.Match[str], var) -> str:
"""Regex replacement helper: ``y_N`` → ``var[N]``."""
idx = int(match.group(1))
- return f"{var}{self.lb}{idx}{self.rb}"
+ return f"{var}{self.lang.lb}{idx}{self.lang.rb}"
if use_cse:
with jaff_progress.indeterminate("Generating cse expressions"):
@@ -1479,7 +1326,7 @@ def _replace_y(match: re.Match[str], var) -> str:
expr = self.__convert_unknown_derivatives(expr, replacements_dict)
match = pattern.search(str(var))
idx: int = int(match.group(0)) if match is not None else 0
- expr_str = self.code_gen(
+ expr_str = self.lang.code_gen(
expr, strict=False, allow_unknown_functions=True
)
# Back-substitute scalar symbols to array notation
@@ -1515,7 +1362,9 @@ def _replace_y(match: re.Match[str], var) -> str:
expr = self.__convert_unknown_derivatives(
expr, replacements_dict if use_cse else None
)
- expr_str = self.code_gen(expr, strict=False, allow_unknown_functions=True)
+ expr_str = self.lang.code_gen(
+ expr, strict=False, allow_unknown_functions=True
+ )
# Back-substitute scalar y_i -> nden[i] and radiation symbols
expr_str = dpattern.sub(lambda m: _replace_y(m, "nden"), expr_str)
@@ -1534,6 +1383,7 @@ def _replace_y(match: re.Match[str], var) -> str:
return ir
+ @scoped_tokens("lang")
def get_jacobian_str(
self,
use_dedt: bool = False,
@@ -1587,35 +1437,14 @@ def get_jacobian_str(
Raises
------
- ValueError
+ InvalidLanguageError
If *matrix_format* is not a supported format string.
"""
- ioff = idx_offset if idx_offset >= 0 else self.ioff
+ ioff = idx_offset if idx_offset >= 0 else self.lang.idx_offset
prefix = (
var_prefix
- or f"{self.extras.get('type_qualifier', '')}{self.types.get('double', '')}"
- )
-
- __matrix_formats = self.__get_matrix_formats()
- if matrix_format and matrix_format not in __matrix_formats.keys():
- raise ValueError(
- f"\n\nUnsupported matrix format: '{matrix_format}'"
- f"\nSupported matrix formats: {[key for key in __matrix_formats]}\n"
- )
-
- mlb, mrb = (
- (self.mlb, self.mrb)
- if not matrix_format
- else (
- __matrix_formats[matrix_format]["brac"][0],
- __matrix_formats[matrix_format]["brac"][1],
- )
- )
- matrix_sep: str = (
- __matrix_formats[matrix_format]["sep"] if matrix_format else self.matrix_sep
+ or f"{self.lang.extras.get('type_qualifier', '')}{self.lang.types.get('double', '')}"
)
- assign_op = assignment_op or self.assignment_op
- lend = line_end or self.line_end
jac_expressions = self.get_indexed_jacobian(
cse_var=cse_var, use_cse=use_cse, use_dedt=use_dedt
@@ -1626,11 +1455,11 @@ def get_jacobian_str(
if use_cse:
for idx, expr in jac_expressions["extras"]["cse"]:
_idx = idx[0]
- jac_code += f"{prefix}{cse_var}{_idx} {assign_op} {expr}{lend}\n"
+ jac_code += f"{prefix}{cse_var}{_idx} {self.lang.assignment_op} {expr}{self.lang.line_end}\n"
# Generate Jacobian code without CSE
for [i, j], expr in jac_expressions["expressions"]:
- jac_code += f"{jac_var}{mlb}{ioff + i}{matrix_sep}{ioff + j}{mrb} {assign_op} {expr}{lend}\n"
+ jac_code += f"{jac_var}{self.lang.mlb}{ioff + i}{self.lang.sep}{ioff + j}{self.lang.mrb} {self.lang.assignment_op} {expr}{self.lang.line_end}\n"
return jac_code
@@ -1835,228 +1664,3 @@ def __get_sym_eos(gamma: float = 1.6666666666667) -> sp.Expr:
tgas = sp.symbols("tgas")
return _R / (gamma - 1) * tgas
-
- @staticmethod
- @cache
- def __get_language_aliases() -> dict[str, str]:
- """Return the mapping from user-facing language aliases to canonical names.
-
- Allows callers to use common shorthand spellings (``"c++"``, ``"py"``,
- ``"rs"``, ``"f90"``, …) and normalise them to the internal canonical
- name used as a key in :meth:`get_language_tokens`.
-
- Returns
- -------
- dict[str, str]
- Mapping of alias -> canonical language name.
- """
- aliases: dict[str, str] = {
- "c++": "cxx",
- "cpp": "cxx",
- "cxx": "cxx",
- "c": "c",
- "fortran": "fortran",
- "f90": "fortran",
- "python": "python",
- "py": "python",
- "rust": "rust",
- "rs": "rust",
- "julia": "julia",
- "jl": "julia",
- "r": "r",
- }
-
- return aliases
-
- @staticmethod
- @cache
- def get_language_tokens() -> dict[str, LangModifier]:
- """Return the :class:`LangModifier` configuration for every supported language.
-
- Each entry in the returned dict captures the syntax conventions needed
- to emit valid code for that language: brackets, assignment operator,
- line terminator, 2-D matrix separator, SymPy code-gen function, index
- base offset, comment character, type keywords, and miscellaneous extras.
-
- The result is cached (via :func:`functools.cache`) because it is a
- pure constant table. Callers should use canonical language names
- (``"cxx"``, ``"c"``, ``"fortran"``, ``"python"``, ``"rust"``,
- ``"julia"``, ``"r"``) as keys; use :meth:`__get_language_aliases` to
- map user-facing aliases first.
-
- Returns
- -------
- dict[str, LangModifier]
- Mapping of canonical language name -> :class:`LangModifier`.
-
- Notes
- -----
- Index offsets:
- * ``0`` for 0-based languages: C, C++, Python, Rust.
- * ``1`` for 1-based languages: Fortran, Julia, R.
- """
- tokens: dict[str, LangModifier] = {
- "cxx": {
- "brac": "[]",
- "assignment_op": "=",
- "line_end": ";",
- "matrix_sep": "][",
- "code_gen": sp.cxxcode,
- "idx_offset": 0,
- "comment": "//",
- "types": {
- "int": "int ",
- "float": "float ",
- "double": "double ",
- "bool": "bool ",
- },
- "extras": {
- "type_qualifier": "const ",
- "class_specifier": "static ",
- },
- },
- # c
- "c": {
- "brac": "[]",
- "assignment_op": "=",
- "line_end": ";",
- "matrix_sep": "][",
- "code_gen": sp.ccode,
- "idx_offset": 0,
- "comment": "//",
- "types": {
- "int": "int ",
- "float": "float ",
- "double": "double ",
- "bool": "_Bool ",
- },
- "extras": {
- "type_qualifier": "const ",
- "class_specifier": "static ",
- },
- },
- "fortran": {
- "brac": "()",
- "assignment_op": "=",
- "line_end": "",
- "matrix_sep": ", ",
- "code_gen": sp.fcode,
- "idx_offset": 1,
- "comment": "!",
- "types": {},
- "extras": {
- "class_specifier": "save ",
- },
- },
- "python": {
- "brac": "[]",
- "assignment_op": "=",
- "line_end": "",
- "matrix_sep": "][",
- "code_gen": sp.pycode,
- "idx_offset": 0,
- "comment": "#",
- "types": {},
- "extras": {},
- },
- "rust": {
- "brac": "[]",
- "assignment_op": "=",
- "line_end": ";",
- "matrix_sep": "][",
- "code_gen": sp.rust_code,
- "idx_offset": 0,
- "comment": "//",
- "types": {
- "int": "i32 ",
- "float": "f32 ",
- "double": "f64 ",
- "bool": "bool ",
- },
- "extras": {
- "type_qualifier": "const ",
- "class_specifier": "",
- },
- },
- "julia": {
- "brac": "[]",
- "assignment_op": "=",
- "line_end": "",
- "matrix_sep": ", ",
- "code_gen": sp.julia_code,
- "idx_offset": 1,
- "comment": "#",
- "types": {
- "int": "Int64 ",
- "float": "Float32 ",
- "double": "Float64 ",
- "bool": "Bool ",
- },
- "extras": {
- "type_qualifier": "const ",
- "class_specifier": "",
- },
- },
- "r": {
- "brac": "[]",
- "assignment_op": "<-",
- "line_end": "",
- "matrix_sep": ", ",
- "code_gen": sp.rcode,
- "idx_offset": 1,
- "comment": "#",
- "types": {},
- "extras": {},
- },
- }
-
- return tokens
-
- @staticmethod
- @cache
- def __get_matrix_formats() -> dict[str, dict[str, str]]:
- """Return supported 2-D array bracket/separator format strings.
-
- Each entry maps a format key (as accepted by the *matrix_format*
- constructor argument) to a ``{"brac": "…", "sep": "…"}`` dict where
- ``brac`` is the two-character bracket pair and ``sep`` is the string
- inserted between the row and column indices.
-
- For example, ``"(,)"`` yields ``J(i, j)`` while ``"[]"`` yields
- ``J[i][j]``.
-
- Returns
- -------
- dict[str, dict[str, str]]
- Mapping of format key -> ``{"brac": str, "sep": str}``.
- """
- formats: dict[str, dict[str, str]] = {
- "()": {"brac": "()", "sep": ")("},
- "()()": {"brac": "()", "sep": ")("},
- "(,)": {"brac": "()", "sep": ", "},
- "[]": {"brac": "[]", "sep": "]["},
- "[][]": {"brac": "[]", "sep": "]["},
- "[,]": {"brac": "[]", "sep": ", "},
- "{}": {"brac": "{}", "sep": "}{"},
- "{}{}": {"brac": "{}", "sep": "}{"},
- "{,}": {"brac": "{}", "sep": ", "},
- "<>": {"brac": "<>", "sep": "><"},
- "<><>": {"brac": "<>", "sep": "><"},
- "<,>": {"brac": "<>", "sep": ", "},
- }
- return formats
-
- @staticmethod
- @cache
- def __get_bracket_formats() -> list[str]:
- """Return the list of supported 1-D array bracket styles.
-
- Returns
- -------
- list[str]
- Each string is a two-character bracket pair accepted by the
- *brac_format* constructor argument.
- """
- formats: list[str] = ["()", "{}", "[]", "<>"]
-
- return formats
diff --git a/src/jaff/common/_helper.py b/src/jaff/common/_helper.py
index 96be9083..a4691989 100644
--- a/src/jaff/common/_helper.py
+++ b/src/jaff/common/_helper.py
@@ -26,8 +26,8 @@
from ..errors import ParserError
if TYPE_CHECKING:
- from ..core._auxiliary_engine import FunctionsDict
from ..core._typing import ElementProps
+ from ..core.parsers.auxiliary_func._typing import AuxiliaryFunctionsDict
# ---------------------------------------------------------------------------
# File-extension groups used by parsers and code generators
@@ -202,7 +202,7 @@ def dfs(sym: str):
def resolve_dependencies(
expr: Basic,
subs_dict: dict[Basic, Basic] | None = None,
- aux_funcs: dict[str, FunctionsDict] | None = None,
+ aux_funcs: dict[str, AuxiliaryFunctionsDict] | None = None,
) -> Expr:
"""
Resolve undefined SymPy function calls inside a single expression.
@@ -224,7 +224,7 @@ def resolve_dependencies(
subs_dict : dict[sympy.Basic, sympy.Basic] or None, optional
Pre-populated substitution table. Modified in-place as new
substitutions are discovered. Defaults to an empty dict.
- aux_funcs : dict[str, FunctionsDict] or None, optional
+ aux_funcs : dict[str, AuxiliaryFunctionsDict] or None, optional
Auxiliary function definitions keyed by lowercase function name.
Each entry must have ``"def"`` (the body expression) and ``"args"``
(the ordered parameter list). Defaults to an empty dict.
@@ -315,3 +315,28 @@ def load_module_from_path(path: str | Path, module_name: str):
spec.loader.exec_module(module)
return module
+
+
+def import_subpackages(package_name: str) -> None:
+ """Import every non-private subpackage of *package_name*.
+
+ Iterates the package directory and imports each non-private subpackage,
+ which triggers any registration side-effects (e.g. the ``@register``
+ decorator in ``_formats``).
+
+ Uses :func:`Path.iterdir` (not :func:`pkgutil.walk_packages`) to avoid
+ eagerly importing private subpackages while walking — a pitfall of
+ :func:`pkgutil.walk_packages` which calls :func:`__import__` internally
+ for every package it encounters.
+
+ Args:
+ package_name: Fully-qualified package name
+ (e.g. ``"jaff.core.parsers.network._formats"``).
+ """
+ from importlib import import_module
+
+ pkg = import_module(package_name)
+ pkg_path = Path(pkg.__path__[0])
+ for entry in pkg_path.iterdir():
+ if entry.is_dir() and not entry.name.startswith("_"):
+ import_module(f"{package_name}.{entry.name}")
diff --git a/src/jaff/config/__init__.py b/src/jaff/config/__init__.py
index fb525f79..42c10170 100644
--- a/src/jaff/config/__init__.py
+++ b/src/jaff/config/__init__.py
@@ -1,21 +1,25 @@
from ._config import (
CONFIG_DIR,
DATA_DIR,
+ DB_DIR,
JAFF_DIR,
NETWORK_DIR,
SHIELDING_DATA_DIR,
SHIELDING_FUNCTIONS_DIR,
SRC_DIR,
+ TEMPLATES_DIR,
XSECS_DATA_DIR,
)
__all__ = [
CONFIG_DIR,
DATA_DIR,
+ DB_DIR,
JAFF_DIR,
NETWORK_DIR,
SHIELDING_DATA_DIR,
SHIELDING_FUNCTIONS_DIR,
SRC_DIR,
+ TEMPLATES_DIR,
XSECS_DATA_DIR,
]
diff --git a/src/jaff/config/_config.py b/src/jaff/config/_config.py
index 0e17330e..d3d2a1c9 100644
--- a/src/jaff/config/_config.py
+++ b/src/jaff/config/_config.py
@@ -3,8 +3,10 @@
CONFIG_DIR = Path(__file__).resolve().parent
JAFF_DIR = CONFIG_DIR.parent
SRC_DIR = JAFF_DIR.parent
-NETWORK_DIR = SRC_DIR.parent / "network"
+NETWORK_DIR = SRC_DIR.parent / "networks"
DATA_DIR = JAFF_DIR / "data"
XSECS_DATA_DIR = DATA_DIR / "xsecs"
SHIELDING_DATA_DIR = DATA_DIR / "shielding"
SHIELDING_FUNCTIONS_DIR = JAFF_DIR / "physics" / "photo_reactions" / "shielding"
+TEMPLATES_DIR = JAFF_DIR / "templates"
+DB_DIR = JAFF_DIR / "db"
diff --git a/src/jaff/core/_network_engine.py b/src/jaff/core/_network_engine.py
deleted file mode 100644
index 17f089b9..00000000
--- a/src/jaff/core/_network_engine.py
+++ /dev/null
@@ -1,1147 +0,0 @@
-"""Low-level reaction network file parser for multiple astrochemical formats.
-
-``NetworkParser`` reads a single network file and converts each reaction line
-into a format-independent ``parsedListProps`` dict with keys:
-
-- ``"r"`` — list of reactant name strings
-- ``"p"`` — list of product name strings
-- ``"tmin"`` — lower temperature bound in Kelvin, or ``None``
-- ``"tmax"`` — upper temperature bound in Kelvin, or ``None``
-- ``"rate"`` — rate expression as a Python/SymPy-compatible string
-- ``"string"`` — the original network-file line (for error reporting)
-
-Supported file formats
-----------------------
-The parser auto-detects the format from line patterns. KROME ``@format:`` /
-``@var:`` headers and the PRIZMO ``VARIABLES { }`` block are matched first;
-reaction lines are then matched in this priority order:
-
-1. **PRIZMO** — arrow-notation (``->``) with optional temperature range in
- ``[tmin, tmax]`` brackets. Variables in a ``VARIABLES { }`` block.
-2. **UDFA** — colon-delimited, fixed-column database from the UMIST project.
-3. **KROME** — comma-separated, declared via ``@format:`` header.
- Variable aliases via ``@var:``.
-4. **UCLCHEM** — comma-separated with a ``NAN`` sentinel, includes grain
- surface reactions.
-5. **KIDA** — fixed-width column format from the KIDA database.
-
-Rate normalization
-------------------
-After parsing, all rate strings are lower-cased. Known format-specific
-symbols (``user_crflux``, ``user_av``, Fortran exponent notation ``d``,
-temperature shortcuts ``t32``, ``invtgas``, etc.) are replaced with canonical
-JAFF symbols before the strings are passed to SymPy for sympification.
-"""
-
-import logging
-import re
-from pathlib import Path
-from typing import Callable
-
-from sympy import Basic, parse_expr
-
-from ..common import f90_convert, resolve_symbolic_dependencies
-from ..errors import ParserError
-from ..io import JaffLogger, jaff_progress
-from ._typing import (
- networkFormatProps,
- parsedListProps,
- patternProps,
-)
-
-
-class NetworkParser:
- """Auto-detecting parser for astrochemical reaction network files.
-
- On construction the file is read, reactions are extracted, and rate
- strings are normalised. Use as a context manager to ensure internal
- pattern state is freed after use.
-
- Parameters
- ----------
- file : str | Path
- Path to the network file.
- logger : logging.Logger | None, optional
- Logger instance. A new JAFF logger is created if ``None``.
-
- Raises
- ------
- ValueError
- If *file* is not a ``str`` or ``Path``.
- FileNotFoundError
- If *file* does not exist on disk.
- ParserError
- On syntax errors encountered while parsing the file.
- """
-
- def __init__(self, file: str | Path, logger: logging.Logger | None = None):
- """Parse *file* and prepare the internal parsed-reaction list.
-
- Parameters
- ----------
- file : str | Path
- Path to the network file.
- logger : logging.Logger | None, optional
- External logger. Defaults to a new JAFF logger.
- """
- if isinstance(file, str):
- file = Path(file)
- if not isinstance(file, (str, Path)):
- raise ValueError(f"Invalid file type detected for {file}: {type(file)}")
-
- file = file.resolve()
- if not file.exists():
- raise FileNotFoundError(file)
-
- self.__file: Path = file
- self.__logger: logging.Logger = logger or JaffLogger().get_logger()
- self.__line: str = ""
- self.__nline: int = 0
- self.__globals: dict[str, Basic] = {}
- self.__matched_group: None | re.Match = None
- self.__local_pattern: None | re.Pattern = None
- self.__matched_handler: None | Callable[..., None] = None
- # Pre-populate well-known Fortran/KROME shorthand symbols as SymPy aliases.
- self.__set_known_replacments()
-
- self.__format_props: networkFormatProps = {
- "prizmo": {"parse_vars": False},
- "krome": {
- "format_nline": 0, # line where @format was declared (0 = not yet seen)
- "idx": True,
- "nreact": 3,
- "nprod": 4,
- "tmin": True,
- "tmax": True,
- "rate": True,
- },
- }
- self.__valid_patterns = self.__global_patterns_dict()
- self.__parsed_list: list[parsedListProps] = []
-
- self.__parse_file()
- self.__normalize_rates()
- self.__globals = resolve_symbolic_dependencies(self.__globals, fname=self.__file)
-
- def __enter__(self) -> "NetworkParser":
- """Return self when entering a ``with`` block.
-
- Returns
- -------
- NetworkParser
- """
- return self
-
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
- """Free compiled regex patterns on context manager exit."""
- self.__valid_patterns.clear()
-
- return
-
- def get_parsed(self) -> tuple[list[parsedListProps], dict[str, Basic]]:
- """Return the parsed reaction list and resolved global variable map.
-
- Returns
- -------
- tuple[list[parsedListProps], dict[str, Basic]]
- - ``list[parsedListProps]``: one dict per reaction with keys
- ``"r"``, ``"p"``, ``"tmin"``, ``"tmax"``, ``"rate"``,
- ``"string"``.
- - ``dict[str, Basic]``: global symbolic constants defined in the
- file (e.g. ``@var`` entries), with all inter-dependencies
- resolved via SymPy substitution.
- """
- return self.__parsed_list, resolve_symbolic_dependencies(
- dep_map=self.__globals, fname=self.__file
- )
-
- def __parse_file(self) -> None:
- """Read the network file line-by-line and dispatch each line for parsing.
-
- Iterates over every line of :attr:`__file`, advancing the line counter
- and calling :meth:`__parse_line` for each.
- """
- with open(self.__file, "r") as f:
- lines = f.readlines()
- for i, line in enumerate(
- jaff_progress.track(lines, description=f"Parsing {self.__file.name}")
- ):
- self.__nline = i + 1
- self.__line = line
- self.__parse_line()
-
- def __parse_line(self) -> None:
- """Match the current line against all known patterns and invoke the handler.
-
- Iterates through :attr:`__valid_patterns` in priority order. The first
- global regex that matches determines the local regex and handler. If no
- pattern matches the line is silently skipped.
- """
- if not self.__line.strip():
- return
- for _, pattern_dict in self.__valid_patterns.items():
- if match := pattern_dict["global_re"].match(self.__line):
- self.__matched_group = match
- self.__local_pattern = pattern_dict["local_re"]
- self.__matched_handler = pattern_dict["handler"]
- break
-
- if self.__matched_handler is not None:
- self.__matched_handler()
- self.__matched_group = None
- self.__local_pattern = None
- self.__matched_handler = None
-
- def __raise_error(self, message: str, **kwargs) -> None:
- """Raise a :exc:`~jaff.errors.ParserError` with current file/line context.
-
- Parameters
- ----------
- message : str
- Human-readable description of the error.
- **kwargs
- Extra keyword arguments forwarded to :class:`~jaff.errors.ParserError`.
-
- Raises
- ------
- ParserError
- Always raised.
- """
- raise ParserError(message, self.__line, self.__nline, self.__file, **kwargs)
-
- def __handle_krome_format(self) -> None:
- """Parse a KROME ``@format:`` header line and update the format descriptor.
-
- Updates ``__format_props["krome"]`` with the field counts and flags
- detected in the format declaration, then rebuilds ``__valid_patterns``
- so subsequent reaction lines are matched with the correct column counts.
-
- Raises
- ------
- ParserError
- Via :meth:`__handle_krome_format_errors` if the format line is
- malformed.
- """
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__handle_krome_format_errors()
-
- self.__format_props["krome"] = {
- "format_nline": self.__nline,
- "idx": bool(match.group("idx")),
- "nreact": match.group("reactants").lower().count("r"),
- "nprod": match.group("products").lower().count("p"),
- "tmin": bool(match.group("tmin")),
- "tmax": bool(match.group("tmax")),
- "rate": bool(match.group("rate")),
- }
-
- self.__valid_patterns = self.__global_patterns_dict()
-
- def __handle_krome_format_errors(self):
- """Raise a descriptive error for a malformed KROME ``@format:`` line.
-
- Raises
- ------
- ParserError
- With a message explaining the specific formatting problem detected.
- """
- assert self.__matched_group is not None
- format = self.__matched_group.group("format")
- if format is None:
- self.__raise_error("Empty @format KROME declerative")
-
- format = format.strip()
- if not format:
- self.__raise_error("Empty @format KROME declerative")
-
- if "," not in format:
- self.__raise_error(
- "Invalid @format KROME declerative\n"
- "@format decelerative must be separated by ','"
- )
-
- expected_tokens = {"idx", "R", "P", "tmin", "tmax", "rate"}
- tokens = [token.strip() for token in format.split(",")]
- for token in tokens:
- if token not in expected_tokens:
- self.__raise_error(
- f"Invalid token in krome format: {token}\n"
- f"Supported tokens are {','.join(expected_tokens)}"
- )
-
- self.__raise_error("Invalid @format KROME declerative")
-
- def __handle_krome_var(self) -> None:
- """Parse a KROME ``@var:`` directive and store the symbolic expression.
-
- Logs a warning and skips the variable when the expression is not valid
- SymPy syntax (rather than raising a hard error).
-
- Raises
- ------
- ParserError
- Via :meth:`__handle_krome_var_errors` if the line structure is
- invalid before attempting SymPy evaluation.
- """
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__raise_error("Invalid KROME variable assignment detected")
-
- try:
- self.__globals[match.group("var").lower()] = parse_expr(
- f90_convert(match.group("expr").lower())
- )
- except (SyntaxError, NameError, TypeError):
- self.__logger.warning(
- f"Skipping variable: {match.group('var')}\n"
- f"at line: {self.__nline} since the expression is invalid sympy syntax"
- )
-
- def __handle_krome_var_errors(self):
- """Raise a descriptive error for a malformed KROME ``@var:`` line.
-
- Raises
- ------
- ParserError
- With a message describing the specific structural problem detected.
- """
- assert self.__matched_group is not None
- segment = self.__matched_group.group("segment")
-
- if segment is None:
- self.__raise_error("Empty segment after KROME @var declerative")
-
- segment = segment.strip()
- if not segment:
- self.__raise_error("Empty segment after KROME @var declerative")
-
- if "=" not in segment:
- self.__raise_error(
- "Invalid KROME @var declerative\n"
- "@var declerative must follow format: @var: varname=expression"
- )
-
- var_name, expr = segment.split("=", 1)
- var_name = var_name.strip()
- expr = expr.strip()
-
- if not var_name:
- self.__raise_error(
- "Invalid KROME @var declerative\nVariable name cannot be empty"
- )
-
- if not expr:
- self.__raise_error(
- "Invalid KROME @var declerative\nExpression cannot be empty"
- )
-
- self.__raise_error("Invalid KROME @var declerative")
-
- def __handle_prizmo_vars(self) -> None:
- """Handle a PRIZMO ``VARIABLES { }`` block line or variable assignment.
-
- Toggles ``__format_props["prizmo"]["parse_vars"]`` on ``VARIABLES {``
- and ``}`` tokens, and stores a parsed SymPy expression for any
- ``var = expr`` line encountered while inside the block.
-
- Raises
- ------
- ParserError
- Via :meth:`__handle_prizmo_vars_errors` if the line is malformed.
- """
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__handle_prizmo_vars_errors()
-
- assert match is not None
-
- if match.group("begin"):
- self.__format_props["prizmo"]["parse_vars"] = True
- return
-
- if match.group("end"):
- self.__format_props["prizmo"]["parse_vars"] = False
- return
-
- if (
- match.group("var")
- and match.group("expr")
- and self.__format_props["prizmo"]["parse_vars"]
- ):
- try:
- self.__globals[match.group("var").lower()] = parse_expr(
- f90_convert(match.group("expr").lower())
- )
-
- except (SyntaxError, NameError, TypeError):
- self.__logger.warning(
- f"Skipping variable: {match.group('var')}\n"
- f"at line: {self.__nline} since the expression is invalid sympy syntax"
- )
-
- def __handle_prizmo_vars_errors(self) -> None:
- """Raise a descriptive error for a malformed PRIZMO variables section line.
-
- Raises
- ------
- ParserError
- With a message describing the specific structural problem detected.
- """
- assert self.__matched_group is not None
- segment = self.__matched_group.group("segment")
- assignment = self.__matched_group.group("assignment")
-
- if segment is None and assignment is None:
- self.__raise_error("Invalid PRIZMO variable section")
-
- if assignment is not None:
- if not self.__format_props["prizmo"]["parse_vars"]:
- self.__raise_error(
- "PRIZMO variable assignment found outside VARIABLES block"
- )
-
- var_name, expr = assignment.split("=", 1)
- var_name = var_name.strip()
- expr = expr.strip()
-
- if not var_name.isidentifier():
- self.__raise_error(f"Invalid variable name '{var_name}'")
-
- if not expr:
- self.__raise_error("Expression cannot be empty")
-
- segment = segment.strip()
- if segment:
- self.__raise_error("Extra characters found after PRIZMO block declarative")
-
- def __handle_prizmo(self):
- """Parse a PRIZMO-format reaction line and append it to the parsed list.
-
- Extracts reactants, products, optional temperature bounds, and rate
- expression from the ``R1 + R2 -> P1 + P2 [tmin, tmax] rate`` pattern.
- Applies species-name normalisation (``HE`` → ``He``, ``E`` → ``e-``,
- ``GRAIN0`` → ``GRAIN``) and converts ``user_crflux``/``user_av``
- aliases to canonical JAFF symbols.
-
- Raises
- ------
- ParserError
- Via :meth:`__handle_prizmo_errors` if the line does not match
- the expected PRIZMO format.
- """
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__handle_prizmo_errors()
-
- reactants: str = match.group("reactants")
- products: str = match.group("products")
- tmin: str | None = match.group("tmin")
- tmax: str | None = match.group("tmax")
- rate: str = match.group("rate").strip()
-
- reactants = (
- reactants.replace("HE", "He")
- .replace(" E", " e-")
- .replace("E ", "e- ")
- .replace("GRAIN0", "GRAIN")
- )
- products = (
- products.replace("HE", "He")
- .replace(" E", " e-")
- .replace("E ", "e- ")
- .replace("GRAIN0", "GRAIN")
- )
-
- rr: list[str] = [r.strip() for r in reactants.split(" + ")]
- pp: list[str] = [p.strip() for p in products.split(" + ")]
-
- t_min: float | None = (
- float(tmin.strip().replace("d", "e")) if tmin and tmin.strip() else None
- )
- t_min = t_min if (t_min is not None and t_min > 0) else None
-
- t_max: float | None = (
- float(tmax.strip().replace("d", "e")) if tmax and tmax.strip() else None
- )
- t_max = t_max if (t_max is not None and t_max < 1e8) else None
-
- rate = rate.replace("user_crflux", "crate").replace("user_av", "av")
-
- self.__parsed_list.append(
- {
- "r": rr,
- "p": pp,
- "tmin": t_min,
- "tmax": t_max,
- "rate": rate,
- "string": self.__line.strip(),
- }
- )
-
- def __handle_prizmo_errors(self):
- """Raise an error for a malformed PRIZMO reaction line.
-
- Raises
- ------
- ParserError
- Always raised with a generic PRIZMO-format error message.
- """
- self.__raise_error("Invalid PRIZMO reaction detected")
-
- def __handler_krome(self):
- """Parse a KROME-format reaction line and append it to the parsed list.
-
- Extracts the index, reactants, products, temperature bounds, and rate
- expression from the comma-delimited KROME format. Applies species
- normalisation (``E``/``e`` → ``e-``, ``g`` → empty, ``HE`` → ``He``)
- and converts ``user_crflux``/``user_av`` aliases. Fortran exponent
- notation is converted to Python notation via :func:`~jaff.common.f90_convert`.
-
- Raises
- ------
- ParserError
- Via :meth:`__handle_krome_error` if the line structure is
- inconsistent with the declared KROME format.
- """
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__handle_krome_error()
-
- reactants: str = match.group("reactants")
- products: str = match.group("products")
- tmin: str = match.groupdict().get("tmin", "").strip().lower()
- tmax: str = match.groupdict().get("tmax", "").strip().lower()
- rate: str = match.groupdict().get("rate", "").strip()
-
- rr: list[str] = [r.strip() for r in reactants.split(",")[:-1]]
- pp: list[str] = [p.strip() for p in products.split(",")[:-1]]
-
- if len(rr) != self.__format_props["krome"]["nreact"]:
- self.__raise_error(
- "Invalid KROME line detected\n"
- f"Expected {self.__format_props['krome']['nreact']} reactants\n"
- f"from line {self.__format_props['krome']['format_nline']}.\n"
- f"Instead got {len(rr)} reactants"
- )
-
- if len(pp) != self.__format_props["krome"]["nprod"]:
- self.__raise_error(
- "Invalid KROME line detected\n"
- f"Expected {self.__format_props['krome']['nprod']} products \n"
- f"from line {self.__format_props['krome']['format_nline']}.\n"
- f"Instead got {len(pp)} products"
- )
-
- t_min: None | float = None
- t_max: None | float = None
-
- sp_reps = {"E": "e-", "e": "e-", "g": ""}
- rr = [sp_reps.get(r, r) for r in rr]
- pp = [sp_reps.get(p, p) for p in pp]
-
- sp_sreps = {"HE": "He"}
-
- for k, v in sp_sreps.items():
- rr = [x.replace(k, v) for x in rr]
- pp = [x.replace(k, v) for x in pp]
-
- rr = [r for r in rr if r != ""]
- pp = [p for p in pp if p != ""]
-
- tminmax_reps = {
- "d": "e",
- ".le.": "",
- ".ge.": "",
- ".lt.": "",
- ".gt.": "",
- ">": "",
- "<": "",
- }
-
- if tmin != "none" and tmin != "":
- for k, v in tminmax_reps.items():
- tmin = tmin.replace(k, v)
- t_min = float(tmin)
-
- if tmax != "none" and tmax != "":
- for k, v in tminmax_reps.items():
- tmax = tmax.replace(k, v)
- t_max = float(tmax)
-
- rate_reps = {
- "user_crflux": "crate",
- "user_crate": "crate",
- "user_av": "av",
- }
- for k, v in rate_reps.items():
- rate = rate.replace(k, v)
-
- rate = f90_convert(rate)
- if "auto" in rate:
- rate = rate.replace("auto", "PHOTO, 1e99")
-
- self.__parsed_list.append(
- {
- "r": rr,
- "p": pp,
- "tmin": t_min,
- "tmax": t_max,
- "rate": rate,
- "string": self.__line.strip(),
- }
- )
-
- def __handle_krome_error(self):
- """Raise a descriptive error for a malformed KROME reaction line.
-
- Diagnoses the most likely cause (wrong field count, wrong reactant or
- product count) before falling back to a generic error message.
-
- Raises
- ------
- ParserError
- With a message describing the detected inconsistency relative to
- the declared KROME format.
- """
- assert self.__matched_group is not None
-
- segment = self.__matched_group.group("segment").lower()
- props = self.__format_props["krome"]
- num_fields = (
- int(props["idx"])
- + props["nreact"]
- + props["nreact"]
- + int(props["tmin"])
- + int(props["tmax"])
- + int(props["rate"])
- )
- num_fields_detected: int = segment.count(",") + 1
-
- if num_fields != num_fields_detected:
- self.__raise_error(
- "Number of fields in KROME reaction doesn't match\n"
- f"Number of fields detected: {num_fields_detected}\n"
- f"Number of fields expected: {num_fields}\n"
- + (
- f"KROME format defined on line: {props['format_nline']}"
- if props["format_nline"]
- else ""
- )
- )
-
- if segment.count("r") != props["nreact"]:
- self.__raise_error(
- "Expected number of reactants did not match krome format\n"
- f"Number of reactants expected: {props['nreact']}\n"
- f"Number of reactants detected: {segment.count('r')}\n"
- + (
- f"KROME format defined on line: {props['format_nline']}"
- if props["format_nline"]
- else ""
- )
- )
-
- if segment.count("p") != props["nprod"]:
- self.__raise_error(
- "Expected number of products did not match krome format\n"
- f"Number of products expected: {props['nprod']}\n"
- f"Number of products detected: {props['nprod']}\n"
- + (
- f"KROME format defined on line: {props['format_nline']}"
- if props["format_nline"]
- else ""
- )
- )
-
- self.__raise_error("Invalid KROME reaction detected")
-
- def __handle_udfa(self):
- """Parse a UDFA (UMIST)-format reaction line and append it to the parsed list.
-
- Extracts the reaction type, reactants, products, rate parameters
- (``ka``, ``kb``, ``kc``), and temperature bounds from the
- colon-delimited UDFA format. Constructs a rate expression based on
- the reaction type: cosmic-ray (``"CR"``), photo-desorption (``"PH"``),
- or standard Arrhenius.
-
- Raises
- ------
- ParserError
- Via :meth:`__handle_udfa_errors` if the line does not match the
- expected UDFA format.
- """
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__handle_udfa_errors()
-
- ignore_species = {"CR", "CRP", "PHOTON", "CRPHOT", ""}
-
- rtype: str = match.group("rtype")
- reactants: str = match.group("reactants")
- products: str = match.group("products")
- ka: float = float(match.group("ka"))
- kb: float = float(match.group("kb"))
- kc: float = float(match.group("kc"))
- tmin: float = float(match.group("tmin"))
- tmax: float = float(match.group("tmax"))
-
- t_min: None | float = tmin if tmin > 0 else None
- t_max: None | float = tmax if tmax < 41000.0 else None
-
- rate_dict = {
- "CR": f"{kc:.2e} * crate",
- "PH": f"{ka:.2e} * exp(-{kc:.2f} * av)",
- }
- rate = f"{ka:.2e}"
- if kb:
- rate = f"{rate} * (tgas / 3e2)**({kb:.2f})"
- if kc:
- rate = f"{rate} * exp(-{kc:.2f} / tgas)"
-
- if rtype in rate_dict:
- rate = rate_dict[rtype]
-
- rr = [
- r.strip()
- for r in reactants.split(":")[:-1]
- if r.strip() not in ignore_species
- ]
- pp = [
- p.strip() for p in products.split(":")[:-1] if p.strip() not in ignore_species
- ]
-
- self.__parsed_list.append(
- {
- "r": rr,
- "p": pp,
- "tmin": t_min,
- "tmax": t_max,
- "rate": rate,
- "string": self.__line.strip(),
- }
- )
-
- def __handle_udfa_errors(self):
- """Raise an error for a malformed UDFA reaction line.
-
- Raises
- ------
- ParserError
- Always raised with a generic UDFA-format error message.
- """
- self.__raise_error("Invalid UDFA reaction detected")
-
- def __handle_uclchem(self):
- """Parse a UCLCHEM-format reaction line and append it to the parsed list.
-
- Extracts reactants, products, rate parameters, temperature bounds, and
- an extrapolation flag from the comma-delimited UCLCHEM format (identified
- by the ``NAN`` sentinel column). Species names are normalised via
- :meth:`__normalize_uclchem_species`.
-
- Raises
- ------
- ParserError
- Via :meth:`__handle_uclchem_errors` if the line does not match the
- expected UCLCHEM format.
- """
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__handle_uclchem_errors()
-
- reactants: str = match.group("reactants")
- products: str = match.group("products")
- ka: float = float(match.group("ka"))
- kb: float = float(match.group("kb"))
- kc: float = float(match.group("kc"))
- tmin: float = float(match.group("tmin"))
- tmax: float = float(match.group("tmax"))
- extrapolate: bool = match.group("extrapolate").strip().lower() == "true"
-
- ignore_species = {
- "CR",
- "CRP",
- "CRPHOT",
- "PHOTON",
- "NAN",
- "",
- "ER",
- "ERDES",
- "FREEZE",
- "H2FORM",
- "BULKSWAP",
- "DESCR",
- "DESOH2",
- "DEUVCR",
- "LH",
- "LHDES",
- "SURFSWAP",
- "THERM",
- }
-
- t_min: float = 3.0 if extrapolate else tmin
- t_max: float = 1e6 if extrapolate else tmax
-
- rr: list[str] = [
- self.__normalize_uclchem_species(r) for r in reactants.split(",")
- ]
- pp: list[str] = [
- self.__normalize_uclchem_species(p)
- for p in products.split(",")
- if p.strip().upper() not in ignore_species
- ]
-
- rate = "0.0"
- rate_dict = {
- "CRP": f"{ka:.2e} * crate",
- "CRPHOT": f"{ka:.2e} * (tgas/3e2)**({kb:.2f}) * crate",
- "PHOTON": f"{ka:.2e} * fuv * exp(-{kc:.2f} * av)",
- "FREEZE": f"(1e0 + {kb:.2e} * 1.671e-3/tgas/asize)*nuth*sigmah*sqrt(tgas/m)",
- }
- for r in rr:
- if r.upper() in rate_dict:
- rate = rate_dict[r.upper()]
- break
- rr = [r for r in rr if r.strip().upper() not in ignore_species]
-
- # FIXME: old parser sets rate = "0.0" at the very end
- rate = "0.0"
-
- self.__parsed_list.append(
- {
- "r": rr,
- "p": pp,
- "tmin": t_min,
- "tmax": t_max,
- "rate": rate,
- "string": self.__line.strip(),
- }
- )
-
- def __handle_uclchem_errors(self):
- """Raise an error for a malformed UCLCHEM reaction line.
-
- Raises
- ------
- ParserError
- Always raised with a generic UCLCHEM-format error message.
- """
- self.__raise_error("Invalid UCLCHEM reaction detected")
-
- def __handle_kida(self):
- """Parse a KIDA-format reaction line and append it to the parsed list.
-
- Extracts reactants, products, rate parameters (``ka``, ``kb``, ``kc``),
- temperature bounds, and formula index from the fixed-width KIDA column
- format. Rate expressions are selected from a formula dictionary keyed
- by the integer formula index (1–5).
-
- Raises
- ------
- ParserError
- Via :meth:`__handle_kida_errors` if the line does not match the
- expected KIDA format.
- """
- assert self.__local_pattern is not None
- match = self.__local_pattern.match(self.__line)
- if not match:
- self.__handle_kida_errors()
-
- reactants: str = match.group("reactants")
- products: str = match.group("products")
- ka: float = float(match.group("ka"))
- kb: float = float(match.group("kb"))
- kc: float = float(match.group("kc"))
- tmin: float = float(match.group("tmin"))
- tmax: float = float(match.group("tmax"))
- formula: int = int(match.group("formula"))
-
- t_min = tmin if tmin > 0 else None
- t_max = tmax if tmax < 9999.0 else None
-
- rr = [r.strip() for r in reactants.split() if r != "+"]
- pp = [p.strip() for p in products.split() if p != "+"]
- ignore_species = {"cr", "crp", "photon"}
- rates_dict = {
- 1: (
- f"{ka:.2e} * crate"
- if "CRP" not in rr
- else f"{ka:.2e} * crate * 2.0 * nH2 / nH"
- ),
- 2: f"{ka:.2e} * chi * exp(-{kc:.2e} * av)",
- 3: f"{ka:.2e}"
- + (f" * (tgas / 300) ** ({kb:.2e})" if kb != 0.0 else "")
- + (f" * exp(-{kc:.2f} / tgas)" if kc != 0.0 else ""),
- 4: f"{ka * kb:.2e} * (0.62 + 0.4767 * {kc:2e} * sqrt(300 / tgas))",
- 5: f"{ka * kb:.2e} * (1 + 0.0967 * {kc:.2e} * sqrt(300 / tgas) + {kc**2:.2e} * 3e2 / 10.526 / tgas)",
- }
- rate = rates_dict.get(formula, "0.0")
-
- self.__parsed_list.append(
- {
- "r": [r for r in rr if r.lower() not in ignore_species],
- "p": [p for p in pp if p.lower() not in ignore_species],
- "tmin": t_min,
- "tmax": t_max,
- "rate": rate,
- "string": self.__line.strip(),
- }
- )
-
- def __handle_kida_errors(self):
- """Raise an error for a malformed KIDA reaction line.
-
- Raises
- ------
- ParserError
- Always raised with a generic KIDA-format error message.
- """
- self.__raise_error("Invalid KIDA reaction detected")
-
- @staticmethod
- def __normalize_uclchem_species(s: str):
- """Normalise a UCLCHEM species token to the JAFF canonical form.
-
- Transformations applied:
- - ``#X`` → ``X_DUST`` (grain-surface species prefix)
- - ``@X`` → ``X_BULK`` (bulk ice species prefix)
- - ``E-`` → ``e-`` (electron lower-case)
- - ``HE`` → ``He``, ``SI`` → ``Si``, ``CL`` → ``Cl``, ``MG`` → ``Mg``
-
- Parameters
- ----------
- s : str
- Raw species token from the UCLCHEM file.
-
- Returns
- -------
- str
- Normalised species name.
- """
- s = s.strip()
- if s.startswith("#"):
- s = s[1:] + "_DUST"
- if s.startswith("@"):
- s = s[1:] + "_BULK"
- if s == "E-":
- s = "e-"
-
- reps = {"HE": "He", "SI": "Si", "CL": "Cl", "MG": "Mg"}
-
- for k, v in reps.items():
- s = s.replace(k, v)
-
- return s
-
- def __set_known_replacments(self) -> None:
- """Pre-populate ``__globals`` with canonical JAFF symbol aliases.
-
- Inserts SymPy expressions for common KROME/PRIZMO shorthand variables
- such as ``t32``, ``te``, ``invtgas``, and ``sqrtgas`` so that they are
- resolved automatically during rate normalization.
- """
- # Populate __globals with canonical SymPy aliases for common shorthand
- # symbols found in KROME/PRIZMO files. Order matters: compound aliases
- # (invt32, invte) must be listed before the simpler ones they depend on
- # so that resolve_symbolic_dependencies can substitute correctly.
- replacements = {
- "invt32": "1e0 / t32",
- "invte": "1e0 / te",
- "t32": "tgas/3e2",
- "te": "tgas*8.617343e-5",
- "invtgas": "1e0 / tgas",
- "sqrtgas": "sqrt(tgas)",
- "user_tdust": "tdust",
- "user_av": "av",
- "get_hnuclei(n)": "nh",
- "n(idx_h2)": "nh2",
- "n(idx_h)": "nh0",
- "n_global(idx_h2)": "nh2",
- }
-
- for k, v in replacements.items():
- self.__globals[k] = parse_expr(v)
-
- def __normalize_rates(self):
- """Lower-case all rate strings so SymPy ``parse_expr`` is case-insensitive."""
- for r in self.__parsed_list:
- assert isinstance(r["rate"], str)
- r["rate"] = r["rate"].lower()
-
- def __global_patterns_dict(self) -> dict[str, patternProps]:
- """Build the ordered pattern dictionary used to identify reaction-line formats.
-
- Each entry maps a format name to a ``patternProps`` dict with three keys:
-
- - ``"global_re"`` — compiled regex for quick line classification.
- - ``"local_re"`` — compiled regex for detailed field extraction.
- - ``"handler"`` — bound method called when the global pattern matches.
-
- The KROME local regex is rebuilt on every call so it reflects the
- current ``__format_props["krome"]`` column counts.
-
- Returns
- -------
- dict[str, patternProps]
- Ordered pattern mapping in priority order (KROME format header
- first, KIDA last).
- """
- patterns: dict = {
- "krome_format": {
- "global_re": r"^\s*@format\s*:(?P.*?)$",
- "local_re": (
- r"^\s*@format\s*:\s*"
- r"(?P(?i:idx)\s*,\s*)?"
- r"(?P(?:(?i:R)\s*,\s*)+)"
- r"(?P(?:(?i:P)\s*,\s*)+)"
- r"(?P(?i:tmin)\s*,?\s*)?"
- r"(?P(?i:tmax)\s*,?\s*)?"
- r"(?P(?i:rate)\s*)?\s*$"
- ),
- "handler": self.__handle_krome_format,
- },
- "krome_var": {
- "global_re": r"^\s*@var\s*:(?P.*?)$",
- "local_re": (
- r"^\s*@var\s*:\s*"
- r"(?P\w+)"
- r"\s*=\s*"
- r"\s*(?P.*?)\s*$"
- ),
- "handler": self.__handle_krome_var,
- },
- "prizmo_vars": {
- "global_re": (
- r"^\s*(?:"
- r"(?:(?i:variables)\s*\{|\})(?P.*?)"
- r"|"
- r"(?P\w+\s*=.*?)"
- r")\s*$"
- ),
- "local_re": (
- r"^\s*(?P(?i:variables)\s*\{)\s*$"
- r"|"
- r"^\s*(?P\}\s*)$"
- r"|"
- r"^\s*(?P\w+)"
- r"\s*=\s*"
- r"\s*(?P.*?)\s*$"
- ),
- "handler": self.__handle_prizmo_vars,
- },
- "prizmo": {
- "global_re": r"^(?!\s*[!#]).*->.*$",
- "local_re": (
- r"^\s*"
- r"(?P[\w\+\-\s]+)"
- r"\s*->\s*"
- r"(?P[\w\+\-\s]+)"
- r"\s*\[\s*"
- r"(?P[^,\]]*)?"
- r"\s*,?\s*"
- r"(?P[^,\]]*)?"
- r"\s*\]\s*"
- r"(?P.*)"
- r"\s*$"
- ),
- "handler": self.__handle_prizmo,
- },
- "udfa": {
- "global_re": r"^(?!\s*[!#@]).*:.*$",
- "local_re": (
- r"^\s*\d+\s*:"
- r"\s*(?P[^:]*?)\s*:"
- r"\s*(?P(?:[^:]*:){2})"
- r"\s*(?P(?:[^:]*:){4})"
- r"\s*(?P[^:]*)\s*:"
- r"\s*(?P[^:]*)\s*:"
- r"\s*(?P[^:]*)\s*:"
- r"\s*(?P[^:]*)\s*:"
- r"\s*(?P[^:]*)\s*:"
- r"\s*(?P[^:]*?)(?:\s*:.*)?$"
- ),
- "handler": self.__handle_udfa,
- },
- "krome": {
- "global_re": (
- r"^(?!\s*[!#@])"
- r"(?!.*,\s*(?i:NAN)\s*(?:,|$))"
- r"(?=.*,)"
- r"(?P.*)$"
- ),
- "local_re": (
- r"^\s*"
- r"(?!.*,\s*(?i:NAN)\s*(?:,|$))"
- + (
- r"(?P[^,]*)\s*,\s*"
- if self.__format_props["krome"]["idx"]
- else ""
- )
- + rf"(?P(?:[^,]*\s*,\s*){{{self.__format_props['krome']['nreact']}}})"
- + rf"(?P(?:[^,]*\s*,\s*){{{self.__format_props['krome']['nprod']}}})"
- + (
- r"(?P[^,]*)\s*,\s*"
- if self.__format_props["krome"]["tmin"]
- else ""
- )
- + (
- r"(?P[^,]*)\s*,\s*"
- if self.__format_props["krome"]["tmax"]
- else ""
- )
- + (r"(?P.*)" if self.__format_props["krome"]["rate"] else "")
- + r"\s*$"
- ),
- "handler": self.__handler_krome,
- },
- "uclchem": {
- "global_re": (r"^(?!\s*[!]|(?:\s*#\s)).*,\s*(?i:NAN)\s*(?:,|$)"),
- "local_re": (
- r"^\s*"
- r"(?=.*,\s*(?i:NAN)\s*(?:,|$))"
- r"(?P(?:[#@\w\d\+-]*\s*,\s*){3})"
- r"(?P(?:[#@\w\d\+-]*\s*,\s*){4})"
- r"(?P[^,]*)\s*,\s*"
- r"(?P[^,]*)\s*,\s*"
- r"(?P[^,]*)\s*,\s*"
- r"(?P[^,]*)\s*,\s*"
- r"(?P[^,]*)\s*,\s*"
- r"(?P.*?)"
- r"\s*$"
- ),
- "handler": self.__handle_uclchem,
- },
- "kida": {
- "global_re": r"^(?!\s*[!#@]).{34}.{57}",
- "local_re": (
- r"^(?P.{34})"
- r"(?P.{57})"
- r"\s*(?P[^\s]+)"
- r"\s*(?P[^\s]+)"
- r"\s*(?P[^\s]+)"
- r"\s*[^\s]+\s*[^\s]+\s*[^\s]+\s*[^\s]+"
- r"\s*(?P[^\s]+)"
- r"\s*(?P[^\s]+)"
- r"\s*(?P[^\s]+)"
- r".*$"
- ),
- "handler": self.__handle_kida,
- },
- }
-
- return {
- key: {
- "global_re": re.compile(value["global_re"]),
- "local_re": re.compile(value["local_re"]),
- "handler": value["handler"],
- }
- for key, value in patterns.items()
- }
diff --git a/src/jaff/core/_typing/__init__.py b/src/jaff/core/_typing/__init__.py
index 44533653..75e3d723 100644
--- a/src/jaff/core/_typing/__init__.py
+++ b/src/jaff/core/_typing/__init__.py
@@ -1,25 +1,9 @@
-from ._auxiliary_engine import FunctionsDict
from ._elements import ElementProps
from ._network import NetworkProps
-from ._network_engine import (
- kromeFormatProps,
- networkFormatProps,
- parsedListProps,
- patternProps,
- prizmoFormatProps,
- uncompiledPatternProps,
-)
from ._reaction import ReactionProps
__all__ = [
NetworkProps,
ElementProps,
- FunctionsDict,
- kromeFormatProps,
- networkFormatProps,
- parsedListProps,
- patternProps,
- prizmoFormatProps,
- uncompiledPatternProps,
ReactionProps,
]
diff --git a/src/jaff/core/_typing/_network_engine.py b/src/jaff/core/_typing/_network_engine.py
deleted file mode 100644
index d51c1e7a..00000000
--- a/src/jaff/core/_typing/_network_engine.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import re
-from typing import Callable, TypedDict
-
-patternProps = TypedDict(
- "patternProps",
- {
- "global_re": re.Pattern,
- "local_re": re.Pattern,
- "handler": Callable[..., None],
- },
-)
-
-uncompiledPatternProps = TypedDict(
- "uncompiledPatternProps",
- {
- "global_re": str,
- "local_re": str,
- "handler": Callable[..., None],
- },
-)
-
-kromeFormatProps = TypedDict(
- "kromeFormatProps",
- {
- "format_nline": int,
- "idx": bool,
- "nreact": int,
- "nprod": int,
- "tmin": bool,
- "tmax": bool,
- "rate": bool,
- },
-)
-
-prizmoFormatProps = TypedDict(
- "prizmoFormatProps",
- {
- "parse_vars": bool,
- },
-)
-
-networkFormatProps = TypedDict(
- "networkFormatProps",
- {
- "prizmo": prizmoFormatProps,
- "krome": kromeFormatProps,
- },
-)
-
-parsedListProps = TypedDict(
- "parsedListProps",
- {
- "r": list[str],
- "p": list[str],
- "tmin": float | None,
- "tmax": float | None,
- "rate": str,
- "string": str,
- },
-)
diff --git a/src/jaff/core/_typing/_reaction.py b/src/jaff/core/_typing/_reaction.py
index ed8122ea..dfd3a072 100644
--- a/src/jaff/core/_typing/_reaction.py
+++ b/src/jaff/core/_typing/_reaction.py
@@ -21,6 +21,7 @@
"dE": Basic,
"dRad": Basic,
"custom_rad_rate": bool,
+ "reaction_type": str,
"tmin": float | None,
"tmax": float | None,
"original_string": str,
diff --git a/src/jaff/core/network.py b/src/jaff/core/network.py
index c07ef344..8ad88749 100644
--- a/src/jaff/core/network.py
+++ b/src/jaff/core/network.py
@@ -23,7 +23,7 @@
import sys
from functools import lru_cache
from pathlib import Path
-from typing import Any
+from typing import TYPE_CHECKING, Any
import numpy as np
from sympy import (
@@ -52,13 +52,28 @@
get_sodes,
get_sradodes,
)
-from ._auxiliary_engine import AuxiliaryFunctionParser, FunctionsDict
-from ._network_engine import NetworkParser
-from ._typing import ElementProps
from .elements import Elements
+from .parsers import AuxiliaryFunctionParser, NetworkParser
from .reaction import Reaction, Reactions
from .species import Specie, Species
+if TYPE_CHECKING:
+ from ._typing import ElementProps
+ from .parsers.auxiliary_func._typing import AuxiliaryFunctionsDict
+
+
+@lru_cache(maxsize=200000)
+def _parse_rate_expr(rate: str) -> Expr:
+ """Parse a rate string into a (non-evaluated) SymPy expression, memoized.
+
+ Large networks contain many reactions with identical rate strings (≈40% of
+ KIDA-2024 rates repeat), and the parsed expression depends only on the
+ string — species substitution happens later in ``_standardize_symbols`` —
+ so results are cached across reactions and networks. SymPy expressions are
+ immutable, making the shared objects safe to reuse.
+ """
+ return parse_expr(rate, evaluate=False)
+
@lru_cache(maxsize=200000)
def _parse_rate_expr(rate: str) -> Expr:
@@ -85,7 +100,10 @@ class Network:
label : str
Human-readable label for this network (defaults to the file stem).
species : Species
- Ordered catalogue of all species in the network.
+ Ordered catalogue of the network's core (real) species. Special
+ pseudo-species (``_PHOTON``, ``_CR``, ``_GRAIN``, ...) are excluded;
+ they live only inside each reaction's ``reactants``/``products`` and
+ carry the reaction's identity without entering the integrated state.
reactions : Reactions
Ordered catalogue of all reactions in the network.
elements : Elements
@@ -247,6 +265,7 @@ def __load_network(
When ``True``, expand ``nh`` to a sum over H-bearing species.
"""
specie_names = set()
+ special_species: dict[str, Specie] = {}
free_symbols = set()
undef_funcs = set()
interp_funcs = set()
@@ -283,12 +302,22 @@ def __load_network(
aux_delta_e = f"deltae{i}"
for s in reactants + products:
- if s not in specie_names:
- specie_names.add(s)
- self.species.add(Specie(s, len(specie_names) - 1))
+ if s in specie_names:
+ continue
+ specie_names.add(s)
+ if s.startswith("_"):
+ special_species[s] = Specie(s, -1)
+ else:
+ self.species.add(Specie(s, self.species.count))
- rr = [self.species[r] for r in reactants]
- pp = [self.species[p] for p in products]
+ rr = [
+ special_species[r] if r.startswith("_") else self.species[r]
+ for r in reactants
+ ]
+ pp = [
+ special_species[p] if p.startswith("_") else self.species[p]
+ for p in products
+ ]
local_subs_dict = {**subs_dict}
@@ -308,7 +337,7 @@ def __load_network(
if sym != tgas and expr.has(tgas):
local_subs_dict[sym] = expr.xreplace({tgas: local_subs_dict[tgas]})
- rate_expr, is_photoreaction, n_photo = self.__parse_rate(
+ rate_expr, n_photo = self.__parse_rate(
aux_chem_rate, rate, aux_funcs, global_vars, n_photo
)
rate_expr = resolve_dependencies(rate_expr, local_subs_dict, aux_funcs)
@@ -338,18 +367,19 @@ def __load_network(
dRad=deltaRad,
original_string=reaction["string"],
index=i,
+ type=reaction.get("type", "unknown"),
)
if "reaction_props" in self._metadata:
self.__parse_reaction_metadata(rea)
self.reactions.add(rea)
- if is_photoreaction:
+ if rea.type == "photo":
if self.__photochemistry is None:
self.__photochemistry = Photochemistry()
rea.xsecs_dict = self.__photochemistry.get_xsec(rea)
- if is_photoreaction and self.radiation is not None:
+ if rea.type == "photo" and self.radiation is not None:
if aux_chem_rate not in aux_funcs:
self.radiation.set_reaction_rate_coefficient(rea)
elif aux_chem_rate in aux_funcs and aux_delta_rad:
@@ -405,12 +435,19 @@ def __load_network_from_jaff_file(self, jaff_props: JaffProps):
tmax=reaction["tmax"],
original_string=reaction["original_string"],
index=i,
+ type=reaction.get("reaction_type", "unknown"),
)
- rea.xsecs_dict = reaction["xsecs_dict"]
rea.custom_rad_rate = reaction["custom_rad_rate"]
self.reactions.add(rea)
- if rea.rtype() == "photo" and self.radiation is not None:
+ if rea.type == "photo":
+ if self.__photochemistry is None:
+ self.__photochemistry = Photochemistry()
+ rea.xsecs_dict = self.__photochemistry.get_xsec(rea) or reaction.get(
+ "xsecs_dict"
+ )
+
+ if rea.type == "photo" and self.radiation is not None:
if rea.custom_rad_rate:
self.radiation.set_custom_rate(rea)
continue
@@ -437,7 +474,7 @@ def __normalize_network_extras(self, replace_nH):
dE_dt = r.dE * r.rate
dRad_dt = r.dRad * r.rate
- for s in r.reactants:
+ for s in r.reactants.core:
dE_dt *= nden[self.species[s.name].index]
dRad_dt *= nden[self.species[s.name].index]
self.dEdt_chem += dE_dt
@@ -449,10 +486,10 @@ def __normalize_network_extras(self, replace_nH):
def __parse_rate(
aux_chem_rate: str,
rate: str,
- aux_funcs: dict[str, FunctionsDict],
+ aux_funcs: dict[str, AuxiliaryFunctionsDict],
global_vars: dict[str, Expr],
n_photo: int,
- ) -> tuple[Expr, bool, int]:
+ ) -> tuple[Expr, int]:
"""Convert a raw rate string to a SymPy expression.
Checks, in priority order:
@@ -468,7 +505,7 @@ def __parse_rate(
Key for the optional custom-rate auxiliary function (e.g. ``"chemrate0"``).
rate : str
Raw rate string from the network file.
- aux_funcs : dict[str, FunctionsDict]
+ aux_funcs : dict[str, AuxiliaryFunctionsDict]
Parsed auxiliary functions dictionary.
global_vars : dict[str, Basic]
Resolved global variable map from the network file.
@@ -477,17 +514,15 @@ def __parse_rate(
Returns
-------
- tuple[Basic, bool, int]
- ``(rate_expr, is_photoreaction, n_photo)`` where *n_photo* is
+ tuple[Expr, int]
+ ``(rate_expr, n_photo)`` where *n_photo* is
incremented by 1 for photo-reactions.
"""
- is_photoreaction = False
if aux_chem_rate in aux_funcs:
rate_expr = aux_funcs[aux_chem_rate]["def"]
elif rate in global_vars:
rate_expr = symbols(rate)
elif "photo" in rate.lower():
- is_photoreaction = True
f: UndefinedFunction = Function("photorates") # type: ignore
n_photo += 1
@@ -515,7 +550,28 @@ def __parse_rate(
if not isinstance(rate_expr, Expr):
raise ParserError(f"Rate expression is not an Expr: {rate_expr}")
- return rate_expr, is_photoreaction, n_photo
+ return rate_expr, n_photo
+
+ def __parse_reaction_metadata(self, reaction: Reaction) -> None:
+ if reaction.serialized not in self._metadata["reaction_props"]:
+ return
+
+ rprops = self._metadata["reaction_props"][reaction.serialized]
+ if "shielding" in rprops:
+ if reaction.type != "photo":
+ raise ParserError(f"{reaction} is not a photo reaction")
+
+ shielding_props = rprops["shielding"]
+ if "type" not in shielding_props:
+ shielding_props["type"] = "leiden"
+
+ reaction._metadata["shielding"] = {
+ k: (v.lower() if isinstance(v, str) else v)
+ for k, v in shielding_props.items()
+ }
+ reaction._metadata["jaffgen"] = {
+ "jaffgen_object": self._metadata["jaffgen_object"]
+ }
def __parse_reaction_metadata(self, reaction: Reaction) -> None:
if reaction.serialized not in self._metadata["reaction_props"]:
@@ -523,18 +579,18 @@ def __parse_reaction_metadata(self, reaction: Reaction) -> None:
rprops = self._metadata["reaction_props"][reaction.serialized]
if "shielding" in rprops:
- if reaction.rtype() != "photo":
+ if reaction.type != "photo":
raise ParserError(f"{reaction} is not a photo reaction")
shielding_props = rprops["shielding"]
if "type" not in shielding_props:
shielding_props["type"] = "leiden"
- reaction.metadata["shielding"] = {
+ reaction._metadata["shielding"] = {
k: (v.lower() if isinstance(v, str) else v)
for k, v in shielding_props.items()
}
- reaction.metadata["jaffgen"] = {
+ reaction._metadata["jaffgen"] = {
"jaffgen_object": self._metadata["jaffgen_object"]
}
@@ -596,7 +652,7 @@ def __read_aux_funcs(self, funcfile: str | Path | None) -> dict:
raise FileNotFoundError(funcfile)
with AuxiliaryFunctionParser(funcfile) as afp:
- func_dict: FunctionsDict = afp.get_dict()
+ func_dict: AuxiliaryFunctionsDict = afp.get_dict()
return func_dict
@@ -722,7 +778,7 @@ def check_sink_sources(self, errors: bool) -> None:
A *sink* species appears as a reactant in at least one reaction but
is never produced. A *source* species is produced but never consumed.
- The special species ``"dummy"`` is excluded from the check.
+ The special species ``"_DUMMY"`` is excluded from the check.
Parameters
----------
@@ -731,7 +787,7 @@ def check_sink_sources(self, errors: bool) -> None:
"""
produced = {p.name for rea in self.reactions for p in rea.products}
consumed = {r.name for rea in self.reactions for r in rea.reactants}
- species_names = {s.name for s in self.species if s.name != "dummy"}
+ species_names = {s.name for s in self.species if s.name != "_DUMMY"}
sinks = species_names - produced
sources = species_names - consumed
@@ -843,7 +899,7 @@ def check_unique_reactions(self, errors):
continue
if rea1.is_isomer_version(rea2):
continue
- if rea1.rtype() != rea2.rtype():
+ if rea1.type != rea2.type:
continue
self.logger.warning(
f"Duplicate reaction found: [cyan]{rea1.get_verbatim()}[/]"
@@ -864,10 +920,10 @@ def __generate_reaction_matrices(self) -> None:
)
for i, reaction in enumerate(self.reactions):
- for reactant in reaction.reactants:
+ for reactant in reaction.reactants.core:
self.reactant_matrix[i, reactant.index] += 1
- for product in reaction.products:
+ for product in reaction.products.core:
self.product_matrix[i, product.index] += 1
def _standardize_symbols(self, expr: Basic, replace_nH: bool) -> Expr:
diff --git a/src/jaff/core/parsers/__init__.py b/src/jaff/core/parsers/__init__.py
new file mode 100644
index 00000000..fd6f20a3
--- /dev/null
+++ b/src/jaff/core/parsers/__init__.py
@@ -0,0 +1,4 @@
+from .auxiliary_func import AuxiliaryFunctionParser
+from .network import NetworkParser
+
+__all__ = ["NetworkParser", "AuxiliaryFunctionParser"]
diff --git a/src/jaff/core/parsers/auxiliary_func/__init__.py b/src/jaff/core/parsers/auxiliary_func/__init__.py
new file mode 100644
index 00000000..18b69ea4
--- /dev/null
+++ b/src/jaff/core/parsers/auxiliary_func/__init__.py
@@ -0,0 +1,3 @@
+from ._engine import AuxiliaryFunctionParser
+
+__all__ = ["AuxiliaryFunctionParser"]
diff --git a/src/jaff/core/_auxiliary_engine.py b/src/jaff/core/parsers/auxiliary_func/_engine.py
similarity index 95%
rename from src/jaff/core/_auxiliary_engine.py
rename to src/jaff/core/parsers/auxiliary_func/_engine.py
index 7654b1b5..1d6a94e9 100644
--- a/src/jaff/core/_auxiliary_engine.py
+++ b/src/jaff/core/parsers/auxiliary_func/_engine.py
@@ -14,7 +14,7 @@
Continuation lines end with ``\\``. Inline comments start with ``#``.
-The parsed results are stored as a ``FunctionsDict`` mapping function names
+The parsed results are stored as a ``AuxiliaryFunctionsDict`` mapping function names
to their symbolic definitions, argument lists, and argument comments. Global
variables are resolved into the function bodies so that callers receive
fully-substituted SymPy expressions.
@@ -31,9 +31,9 @@
import sympy as sp
from sympy.core.function import AppliedUndef
-from ..common import resolve_symbolic_dependencies
-from ..errors import ParserError
-from ._typing import FunctionsDict
+from ....common import resolve_symbolic_dependencies
+from ....errors import ParserError
+from ._typing import AuxiliaryFunctionsDict
class AuxiliaryFunctionParser:
@@ -49,9 +49,9 @@ class AuxiliaryFunctionParser:
Absolute path to the parsed ``.jfunc`` file.
globals : dict[str, sp.Basic]
Global symbolic constants defined with ``@var``, keyed by name.
- func_dict : dict[str, FunctionsDict]
+ func_dict : dict[str, AuxiliaryFunctionsDict]
Parsed functions, keyed by lower-cased function name. Each entry
- is a ``FunctionsDict`` with keys ``"def"`` (SymPy expression),
+ is a ``AuxiliaryFunctionsDict`` with keys ``"def"`` (SymPy expression),
``"args"`` (list of SymPy symbols), and ``"argcomments"`` (dict of
argument doc strings).
@@ -91,13 +91,13 @@ def __init__(self, file: Path | str):
raise FileNotFoundError(file)
self.file: Path = file
- self.og_line: str = "" # raw line from file (before continuation merge)
- self.line: str = "" # processed line ready for parsing
- self.cline: str = "" # accumulator for continuation lines
- self.nline: int = 0 # current 1-based line number (for error messages)
+ self.og_line: str = "" # raw line from file (before continuation merge)
+ self.line: str = "" # processed line ready for parsing
+ self.cline: str = "" # accumulator for continuation lines
+ self.nline: int = 0 # current 1-based line number (for error messages)
self.globals: dict[str, sp.Basic] = {}
self.globals_parsed: bool = False # True once global vars are resolved
- self.func_dict: dict[str, FunctionsDict] = {}
+ self.func_dict: dict[str, AuxiliaryFunctionsDict] = {}
self.scope: str = "global" # "global" | "function"
self.current_func: str = "" # name of the function block being parsed
@@ -138,7 +138,7 @@ def get_dict(self):
Returns
-------
- dict[str, FunctionsDict]
+ dict[str, AuxiliaryFunctionsDict]
Maps lower-cased function names to their symbolic definitions,
argument lists, and argument documentation strings.
"""
diff --git a/src/jaff/core/parsers/auxiliary_func/_typing/__init__.py b/src/jaff/core/parsers/auxiliary_func/_typing/__init__.py
new file mode 100644
index 00000000..0ddf19a0
--- /dev/null
+++ b/src/jaff/core/parsers/auxiliary_func/_typing/__init__.py
@@ -0,0 +1,3 @@
+from ._functions import AuxiliaryFunctionsDict
+
+__all__ = ["AuxiliaryFunctionsDict"]
diff --git a/src/jaff/core/_typing/_auxiliary_engine.py b/src/jaff/core/parsers/auxiliary_func/_typing/_functions.py
similarity index 77%
rename from src/jaff/core/_typing/_auxiliary_engine.py
rename to src/jaff/core/parsers/auxiliary_func/_typing/_functions.py
index 2f31428f..915ab17d 100644
--- a/src/jaff/core/_typing/_auxiliary_engine.py
+++ b/src/jaff/core/parsers/auxiliary_func/_typing/_functions.py
@@ -2,8 +2,8 @@
from sympy import Basic
-FunctionsDict = TypedDict(
- "FunctionsDict",
+AuxiliaryFunctionsDict = TypedDict(
+ "AuxiliaryFunctionsDict",
{
"def": Basic,
"args": list[Basic],
diff --git a/src/jaff/core/parsers/network/__init__.py b/src/jaff/core/parsers/network/__init__.py
new file mode 100644
index 00000000..6674f0af
--- /dev/null
+++ b/src/jaff/core/parsers/network/__init__.py
@@ -0,0 +1,3 @@
+from ._engine import NetworkParser
+
+__all__ = ["NetworkParser"]
diff --git a/src/jaff/core/parsers/network/_engine.py b/src/jaff/core/parsers/network/_engine.py
new file mode 100644
index 00000000..40265fbe
--- /dev/null
+++ b/src/jaff/core/parsers/network/_engine.py
@@ -0,0 +1,211 @@
+"""Low-level reaction network file parser for multiple astrochemical formats.
+
+``NetworkParser`` reads a single network file and converts each reaction line
+into a format-independent ``parsedListProps`` dict with keys:
+
+- ``"r"`` — list of reactant name strings
+- ``"p"`` — list of product name strings
+- ``"tmin"`` — lower temperature bound in Kelvin, or ``None``
+- ``"tmax"`` — upper temperature bound in Kelvin, or ``None``
+- ``"rate"`` — rate expression as a Python/SymPy-compatible string
+- ``"type"`` — reaction type concluded by the parser (e.g. ``"photo"``)
+- ``"string"`` — the original network-file line (for error reporting)
+
+Supported file formats
+----------------------
+The parser auto-detects the format from line patterns. Each format is a
+self-contained plugin under ``parsers.network._formats``; the engine discovers
+them through :func:`~.parsers.network._formats.all_formats`, which orders them
+by their declared ``priority`` (lower is matched first):
+
+1. **PRIZMO** — arrow-notation (``->``) with optional temperature range in
+ ``[tmin, tmax]`` brackets. Variables in a ``VARIABLES { }`` block.
+2. **UDFA** — colon-delimited, fixed-column database from the UMIST project.
+3. **KROME** — comma-separated, declared via ``@format:`` header.
+ Variable aliases via ``@var:``.
+4. **UCLCHEM** — comma-separated with a ``NAN`` sentinel, includes grain
+ surface reactions.
+5. **KIDA** — fixed-width column format from the KIDA database.
+
+Rate normalization
+------------------
+After parsing, all rate strings are lower-cased. Known format-specific
+symbols (``user_crflux``, ``user_av``, Fortran exponent notation ``d``,
+temperature shortcuts ``t32``, ``invtgas``, etc.) are replaced with canonical
+JAFF symbols before the strings are passed to SymPy for sympification.
+"""
+
+import logging
+from pathlib import Path
+
+from sympy import Basic, parse_expr
+
+from ....common import resolve_symbolic_dependencies
+from ....io import JaffLogger, jaff_progress
+from ._typing import parsedListProps
+from ._formats import (
+ NetworkFormat,
+ ParseContext,
+ all_formats,
+ build_state,
+)
+
+
+class NetworkParser:
+ """Auto-detecting parser for astrochemical reaction network files.
+
+ On construction the file is read, reactions are extracted, and rate
+ strings are normalised. Use as a context manager to ensure internal
+ pattern state is freed after use.
+
+ Parameters
+ ----------
+ file : str | Path
+ Path to the network file.
+ logger : logging.Logger | None, optional
+ Logger instance. A new JAFF logger is created if ``None``.
+
+ Raises
+ ------
+ ValueError
+ If *file* is not a ``str`` or ``Path``.
+ FileNotFoundError
+ If *file* does not exist on disk.
+ ParserError
+ On syntax errors encountered while parsing the file.
+ """
+
+ def __init__(self, file: str | Path, logger: logging.Logger | None = None):
+ """Parse *file* and prepare the internal parsed-reaction list.
+
+ Parameters
+ ----------
+ file : str | Path
+ Path to the network file.
+ logger : logging.Logger | None, optional
+ External logger. Defaults to a new JAFF logger.
+ """
+ if isinstance(file, str):
+ file = Path(file)
+ if not isinstance(file, (str, Path)):
+ raise ValueError(f"Invalid file type detected for {file}: {type(file)}")
+
+ file = file.resolve()
+ if not file.exists():
+ raise FileNotFoundError(file)
+
+ self.__file: Path = file
+ self.__logger: logging.Logger = logger or JaffLogger().get_logger()
+ self.__globals: dict[str, Basic] = {}
+ # Pre-populate well-known Fortran/KROME shorthand symbols as SymPy aliases.
+ self.__set_known_replacments()
+
+ self.__parsed_list: list[parsedListProps] = []
+ self.__formats: list[NetworkFormat] = all_formats()
+ self.__ctx: ParseContext = ParseContext(
+ self.__file,
+ self.__logger,
+ self.__globals,
+ self.__parsed_list,
+ build_state(self.__formats),
+ )
+
+ self.__parse_file()
+ self.__normalize_rates()
+ self.__globals = resolve_symbolic_dependencies(self.__globals, fname=self.__file)
+
+ def __enter__(self) -> "NetworkParser":
+ """Return self when entering a ``with`` block.
+
+ Returns
+ -------
+ NetworkParser
+ """
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Free the registered format plugins on context manager exit."""
+ self.__formats.clear()
+
+ return
+
+ def get_parsed(self) -> tuple[list[parsedListProps], dict[str, Basic]]:
+ """Return the parsed reaction list and resolved global variable map.
+
+ Returns
+ -------
+ tuple[list[parsedListProps], dict[str, Basic]]
+ - ``list[parsedListProps]``: one dict per reaction with keys
+ ``"r"``, ``"p"``, ``"tmin"``, ``"tmax"``, ``"rate"``,
+ ``"type"``, ``"string"``.
+ - ``dict[str, Basic]``: global symbolic constants defined in the
+ file (e.g. ``@var`` entries), with all inter-dependencies
+ resolved via SymPy substitution.
+ """
+ return self.__parsed_list, resolve_symbolic_dependencies(
+ dep_map=self.__globals, fname=self.__file
+ )
+
+ def __parse_file(self) -> None:
+ """Read the network file line-by-line and dispatch each line for parsing.
+
+ Iterates over every line of :attr:`__file`, advancing the line counter
+ and calling :meth:`__parse_line` for each.
+ """
+ with open(self.__file, "r") as f:
+ lines = f.readlines()
+ for i, line in enumerate(
+ jaff_progress.track(lines, description=f"Parsing {self.__file.name}")
+ ):
+ self.__ctx.nline = i + 1
+ self.__ctx.line = line
+ self.__parse_line()
+
+ def __parse_line(self) -> None:
+ """Match the current line against all known formats and invoke the handler.
+
+ Iterates through :attr:`__formats` in priority order. The first format
+ whose global regex matches handles the line. If no format matches the
+ line is silently skipped.
+ """
+ if not self.__ctx.line.strip():
+ return
+ for fmt in self.__formats:
+ if match := fmt._global_re(self.__ctx).match(self.__ctx.line):
+ fmt.handle(match, self.__ctx)
+ break
+
+ def __set_known_replacments(self) -> None:
+ """Pre-populate ``__globals`` with canonical JAFF symbol aliases.
+
+ Inserts SymPy expressions for common KROME/PRIZMO shorthand variables
+ such as ``t32``, ``te``, ``invtgas``, and ``sqrtgas`` so that they are
+ resolved automatically during rate normalization.
+ """
+ # Populate __globals with canonical SymPy aliases for common shorthand
+ # symbols found in KROME/PRIZMO files. Order matters: compound aliases
+ # (invt32, invte) must be listed before the simpler ones they depend on
+ # so that resolve_symbolic_dependencies can substitute correctly.
+ replacements = {
+ "invt32": "1e0 / t32",
+ "invte": "1e0 / te",
+ "t32": "tgas/3e2",
+ "te": "tgas*8.617343e-5",
+ "invtgas": "1e0 / tgas",
+ "sqrtgas": "sqrt(tgas)",
+ "user_tdust": "tdust",
+ "user_av": "av",
+ "get_hnuclei(n)": "nh",
+ "n(idx_h2)": "nh2",
+ "n(idx_h)": "nh0",
+ "n_global(idx_h2)": "nh2",
+ }
+
+ for k, v in replacements.items():
+ self.__globals[k] = parse_expr(v)
+
+ def __normalize_rates(self):
+ """Lower-case all rate strings so SymPy ``parse_expr`` is case-insensitive."""
+ for r in self.__parsed_list:
+ assert isinstance(r["rate"], str)
+ r["rate"] = r["rate"].lower()
diff --git a/src/jaff/core/parsers/network/_formats/__init__.py b/src/jaff/core/parsers/network/_formats/__init__.py
new file mode 100644
index 00000000..258d6603
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/__init__.py
@@ -0,0 +1,82 @@
+"""Plugin registry for network-file format parsers.
+
+Each supported format lives in its own subpackage (``krome``, ``prizmo``,
+``udfa``, ``uclchem``, ``kida``) and registers one or more
+:class:`~._base.NetworkFormat` subclasses via the :func:`register` decorator.
+The engine discovers them through :func:`all_formats`, which returns the
+formats sorted by their declared ``priority`` — so match order is independent
+of file or import order.
+
+Adding a new format requires only a new subpackage with a ``@register``-ed
+class; no engine or :class:`~._context.ParseContext` edits.
+"""
+
+from ._base import NetworkFormat
+from ._context import ParseContext
+
+_REGISTRY: list[type[NetworkFormat]] = []
+
+
+def register(cls: type[NetworkFormat]) -> type[NetworkFormat]:
+ """Register *cls* as an available network format.
+
+ Parameters
+ ----------
+ cls : type[NetworkFormat]
+ The format class to register.
+
+ Returns
+ -------
+ type[NetworkFormat]
+ *cls* unchanged, so the decorator is transparent.
+ """
+ _REGISTRY.append(cls)
+
+ return cls
+
+
+def all_formats() -> list[NetworkFormat]:
+ """Instantiate every registered format, sorted by priority.
+
+ Importing the format subpackages here triggers their ``@register``
+ decorators, populating :data:`_REGISTRY`.
+
+ Returns
+ -------
+ list[NetworkFormat]
+ One instance per registered format, in ascending priority order
+ (lower priority is matched against each line first).
+ """
+ from jaff.common._helper import import_subpackages
+
+ import_subpackages(__name__)
+
+ return sorted((cls() for cls in _REGISTRY), key=lambda fmt: fmt.priority)
+
+
+def build_state(formats: list[NetworkFormat]) -> dict[str, dict]:
+ """Build the shared per-format state store for a :class:`ParseContext`.
+
+ Merges each format's :meth:`~._base.NetworkFormat.default_state` into a
+ dict keyed by ``state_key``. Formats sharing a ``state_key`` (e.g. a KROME
+ ``@format`` header and the reaction lines it configures) share one dict.
+
+ Parameters
+ ----------
+ formats : list[NetworkFormat]
+ Formats whose initial state to collect.
+
+ Returns
+ -------
+ dict[str, dict]
+ Mapping of ``state_key`` to its merged initial state.
+ """
+ state: dict[str, dict] = {}
+ for fmt in formats:
+ if fmt.state_key:
+ state.setdefault(fmt.state_key, {}).update(fmt.default_state())
+
+ return state
+
+
+__all__ = ["NetworkFormat", "ParseContext", "register", "all_formats", "build_state"]
diff --git a/src/jaff/core/parsers/network/_formats/_base.py b/src/jaff/core/parsers/network/_formats/_base.py
new file mode 100644
index 00000000..c6b97027
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/_base.py
@@ -0,0 +1,75 @@
+import re
+from abc import ABC, abstractmethod
+
+
+class NetworkFormat(ABC):
+ """Base interface for a single network-file format plugin.
+
+ Subclasses live in their own module under ``_formats`` and register
+ themselves so the engine can discover them. Priority — not file or import
+ order — determines the order in which formats are matched against a line.
+
+ Class attributes
+ ----------------
+ priority : int
+ Match order; lower is tried first.
+ name : str
+ Unique format identifier.
+ state_key : str
+ Namespace into ``ParseContext.state`` for this format's mutable props.
+ Formats that must share live state (e.g. a ``@format`` header and the
+ reaction lines it configures) declare the *same* ``state_key``. The
+ empty string means the format keeps no state.
+ """
+
+ priority: int
+ name: str
+ state_key: str = ""
+
+ def default_state(self) -> dict:
+ """Return this format's initial mutable props.
+
+ Merged into ``ParseContext.state[self.state_key]`` once at parser
+ construction. Formats sharing a ``state_key`` have their dicts merged.
+
+ Returns
+ -------
+ dict
+ Initial state for this format (empty by default).
+ """
+ return {}
+
+ def state(self, ctx) -> dict:
+ """Return this format's live state slice from *ctx*.
+
+ Parameters
+ ----------
+ ctx : ParseContext
+ Shared parse context.
+
+ Returns
+ -------
+ dict
+ The mutable dict at ``ctx.state[self.state_key]``; writes are
+ visible to every format sharing the same ``state_key``.
+ """
+ return ctx.state[self.state_key]
+
+ @abstractmethod
+ def _global_re(self, ctx) -> re.Pattern:
+ """Return the compiled regex used to classify a line as this format."""
+ pass
+
+ @abstractmethod
+ def _local_re(self, ctx) -> re.Pattern:
+ """Return the compiled regex used to extract fields from a line.
+
+ Recomputed per call so it reflects the current ``ctx.state`` (e.g. the
+ KROME column counts updated by a ``@format`` header).
+ """
+ pass
+
+ @abstractmethod
+ def handle(self, match: re.Match, ctx) -> None:
+ """Process a matched line, mutating *ctx* (append a reaction, update state)."""
+ pass
diff --git a/src/jaff/core/parsers/network/_formats/_context.py b/src/jaff/core/parsers/network/_formats/_context.py
new file mode 100644
index 00000000..2318a6b9
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/_context.py
@@ -0,0 +1,72 @@
+import logging
+from pathlib import Path
+
+from sympy import Basic
+
+from .._typing import parsedListProps
+from .....errors import ParserError
+
+
+class ParseContext:
+ """Shared mutable state threaded through every :class:`NetworkFormat`.
+
+ A single instance is created per parse and passed by reference to every
+ format. It owns the line cursor, the resolved global symbol map, the
+ accumulating parsed-reaction list, and a generic ``state`` store. It holds
+ *no* format-specific knowledge: each format seeds and reads its own slice of
+ ``state`` under its declared ``state_key``.
+
+ Parameters
+ ----------
+ file : Path
+ Network file being parsed (used for error context).
+ logger : logging.Logger
+ Logger for non-fatal warnings raised by formats.
+ globals_ : dict[str, Basic]
+ Global symbolic constants; formats add ``@var`` / ``VARIABLES`` entries.
+ parsed_list : list[parsedListProps]
+ Accumulator; formats append one dict per reaction.
+ state : dict[str, dict]
+ Per-format mutable props, keyed by ``NetworkFormat.state_key``. Built
+ once at construction from each format's ``default_state()``.
+
+ Attributes
+ ----------
+ line : str
+ Current line text, updated by the engine before each dispatch.
+ nline : int
+ Current 1-based line number.
+ """
+
+ def __init__(
+ self,
+ file: Path,
+ logger: logging.Logger,
+ globals_: dict[str, Basic],
+ parsed_list: list[parsedListProps],
+ state: dict[str, dict],
+ ):
+ self.file: Path = file
+ self.logger: logging.Logger = logger
+ self.globals: dict[str, Basic] = globals_
+ self.parsed_list: list[parsedListProps] = parsed_list
+ self.state: dict[str, dict] = state
+ self.line: str = ""
+ self.nline: int = 0
+
+ def raise_error(self, message: str, **kwargs) -> None:
+ """Raise a :exc:`~jaff.errors.ParserError` with current file/line context.
+
+ Parameters
+ ----------
+ message : str
+ Human-readable description of the error.
+ **kwargs
+ Extra keyword arguments forwarded to :class:`~jaff.errors.ParserError`.
+
+ Raises
+ ------
+ ParserError
+ Always raised.
+ """
+ raise ParserError(message, self.line, self.nline, self.file, **kwargs)
diff --git a/src/jaff/core/parsers/network/_formats/kida/__init__.py b/src/jaff/core/parsers/network/_formats/kida/__init__.py
new file mode 100644
index 00000000..32ea0c4c
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/kida/__init__.py
@@ -0,0 +1,3 @@
+from .reaction import KidaReaction
+
+__all__ = ["KidaReaction"]
diff --git a/src/jaff/core/parsers/network/_formats/kida/reaction.py b/src/jaff/core/parsers/network/_formats/kida/reaction.py
new file mode 100644
index 00000000..097cc033
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/kida/reaction.py
@@ -0,0 +1,132 @@
+"""KIDA format: fixed-width column reaction database."""
+
+import re
+from functools import cache
+
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class KidaReaction(NetworkFormat):
+ """KIDA fixed-width reaction line."""
+
+ priority = 80
+ name = "kida"
+
+ SPECIAL_MAP = {
+ "CR": "_CR",
+ "CRP": "_CRP",
+ "CRPHOT": "_CRPHOT",
+ "Photon": "_PHOTON",
+ "PHOTON": "_PHOTON",
+ }
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(r"^(?!\s*[!#@]).{34}.{57}")
+
+ @cache
+ def _local_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(
+ r"^(?P.{34})"
+ r"(?P.{57})"
+ r"\s*(?P[^\s]+)"
+ r"\s*(?P[^\s]+)"
+ r"\s*(?P[^\s]+)"
+ r"\s*[^\s]+\s*[^\s]+\s*[^\s]+\s*[^\s]+"
+ r"\s*(?P[^\s]+)"
+ r"\s*(?P[^\s]+)"
+ r"\s*(?P[^\s]+)"
+ r".*$"
+ )
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None:
+ """Parse a KIDA-format reaction line and append it to the parsed list.
+
+ Extracts reactants, products, rate parameters (``ka``, ``kb``, ``kc``),
+ temperature bounds, and formula index from the fixed-width KIDA column
+ format. Rate expressions are selected from a formula dictionary keyed
+ by the integer formula index (1–5).
+
+ Raises
+ ------
+ ParserError
+ Via :meth:`_handle_errors` if the line does not match the expected
+ KIDA format.
+ """
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ self._handle_errors(match, ctx)
+
+ reactants: str = local.group("reactants")
+ products: str = local.group("products")
+ ka: float = float(local.group("ka"))
+ kb: float = float(local.group("kb"))
+ kc: float = float(local.group("kc"))
+ tmin: float = float(local.group("tmin"))
+ tmax: float = float(local.group("tmax"))
+ formula: int = int(local.group("formula"))
+
+ t_min = tmin if tmin > 0 else None
+ t_max = tmax if tmax < 9999.0 else None
+
+ rr = [r.strip() for r in reactants.split() if r != "+"]
+ pp = [p.strip() for p in products.split() if p != "+"]
+ rates_dict = {
+ 1: (
+ f"{ka:.2e} * crate"
+ if "CRP" not in rr
+ else f"{ka:.2e} * crate * 2.0 * nH2 / nH"
+ ),
+ 2: f"{ka:.2e} * chi * exp(-{kc:.2e} * av)",
+ 3: f"{ka:.2e}"
+ + (f" * (tgas / 300) ** ({kb:.2e})" if kb != 0.0 else "")
+ + (f" * exp(-{kc:.2f} / tgas)" if kc != 0.0 else ""),
+ 4: f"{ka * kb:.2e} * (0.62 + 0.4767 * {kc:2e} * sqrt(300 / tgas))",
+ 5: f"{ka * kb:.2e} * (1 + 0.0967 * {kc:.2e} * sqrt(300 / tgas) + {kc**2:.2e} * 3e2 / 10.526 / tgas)",
+ }
+ rate = rates_dict.get(formula, "0.0")
+
+ rr = [self.SPECIAL_MAP.get(r, r) for r in rr]
+ pp = [self.SPECIAL_MAP.get(p, p) for p in pp]
+
+ if formula == 2 and "_PHOTON" not in rr:
+ rr.append("_PHOTON")
+ elif formula == 1 and not any(cr in rr for cr in ("_CR", "_CRP", "_CRPHOT")):
+ rr.append("_CR")
+
+ ctx.parsed_list.append(
+ {
+ "r": rr,
+ "p": pp,
+ "tmin": t_min,
+ "tmax": t_max,
+ "rate": rate,
+ "type": self._reaction_type(formula, rr),
+ "string": ctx.line.strip(),
+ }
+ )
+
+ def _handle_errors(self, match: re.Match, ctx: ParseContext) -> None:
+ """Raise an error for a malformed KIDA reaction line."""
+ ctx.raise_error("Invalid KIDA reaction detected")
+
+ @staticmethod
+ def _reaction_type(formula: int, rr: list[str]) -> str:
+ """Conclude the reaction type from the KIDA formula index and reactants.
+
+ 1 = cosmic-ray, 2 = photoprocess. Otherwise a reaction with three or
+ more real (non-pseudo) reactants is three-body; else ``"unknown"``.
+ Reactant-count classification is rate-independent, so it survives
+ custom auxiliary-function rates.
+ """
+ agent = {1: "cosmic_ray", 2: "photo"}.get(formula)
+ if agent:
+ return agent
+
+ if sum(1 for r in rr if not r.startswith("_")) >= 3:
+ return "3_body"
+
+ return "unknown"
diff --git a/src/jaff/core/parsers/network/_formats/krome/__init__.py b/src/jaff/core/parsers/network/_formats/krome/__init__.py
new file mode 100644
index 00000000..3da4d8b6
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/krome/__init__.py
@@ -0,0 +1,5 @@
+from .header import KromeFormatHeader
+from .reaction import KromeReaction
+from .var import KromeVar
+
+__all__ = ["KromeFormatHeader", "KromeVar", "KromeReaction"]
diff --git a/src/jaff/core/parsers/network/_formats/krome/header.py b/src/jaff/core/parsers/network/_formats/krome/header.py
new file mode 100644
index 00000000..850af0ec
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/krome/header.py
@@ -0,0 +1,100 @@
+"""KROME ``@format:`` header — declares the column layout for reaction lines."""
+
+import re
+from functools import cache
+
+from ..._typing import kromeFormatProps
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class KromeFormatHeader(NetworkFormat):
+ """KROME ``@format:`` header — declares column layout for reaction lines."""
+
+ priority = 10
+ name = "krome_format"
+ state_key = "krome"
+
+ def default_state(self) -> kromeFormatProps: # type: ignore
+ return {
+ "format_nline": 0, # line where @format was declared (0 = not yet seen)
+ "idx": True,
+ "nreact": 3,
+ "nprod": 4,
+ "tmin": True,
+ "tmax": True,
+ "rate": True,
+ }
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(r"^\s*@format\s*:(?P.*?)$")
+
+ @cache
+ def _local_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(
+ r"^\s*@format\s*:\s*"
+ r"(?P(?i:idx)\s*,\s*)?"
+ r"(?P(?:(?i:R)\s*,\s*)+)"
+ r"(?P(?:(?i:P)\s*,\s*)+)"
+ r"(?P(?i:tmin)\s*,?\s*)?"
+ r"(?P(?i:tmax)\s*,?\s*)?"
+ r"(?P(?i:rate)\s*)?\s*$"
+ )
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None:
+ """Parse a KROME ``@format:`` header line and update the format descriptor.
+
+ Updates the shared ``"krome"`` state with the field counts and flags
+ detected in the format declaration so subsequent reaction lines are
+ matched with the correct column counts.
+
+ Raises
+ ------
+ ParserError
+ Via :meth:`_handle_errors` if the format line is malformed.
+ """
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ self._handle_errors(match, ctx)
+
+ self.state(ctx).update(
+ {
+ "format_nline": ctx.nline,
+ "idx": bool(local.group("idx")),
+ "nreact": local.group("reactants").lower().count("r"),
+ "nprod": local.group("products").lower().count("p"),
+ "tmin": bool(local.group("tmin")),
+ "tmax": bool(local.group("tmax")),
+ "rate": bool(local.group("rate")),
+ }
+ )
+
+ def _handle_errors(self, match: re.Match, ctx: ParseContext) -> None:
+ """Raise a descriptive error for a malformed KROME ``@format:`` line."""
+ format = match.group("format")
+ if format is None:
+ ctx.raise_error("Empty @format KROME declerative")
+
+ format = format.strip()
+ if not format:
+ ctx.raise_error("Empty @format KROME declerative")
+
+ if "," not in format:
+ ctx.raise_error(
+ "Invalid @format KROME declerative\n"
+ "@format decelerative must be separated by ','"
+ )
+
+ expected_tokens = {"idx", "R", "P", "tmin", "tmax", "rate"}
+ tokens = [token.strip() for token in format.split(",")]
+ for token in tokens:
+ if token not in expected_tokens:
+ ctx.raise_error(
+ f"Invalid token in krome format: {token}\n"
+ f"Supported tokens are {','.join(expected_tokens)}"
+ )
+
+ ctx.raise_error("Invalid @format KROME declerative")
diff --git a/src/jaff/core/parsers/network/_formats/krome/reaction.py b/src/jaff/core/parsers/network/_formats/krome/reaction.py
new file mode 100644
index 00000000..d90dd55a
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/krome/reaction.py
@@ -0,0 +1,246 @@
+"""KROME comma-delimited reaction line."""
+
+import re
+from functools import cache
+
+from ......common import f90_convert
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class KromeReaction(NetworkFormat):
+ """KROME comma-delimited reaction line."""
+
+ priority = 60
+ name = "krome"
+ state_key = "krome"
+
+ SPECIAL_MAP = {
+ "CR": "_CR",
+ "CRP": "_CRP",
+ "CRPHOT": "_CRPHOT",
+ "PHOTON": "_PHOTON",
+ }
+
+ WHOLE_TOKEN_MAP = {"E": "e-", "e": "e-", "g": "_GRAIN", **SPECIAL_MAP}
+
+ SUBSTRING_MAP = {"HE": "He"}
+
+ TMINMAX_REPS = {
+ "d": "e",
+ ".le.": "",
+ ".ge.": "",
+ ".lt.": "",
+ ".gt.": "",
+ ">": "",
+ "<": "",
+ }
+
+ RATE_REPS = {
+ "user_crflux": "crate",
+ "user_crate": "crate",
+ "user_av": "av",
+ }
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(
+ r"^(?!\s*[!#@])"
+ r"(?!.*,\s*(?i:NAN)\s*(?:,|$))"
+ r"(?=.*,)"
+ r"(?P.*)$"
+ )
+
+ def _local_re(self, ctx: ParseContext) -> re.Pattern:
+ props = self.state(ctx)
+
+ return re.compile(
+ r"^\s*"
+ r"(?!.*,\s*(?i:NAN)\s*(?:,|$))"
+ + (r"(?P[^,]*)\s*,\s*" if props["idx"] else "")
+ + rf"(?P(?:[^,]*\s*,\s*){{{props['nreact']}}})"
+ + rf"(?P(?:[^,]*\s*,\s*){{{props['nprod']}}})"
+ + (r"(?P[^,]*)\s*,\s*" if props["tmin"] else "")
+ + (r"(?P[^,]*)\s*,\s*" if props["tmax"] else "")
+ + (r"(?P.*)" if props["rate"] else "")
+ + r"\s*$"
+ )
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None:
+ """Parse a KROME-format reaction line and append it to the parsed list.
+
+ Extracts the index, reactants, products, temperature bounds, and rate
+ expression from the comma-delimited KROME format. Applies species
+ normalisation (``E``/``e`` → ``e-``, ``g`` → ``_GRAIN``,
+ ``HE`` → ``He``), normalises exotic pseudo-species
+ (``CR``/``CRP``/``CRPHOT``/``PHOTON`` → underscore form), and converts
+ ``user_crflux``/``user_av`` aliases. Fortran exponent
+ notation is converted to Python notation via :func:`~jaff.common.f90_convert`.
+
+ Raises
+ ------
+ ParserError
+ Via :meth:`_handle_errors` if the line structure is inconsistent
+ with the declared KROME format.
+ """
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ self._handle_errors(match, ctx)
+
+ reactants: str = local.group("reactants")
+ products: str = local.group("products")
+ tmin: str = local.groupdict().get("tmin", "").strip().lower()
+ tmax: str = local.groupdict().get("tmax", "").strip().lower()
+ rate: str = local.groupdict().get("rate", "").strip()
+
+ rr: list[str] = [r.strip() for r in reactants.split(",")[:-1]]
+ pp: list[str] = [p.strip() for p in products.split(",")[:-1]]
+
+ if len(rr) != self.state(ctx)["nreact"]:
+ ctx.raise_error(
+ "Invalid KROME line detected\n"
+ f"Expected {self.state(ctx)['nreact']} reactants\n"
+ f"from line {self.state(ctx)['format_nline']}.\n"
+ f"Instead got {len(rr)} reactants"
+ )
+
+ if len(pp) != self.state(ctx)["nprod"]:
+ ctx.raise_error(
+ "Invalid KROME line detected\n"
+ f"Expected {self.state(ctx)['nprod']} products \n"
+ f"from line {self.state(ctx)['format_nline']}.\n"
+ f"Instead got {len(pp)} products"
+ )
+
+ t_min: None | float = None
+ t_max: None | float = None
+
+ rr = [self.WHOLE_TOKEN_MAP.get(r, r) for r in rr]
+ pp = [self.WHOLE_TOKEN_MAP.get(p, p) for p in pp]
+
+ for k, v in self.SUBSTRING_MAP.items():
+ rr = [x.replace(k, v) for x in rr]
+ pp = [x.replace(k, v) for x in pp]
+
+ rr = [r for r in rr if r != ""]
+ pp = [p for p in pp if p != ""]
+
+ if tmin != "none" and tmin != "":
+ for k, v in self.TMINMAX_REPS.items():
+ tmin = tmin.replace(k, v)
+ t_min = float(tmin)
+
+ if tmax != "none" and tmax != "":
+ for k, v in self.TMINMAX_REPS.items():
+ tmax = tmax.replace(k, v)
+ t_max = float(tmax)
+
+ for k, v in self.RATE_REPS.items():
+ rate = rate.replace(k, v)
+
+ rate = f90_convert(rate)
+ if "auto" in rate:
+ rate = rate.replace("auto", "PHOTO, 1e99")
+
+ if "photo" in rate.lower() and "_PHOTON" not in rr:
+ rr.append("_PHOTON")
+
+ ctx.parsed_list.append(
+ {
+ "r": rr,
+ "p": pp,
+ "tmin": t_min,
+ "tmax": t_max,
+ "rate": rate,
+ "type": self._reaction_type(rate, rr),
+ "string": ctx.line.strip(),
+ }
+ )
+
+ @staticmethod
+ def _reaction_type(rate: str, rr: list[str]) -> str:
+ """Conclude the reaction type from the reactants, falling back to rate.
+
+ Structural signals are checked first so the result survives custom
+ auxiliary-function rates: a ``_PHOTON`` reactant -> ``"photo"``, a
+ cosmic-ray pseudo-species (``_CR``/``_CRP``/``_CRPHOT``) ->
+ ``"cosmic_ray"``, three or more real reactants -> ``"3_body"``. Only
+ then is the rate inspected (``photo``/``av`` -> ``"photo"``, ``crate``
+ -> ``"cosmic_ray"``, ``ntot`` -> ``"3_body"``); otherwise ``"unknown"``.
+ """
+ if "_PHOTON" in rr:
+ return "photo"
+ if any(c in rr for c in ("_CR", "_CRP", "_CRPHOT")):
+ return "cosmic_ray"
+ if sum(1 for r in rr if not r.startswith("_")) >= 3:
+ return "3_body"
+
+ r = rate.lower()
+ if "photo" in r:
+ return "photo"
+ if "crate" in r:
+ return "cosmic_ray"
+ if "av" in r:
+ return "photo"
+ if "ntot" in r:
+ return "3_body"
+
+ return "unknown"
+
+ def _handle_errors(self, match: re.Match, ctx: ParseContext) -> None:
+ """Raise a descriptive error for a malformed KROME reaction line.
+
+ Diagnoses the most likely cause (wrong field count, wrong reactant or
+ product count) before falling back to a generic error message.
+ """
+ segment = match.group("segment").lower()
+ props = self.state(ctx)
+ num_fields = (
+ int(props["idx"])
+ + props["nreact"]
+ + props["nreact"]
+ + int(props["tmin"])
+ + int(props["tmax"])
+ + int(props["rate"])
+ )
+ num_fields_detected: int = segment.count(",") + 1
+
+ if num_fields != num_fields_detected:
+ ctx.raise_error(
+ "Number of fields in KROME reaction doesn't match\n"
+ f"Number of fields detected: {num_fields_detected}\n"
+ f"Number of fields expected: {num_fields}\n"
+ + (
+ f"KROME format defined on line: {props['format_nline']}"
+ if props["format_nline"]
+ else ""
+ )
+ )
+
+ if segment.count("r") != props["nreact"]:
+ ctx.raise_error(
+ "Expected number of reactants did not match krome format\n"
+ f"Number of reactants expected: {props['nreact']}\n"
+ f"Number of reactants detected: {segment.count('r')}\n"
+ + (
+ f"KROME format defined on line: {props['format_nline']}"
+ if props["format_nline"]
+ else ""
+ )
+ )
+
+ if segment.count("p") != props["nprod"]:
+ ctx.raise_error(
+ "Expected number of products did not match krome format\n"
+ f"Number of products expected: {props['nprod']}\n"
+ f"Number of products detected: {props['nprod']}\n"
+ + (
+ f"KROME format defined on line: {props['format_nline']}"
+ if props["format_nline"]
+ else ""
+ )
+ )
+
+ ctx.raise_error("Invalid KROME reaction detected")
diff --git a/src/jaff/core/parsers/network/_formats/krome/var.py b/src/jaff/core/parsers/network/_formats/krome/var.py
new file mode 100644
index 00000000..37eab0dd
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/krome/var.py
@@ -0,0 +1,47 @@
+"""KROME ``@var:`` directive — stores a symbolic global expression."""
+
+import re
+from functools import cache
+
+from sympy import parse_expr
+
+from ......common import f90_convert
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class KromeVar(NetworkFormat):
+ """KROME ``@var:`` directive — stores a symbolic global expression."""
+
+ priority = 20
+ name = "krome_var"
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(r"^\s*@var\s*:(?P.*?)$")
+
+ @cache
+ def _local_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(r"^\s*@var\s*:\s*(?P\w+)\s*=\s*\s*(?P.*?)\s*$")
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None:
+ """Parse a KROME ``@var:`` directive and store the symbolic expression.
+
+ Logs a warning and skips the variable when the expression is not valid
+ SymPy syntax (rather than raising a hard error).
+ """
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ ctx.raise_error("Invalid KROME variable assignment detected")
+
+ try:
+ ctx.globals[local.group("var").lower()] = parse_expr(
+ f90_convert(local.group("expr").lower())
+ )
+ except (SyntaxError, NameError, TypeError):
+ ctx.logger.warning(
+ f"Skipping variable: {local.group('var')}\n"
+ f"at line: {ctx.nline} since the expression is invalid sympy syntax"
+ )
diff --git a/src/jaff/core/parsers/network/_formats/prizmo/__init__.py b/src/jaff/core/parsers/network/_formats/prizmo/__init__.py
new file mode 100644
index 00000000..cdc23523
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/prizmo/__init__.py
@@ -0,0 +1,4 @@
+from .reaction import PrizmoReaction
+from .vars import PrizmoVars
+
+__all__ = ["PrizmoVars", "PrizmoReaction"]
diff --git a/src/jaff/core/parsers/network/_formats/prizmo/reaction.py b/src/jaff/core/parsers/network/_formats/prizmo/reaction.py
new file mode 100644
index 00000000..b8506d6a
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/prizmo/reaction.py
@@ -0,0 +1,146 @@
+"""PRIZMO arrow-notation reaction line."""
+
+import re
+from functools import cache
+
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class PrizmoReaction(NetworkFormat):
+ """PRIZMO arrow-notation reaction line."""
+
+ priority = 40
+ name = "prizmo"
+
+ SPECIAL_MAP = {
+ "GRAIN0": "_GRAIN",
+ "GRAIN": "_GRAIN",
+ "CR": "_CR",
+ "CRP": "_CRP",
+ "CRPHOT": "_CRPHOT",
+ "PHOTON": "_PHOTON",
+ "dummy": "_DUMMY",
+ }
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(r"^(?!\s*[!#]).*->.*$")
+
+ @cache
+ def _local_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(
+ r"^\s*"
+ r"(?P[\w\+\-\s]+)"
+ r"\s*->\s*"
+ r"(?P[\w\+\-\s]+)"
+ r"\s*\[\s*"
+ r"(?P[^,\]]*)?"
+ r"\s*,?\s*"
+ r"(?P[^,\]]*)?"
+ r"\s*\]\s*"
+ r"(?P.*)"
+ r"\s*$"
+ )
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None:
+ """Parse a PRIZMO-format reaction line and append it to the parsed list.
+
+ Extracts reactants, products, optional temperature bounds, and rate
+ expression from the ``R1 + R2 -> P1 + P2 [tmin, tmax] rate`` pattern.
+ Applies species-name normalisation (``HE`` → ``He``, ``E`` → ``e-``)
+ and exotic pseudo-species normalisation (``GRAIN0``/``GRAIN`` →
+ ``_GRAIN``, ``CR`` → ``_CR``, ``PHOTON`` → ``_PHOTON``, ``dummy`` →
+ ``_DUMMY``, ...), and converts ``user_crflux``/``user_av`` aliases to
+ canonical JAFF symbols.
+
+ Raises
+ ------
+ ParserError
+ Via :meth:`_handle_errors` if the line does not match the expected
+ PRIZMO format.
+ """
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ self._handle_errors(match, ctx)
+
+ reactants: str = local.group("reactants")
+ products: str = local.group("products")
+ tmin: str | None = local.group("tmin")
+ tmax: str | None = local.group("tmax")
+ rate: str = local.group("rate").strip()
+
+ reactants = (
+ reactants.replace("HE", "He").replace(" E", " e-").replace("E ", "e- ")
+ )
+ products = products.replace("HE", "He").replace(" E", " e-").replace("E ", "e- ")
+
+ rr: list[str] = [
+ self.SPECIAL_MAP.get(r.strip(), r.strip()) for r in reactants.split(" + ")
+ ]
+ pp: list[str] = [
+ self.SPECIAL_MAP.get(p.strip(), p.strip()) for p in products.split(" + ")
+ ]
+
+ t_min: float | None = (
+ float(tmin.strip().replace("d", "e")) if tmin and tmin.strip() else None
+ )
+ t_min = t_min if (t_min is not None and t_min > 0) else None
+
+ t_max: float | None = (
+ float(tmax.strip().replace("d", "e")) if tmax and tmax.strip() else None
+ )
+ t_max = t_max if (t_max is not None and t_max < 1e8) else None
+
+ rate = rate.replace("user_crflux", "crate").replace("user_av", "av")
+
+ if "photo" in rate.lower() and "_PHOTON" not in rr:
+ rr.append("_PHOTON")
+
+ ctx.parsed_list.append(
+ {
+ "r": rr,
+ "p": pp,
+ "tmin": t_min,
+ "tmax": t_max,
+ "rate": rate,
+ "type": self._reaction_type(rate, rr),
+ "string": ctx.line.strip(),
+ }
+ )
+
+ @staticmethod
+ def _reaction_type(rate: str, rr: list[str]) -> str:
+ """Conclude the reaction type from the reactants, falling back to rate.
+
+ Structural signals are checked first so the result survives custom
+ auxiliary-function rates: a ``_PHOTON`` reactant -> ``"photo"``, a
+ cosmic-ray pseudo-species (``_CR``/``_CRP``/``_CRPHOT``) ->
+ ``"cosmic_ray"``, three or more real reactants -> ``"3_body"``. Only
+ then is the rate inspected (``photo``/``av`` -> ``"photo"``, ``crate``
+ -> ``"cosmic_ray"``, ``ntot`` -> ``"3_body"``); otherwise ``"unknown"``.
+ """
+ if "_PHOTON" in rr:
+ return "photo"
+ if any(c in rr for c in ("_CR", "_CRP", "_CRPHOT")):
+ return "cosmic_ray"
+ if sum(1 for r in rr if not r.startswith("_")) >= 3:
+ return "3_body"
+
+ r = rate.lower()
+ if "photo" in r:
+ return "photo"
+ if "crate" in r:
+ return "cosmic_ray"
+ if "av" in r:
+ return "photo"
+ if "ntot" in r:
+ return "3_body"
+
+ return "unknown"
+
+ def _handle_errors(self, match: re.Match, ctx: ParseContext) -> None:
+ """Raise an error for a malformed PRIZMO reaction line."""
+ ctx.raise_error("Invalid PRIZMO reaction detected")
diff --git a/src/jaff/core/parsers/network/_formats/prizmo/vars.py b/src/jaff/core/parsers/network/_formats/prizmo/vars.py
new file mode 100644
index 00000000..c22896b1
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/prizmo/vars.py
@@ -0,0 +1,110 @@
+"""PRIZMO ``VARIABLES { }`` block lines and variable assignments."""
+
+import re
+from functools import cache
+
+from sympy import parse_expr
+
+from ......common import f90_convert
+from ..._typing import prizmoFormatProps
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class PrizmoVars(NetworkFormat):
+ """PRIZMO ``VARIABLES { }`` block lines and variable assignments."""
+
+ priority = 30
+ name = "prizmo_vars"
+ state_key = "prizmo"
+
+ def default_state(self) -> prizmoFormatProps:
+ return {"parse_vars": False}
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(
+ r"^\s*(?:"
+ r"(?:(?i:variables)\s*\{|\})(?P.*?)"
+ r"|"
+ r"(?P\w+\s*=.*?)"
+ r")\s*$"
+ )
+
+ @cache
+ def _local_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(
+ r"^\s*(?P(?i:variables)\s*\{)\s*$"
+ r"|"
+ r"^\s*(?P\}\s*)$"
+ r"|"
+ r"^\s*(?P\w+)\s*=\s*\s*(?P.*?)\s*$"
+ )
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None:
+ """Handle a PRIZMO ``VARIABLES { }`` block line or variable assignment.
+
+ Toggles ``parse_vars`` on ``VARIABLES {`` and ``}`` tokens, and stores a
+ parsed SymPy expression for any ``var = expr`` line encountered while
+ inside the block.
+
+ Raises
+ ------
+ ParserError
+ Via :meth:`_handle_errors` if the line is malformed.
+ """
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ self._handle_errors(match, ctx)
+
+ assert local is not None
+
+ if local.group("begin"):
+ self.state(ctx)["parse_vars"] = True
+ return
+
+ if local.group("end"):
+ self.state(ctx)["parse_vars"] = False
+ return
+
+ if local.group("var") and local.group("expr") and self.state(ctx)["parse_vars"]:
+ try:
+ ctx.globals[local.group("var").lower()] = parse_expr(
+ f90_convert(local.group("expr").lower())
+ )
+
+ except (SyntaxError, NameError, TypeError):
+ ctx.logger.warning(
+ f"Skipping variable: {local.group('var')}\n"
+ f"at line: {ctx.nline} since the expression is invalid sympy syntax"
+ )
+
+ def _handle_errors(self, match: re.Match, ctx: ParseContext) -> None:
+ """Raise a descriptive error for a malformed PRIZMO variables section line."""
+ segment = match.group("segment")
+ assignment = match.group("assignment")
+
+ if segment is None and assignment is None:
+ ctx.raise_error("Invalid PRIZMO variable section")
+
+ if assignment is not None:
+ if not self.state(ctx)["parse_vars"]:
+ ctx.raise_error(
+ "PRIZMO variable assignment found outside VARIABLES block"
+ )
+
+ var_name, expr = assignment.split("=", 1)
+ var_name = var_name.strip()
+ expr = expr.strip()
+
+ if not var_name.isidentifier():
+ ctx.raise_error(f"Invalid variable name '{var_name}'")
+
+ if not expr:
+ ctx.raise_error("Expression cannot be empty")
+
+ segment = segment.strip()
+ if segment:
+ ctx.raise_error("Extra characters found after PRIZMO block declarative")
diff --git a/src/jaff/core/parsers/network/_formats/uclchem/__init__.py b/src/jaff/core/parsers/network/_formats/uclchem/__init__.py
new file mode 100644
index 00000000..d4ae3106
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/uclchem/__init__.py
@@ -0,0 +1,3 @@
+from .reaction import UclchemReaction
+
+__all__ = ["UclchemReaction"]
diff --git a/src/jaff/core/parsers/network/_formats/uclchem/reaction.py b/src/jaff/core/parsers/network/_formats/uclchem/reaction.py
new file mode 100644
index 00000000..f88baf77
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/uclchem/reaction.py
@@ -0,0 +1,200 @@
+"""UCLCHEM format: comma-delimited reactions with a ``NAN`` sentinel column."""
+
+import re
+from functools import cache
+
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class UclchemReaction(NetworkFormat):
+ """UCLCHEM comma-delimited reaction line (``NAN``-sentinel format)."""
+
+ priority = 70
+ name = "uclchem"
+
+ SPECIAL_MAP = {
+ "CR": "_CR",
+ "CRP": "_CRP",
+ "CRPHOT": "_CRPHOT",
+ "PHOTON": "_PHOTON",
+ }
+
+ ELEMENT_REPS = {"HE": "He", "SI": "Si", "CL": "Cl", "MG": "Mg"}
+
+ IGNORE_SPECIES = {
+ "NAN",
+ "",
+ "ER",
+ "ERDES",
+ "FREEZE",
+ "H2FORM",
+ "BULKSWAP",
+ "DESCR",
+ "DESOH2",
+ "DEUVCR",
+ "LH",
+ "LHDES",
+ "SURFSWAP",
+ "THERM",
+ }
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(r"^(?!\s*[!]|(?:\s*#\s)).*,\s*(?i:NAN)\s*(?:,|$)")
+
+ @cache
+ def _local_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(
+ r"^\s*"
+ r"(?=.*,\s*(?i:NAN)\s*(?:,|$))"
+ r"(?P(?:[#@\w\d\+-]*\s*,\s*){3})"
+ r"(?P(?:[#@\w\d\+-]*\s*,\s*){4})"
+ r"(?P[^,]*)\s*,\s*"
+ r"(?P[^,]*)\s*,\s*"
+ r"(?P[^,]*)\s*,\s*"
+ r"(?P[^,]*)\s*,\s*"
+ r"(?P[^,]*)\s*,\s*"
+ r"(?P.*?)"
+ r"\s*$"
+ )
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None:
+ """Parse a UCLCHEM-format reaction line and append it to the parsed list.
+
+ Extracts reactants, products, rate parameters, temperature bounds, and
+ an extrapolation flag from the comma-delimited UCLCHEM format (identified
+ by the ``NAN`` sentinel column). Species names are normalised via
+ :meth:`_normalize_species`.
+
+ Raises
+ ------
+ ParserError
+ Via :meth:`_handle_errors` if the line does not match the expected
+ UCLCHEM format.
+ """
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ self._handle_errors(match, ctx)
+
+ reactants: str = local.group("reactants")
+ products: str = local.group("products")
+ ka: float = float(local.group("ka"))
+ kb: float = float(local.group("kb"))
+ kc: float = float(local.group("kc"))
+ tmin: float = float(local.group("tmin"))
+ tmax: float = float(local.group("tmax"))
+ extrapolate: bool = local.group("extrapolate").strip().lower() == "true"
+
+ t_min: float = 3.0 if extrapolate else tmin
+ t_max: float = 1e6 if extrapolate else tmax
+
+ rr: list[str] = [self._normalize_species(r) for r in reactants.split(",")]
+ pp: list[str] = [
+ self._normalize_species(p)
+ for p in products.split(",")
+ if p.strip().upper() not in self.IGNORE_SPECIES
+ ]
+
+ rate = "0.0"
+ rate_dict = {
+ "CRP": f"{ka:.2e} * crate",
+ "CRPHOT": f"{ka:.2e} * (tgas/3e2)**({kb:.2f}) * crate",
+ "PHOTON": f"{ka:.2e} * fuv * exp(-{kc:.2f} * av)",
+ "FREEZE": f"(1e0 + {kb:.2e} * 1.671e-3/tgas/asize)*nuth*sigmah*sqrt(tgas/m)",
+ }
+ for r in rr:
+ if r.upper() in rate_dict:
+ rate = rate_dict[r.upper()]
+ break
+ rr = [r for r in rr if r.strip().upper() not in self.IGNORE_SPECIES]
+
+ # Normalise exotics after rate selection (which keys off raw tokens).
+ rr = [self.SPECIAL_MAP.get(r, r) for r in rr]
+ pp = [self.SPECIAL_MAP.get(p, p) for p in pp]
+
+ # FIXME: old parser sets rate = "0.0" at the very end
+ rate = "0.0"
+
+ ctx.parsed_list.append(
+ {
+ "r": rr,
+ "p": pp,
+ "tmin": t_min,
+ "tmax": t_max,
+ "rate": rate,
+ "type": self._reaction_type(rate, rr),
+ "string": ctx.line.strip(),
+ }
+ )
+
+ @staticmethod
+ def _reaction_type(rate: str, rr: list[str]) -> str:
+ """Conclude the reaction type from the reactants, falling back to rate.
+
+ Structural signals are checked first so the result survives custom
+ auxiliary-function rates: a ``_PHOTON`` reactant -> ``"photo"``, a
+ cosmic-ray pseudo-species (``_CR``/``_CRP``/``_CRPHOT``) ->
+ ``"cosmic_ray"``, three or more real reactants -> ``"3_body"``. Only
+ then is the rate inspected (``photo``/``av`` -> ``"photo"``, ``crate``
+ -> ``"cosmic_ray"``, ``ntot`` -> ``"3_body"``); otherwise ``"unknown"``.
+ The UCLCHEM rate is FIXME-forced to ``"0.0"`` upstream, so the rate
+ fallback never contributes; classification relies on the reactants.
+ """
+ if "_PHOTON" in rr:
+ return "photo"
+ if any(c in rr for c in ("_CR", "_CRP", "_CRPHOT")):
+ return "cosmic_ray"
+ if sum(1 for r in rr if not r.startswith("_")) >= 3:
+ return "3_body"
+
+ r = rate.lower()
+ if "photo" in r:
+ return "photo"
+ if "crate" in r:
+ return "cosmic_ray"
+ if "av" in r:
+ return "photo"
+ if "ntot" in r:
+ return "3_body"
+
+ return "unknown"
+
+ def _handle_errors(self, match: re.Match, ctx: ParseContext) -> None:
+ """Raise an error for a malformed UCLCHEM reaction line."""
+ ctx.raise_error("Invalid UCLCHEM reaction detected")
+
+ @staticmethod
+ def _normalize_species(s: str) -> str:
+ """Normalise a UCLCHEM species token to the JAFF canonical form.
+
+ Transformations applied:
+ - ``#X`` → ``X_DUST`` (grain-surface species prefix)
+ - ``@X`` → ``X_BULK`` (bulk ice species prefix)
+ - ``E-`` → ``e-`` (electron lower-case)
+ - ``HE`` → ``He``, ``SI`` → ``Si``, ``CL`` → ``Cl``, ``MG`` → ``Mg``
+
+ Parameters
+ ----------
+ s : str
+ Raw species token from the UCLCHEM file.
+
+ Returns
+ -------
+ str
+ Normalised species name.
+ """
+ s = s.strip()
+ if s.startswith("#"):
+ s = s[1:] + "_DUST"
+ if s.startswith("@"):
+ s = s[1:] + "_BULK"
+ if s == "E-":
+ s = "e-"
+
+ for k, v in UclchemReaction.ELEMENT_REPS.items():
+ s = s.replace(k, v)
+
+ return s
diff --git a/src/jaff/core/parsers/network/_formats/udfa/__init__.py b/src/jaff/core/parsers/network/_formats/udfa/__init__.py
new file mode 100644
index 00000000..55bf2f83
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/udfa/__init__.py
@@ -0,0 +1,3 @@
+from .reaction import UdfaReaction
+
+__all__ = ["UdfaReaction"]
diff --git a/src/jaff/core/parsers/network/_formats/udfa/reaction.py b/src/jaff/core/parsers/network/_formats/udfa/reaction.py
new file mode 100644
index 00000000..00bf80dd
--- /dev/null
+++ b/src/jaff/core/parsers/network/_formats/udfa/reaction.py
@@ -0,0 +1,135 @@
+"""UDFA (UMIST) format: colon-delimited fixed-column reaction database."""
+
+import re
+from functools import cache
+
+from .. import register
+from .._base import NetworkFormat
+from .._context import ParseContext
+
+
+@register
+class UdfaReaction(NetworkFormat):
+ """UDFA colon-delimited reaction line."""
+
+ priority = 50
+ name = "udfa"
+
+ SPECIAL_MAP = {
+ "CR": "_CR",
+ "CRP": "_CRP",
+ "CRPHOT": "_CRPHOT",
+ "PHOTON": "_PHOTON",
+ }
+
+ @cache
+ def _global_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(r"^(?!\s*[!#@]).*:.*$")
+
+ @cache
+ def _local_re(self, ctx: ParseContext) -> re.Pattern:
+ return re.compile(
+ r"^\s*\d+\s*:"
+ r"\s*(?P[^:]*?)\s*:"
+ r"\s*(?P(?:[^:]*:){2})"
+ r"\s*(?P(?:[^:]*:){4})"
+ r"\s*(?P[^:]*)\s*:"
+ r"\s*(?P[^:]*)\s*:"
+ r"\s*(?P[^:]*)\s*:"
+ r"\s*(?P[^:]*)\s*:"
+ r"\s*(?P[^:]*)\s*:"
+ r"\s*(?P[^:]*?)(?:\s*:.*)?$"
+ )
+
+ def handle(self, match: re.Match, ctx: ParseContext) -> None:
+ """Parse a UDFA (UMIST)-format reaction line and append it to the parsed list.
+
+ Extracts the reaction type, reactants, products, rate parameters
+ (``ka``, ``kb``, ``kc``), and temperature bounds from the
+ colon-delimited UDFA format. Constructs a rate expression based on
+ the reaction type: cosmic-ray (``"CR"``), photo-desorption (``"PH"``),
+ or standard Arrhenius.
+
+ Raises
+ ------
+ ParserError
+ Via :meth:`_handle_errors` if the line does not match the expected
+ UDFA format.
+ """
+ local = self._local_re(ctx).match(ctx.line)
+ if not local:
+ self._handle_errors(match, ctx)
+
+ rtype: str = local.group("rtype")
+ reactants: str = local.group("reactants")
+ products: str = local.group("products")
+ ka: float = float(local.group("ka"))
+ kb: float = float(local.group("kb"))
+ kc: float = float(local.group("kc"))
+ tmin: float = float(local.group("tmin"))
+ tmax: float = float(local.group("tmax"))
+
+ t_min: None | float = tmin if tmin > 0 else None
+ t_max: None | float = tmax if tmax < 41000.0 else None
+
+ rate_dict = {
+ "CR": f"{kc:.2e} * crate",
+ "PH": f"{ka:.2e} * exp(-{kc:.2f} * av)",
+ }
+ rate = f"{ka:.2e}"
+ if kb:
+ rate = f"{rate} * (tgas / 3e2)**({kb:.2f})"
+ if kc:
+ rate = f"{rate} * exp(-{kc:.2f} / tgas)"
+
+ if rtype in rate_dict:
+ rate = rate_dict[rtype]
+
+ rr = [
+ self.SPECIAL_MAP.get(r.strip(), r.strip())
+ for r in reactants.split(":")[:-1]
+ if r.strip() != ""
+ ]
+ pp = [
+ self.SPECIAL_MAP.get(p.strip(), p.strip())
+ for p in products.split(":")[:-1]
+ if p.strip() != ""
+ ]
+
+ if rtype == "PH" and "_PHOTON" not in rr:
+ rr.append("_PHOTON")
+ elif rtype == "CR" and not any(cr in rr for cr in ("_CR", "_CRP", "_CRPHOT")):
+ rr.append("_CR")
+
+ ctx.parsed_list.append(
+ {
+ "r": rr,
+ "p": pp,
+ "tmin": t_min,
+ "tmax": t_max,
+ "rate": rate,
+ "type": self._reaction_type(rtype, rr),
+ "string": ctx.line.strip(),
+ }
+ )
+
+ def _handle_errors(self, match: re.Match, ctx: ParseContext) -> None:
+ """Raise an error for a malformed UDFA reaction line."""
+ ctx.raise_error("Invalid UDFA reaction detected")
+
+ @staticmethod
+ def _reaction_type(rtype: str, rr: list[str]) -> str:
+ """Conclude the reaction type from the UDFA code and reactants.
+
+ ``"CR"`` = cosmic-ray, ``"PH"`` = photoprocess. Otherwise a reaction
+ with three or more real (non-pseudo) reactants is three-body; else
+ ``"unknown"``. Reactant-count classification is rate-independent, so it
+ survives custom auxiliary-function rates.
+ """
+ agent = {"CR": "cosmic_ray", "PH": "photo"}.get(rtype)
+ if agent:
+ return agent
+ if sum(1 for r in rr if not r.startswith("_")) >= 3:
+ return "3_body"
+
+ return "unknown"
diff --git a/src/jaff/core/parsers/network/_typing/__init__.py b/src/jaff/core/parsers/network/_typing/__init__.py
new file mode 100644
index 00000000..6351b364
--- /dev/null
+++ b/src/jaff/core/parsers/network/_typing/__init__.py
@@ -0,0 +1,4 @@
+from ._formats import kromeFormatProps, prizmoFormatProps
+from ._parsed import parsedListProps
+
+__all__ = ["parsedListProps", "kromeFormatProps", "prizmoFormatProps"]
diff --git a/src/jaff/core/parsers/network/_typing/_formats.py b/src/jaff/core/parsers/network/_typing/_formats.py
new file mode 100644
index 00000000..dad5babf
--- /dev/null
+++ b/src/jaff/core/parsers/network/_typing/_formats.py
@@ -0,0 +1,21 @@
+from typing import TypedDict
+
+kromeFormatProps = TypedDict(
+ "kromeFormatProps",
+ {
+ "format_nline": int,
+ "idx": bool,
+ "nreact": int,
+ "nprod": int,
+ "tmin": bool,
+ "tmax": bool,
+ "rate": bool,
+ },
+)
+
+prizmoFormatProps = TypedDict(
+ "prizmoFormatProps",
+ {
+ "parse_vars": bool,
+ },
+)
diff --git a/src/jaff/core/parsers/network/_typing/_parsed.py b/src/jaff/core/parsers/network/_typing/_parsed.py
new file mode 100644
index 00000000..793f933f
--- /dev/null
+++ b/src/jaff/core/parsers/network/_typing/_parsed.py
@@ -0,0 +1,14 @@
+from typing import TypedDict
+
+parsedListProps = TypedDict(
+ "parsedListProps",
+ {
+ "r": list[str],
+ "p": list[str],
+ "tmin": float | None,
+ "tmax": float | None,
+ "rate": str,
+ "type": str,
+ "string": str,
+ },
+)
diff --git a/src/jaff/core/reaction.py b/src/jaff/core/reaction.py
index df2520de..4d5e6590 100644
--- a/src/jaff/core/reaction.py
+++ b/src/jaff/core/reaction.py
@@ -11,20 +11,26 @@
"__"
-where species names are sorted alphabetically and joined with ``"_"``.
-For example ``H + H2O+ -> H2O + H+`` serializes as
-``"H_H2O+__H+_H2O"``. This canonical form is used for equality testing,
-hashing, and duplicate detection.
+where species names are sorted alphabetically and joined with ``"."``, and
+the two sides are separated by ``"__"``. For example
+``H + H2O+ -> H2O + H+`` serializes as ``"H.H2O+__H+.H2O"``. This canonical
+form is used for equality testing, hashing, and duplicate detection.
+
+The ``"."`` species joiner (rather than ``"_"``) is required because special
+pseudo-species names start with an underscore (e.g. ``_PHOTON``, ``_GRAIN``)
+and underscore-suffixed grain/ice species exist (``X_DUST``); a ``"_"`` joiner
+would collide with those and make the form ambiguous.
Reaction types
--------------
-``rtype()`` classifies reactions by inspecting the symbolic rate expression:
-
-- ``"photo"`` — rate contains a ``photorates(...)`` function call
-- ``"cosmic_ray"`` — rate contains the symbol ``crate``
-- ``"photo_av"`` — rate contains the symbol ``av``
-- ``"3_body"`` — rate contains the symbol ``ntot``
-- ``"unknown"`` — none of the above
+The reaction type is concluded by the network-format parser and passed to the
+``Reaction`` constructor; the ``type`` attribute holds that stored value (it no
+longer inspects the rate expression). One of:
+
+- ``"photo"`` — radiation-driven (photodissociation/ionisation)
+- ``"cosmic_ray"`` — cosmic-ray driven
+- ``"3_body"`` — three-body reaction
+- ``"unknown"`` — unclassified
"""
from __future__ import annotations
@@ -38,15 +44,6 @@
Basic,
Expr,
Function,
- ccode,
- cxxcode,
- fcode,
- julia_code,
- lambdify,
- pycode,
- rcode,
- rust_code,
- symbols,
sympify,
)
@@ -58,6 +55,27 @@
if TYPE_CHECKING:
import matplotlib.pyplot as plt
+ import pandas as pd
+
+ from ..physics.photo_reactions._radiation import RadiationGroup
+
+
+def _to_float_or_none(value: Any) -> float | None:
+ """Coerce a band quantity to ``float``, or ``None`` when not representable.
+
+ Band edges and averages may be plain numbers, SymPy numeric objects, or
+ ``sympy.oo`` (open upper band, which casts to ``float('inf')``). A value
+ of ``None`` (e.g. the cross section of a custom-rate reaction) or a
+ still-symbolic expression maps to ``None`` so it becomes ``NaN`` in a
+ :class:`pandas.DataFrame`.
+ """
+ if value is None:
+ return None
+
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
class Reaction:
@@ -87,14 +105,14 @@ class Reaction:
Human-readable string ``"R1 + R2 -> P1 + P2"``.
index : int
Position of this reaction in the parent ``Reactions`` catalogue.
+ type : str
+ Reaction type concluded by the parser (``"photo"``, ``"cosmic_ray"``,
+ ``"3_body"``, ``"unknown"``).
serialized : str
Canonical form ``"__"``.
serialized_exploded : str
Like ``serialized`` but built from the atom-level serialized forms of
each species (isomer-insensitive comparison).
- metadata : dict
- Arbitrary key/value store; ``metadata["type"]`` is populated by
- ``rtype()``.
custom_rad_rate : bool
``True`` when the radiation rate was supplied via a ``.jfunc`` aux
function rather than computed from cross-sections.
@@ -103,6 +121,10 @@ class Reaction:
``photon_energy`` (eV), optional ``photo_absorption`` and the
``photodecay`` array (cm²), plus ``_equations`` metadata. ``None`` for
non-photo reactions.
+ rad_groups : list[RadiationGroup]
+ Back-references to the radiation bands this reaction contributes to,
+ populated when a radiation field is configured. Empty otherwise. See
+ the :attr:`band_xsecs` property for the band-averaged cross sections.
"""
def __init__(
@@ -116,6 +138,7 @@ def __init__(
dRad: Basic,
original_string: str,
index: int,
+ type: str = "unknown",
errors: bool = False,
):
"""Construct a ``Reaction`` and validate mass/charge conservation.
@@ -140,6 +163,11 @@ def __init__(
The raw network-file line that produced this reaction.
index : int
Zero-based position in the parent ``Reactions`` catalogue.
+ type : str, optional
+ Reaction type as concluded by the network-format parser (e.g.
+ ``"photo"``, ``"cosmic_ray"``, ``"3_body"``, ``"unknown"``).
+ Stored verbatim on the ``type`` attribute; defaults to
+ ``"unknown"``.
errors : bool, optional
If ``True``, terminate the process on mass or charge conservation
violations instead of merely logging a warning, by default
@@ -160,6 +188,7 @@ def __init__(
self.dRad: Basic = dRad
self.custom_rad_rate: bool = False
self.rad_xsecs: float | None = None
+ self.rad_groups: list[RadiationGroup] = []
self.xsecs_dict: XsecsProps | None = None
self.original_string = original_string
# verbatim is kept for backward compatibility alongside original_string
@@ -169,10 +198,12 @@ def __init__(
self.check(errors)
self.serialized_exploded: str = self.serialize_exploded()
self.serialized: str = self.serialize()
- self.metadata: dict = {}
-
- # Eagerly classify the reaction so metadata["type"] is populated.
- self.rtype()
+ # The reaction type is concluded by the parser and supplied here, not
+ # inferred from the rate expression.
+ self.type: str = type
+ # Private key/value store for parser- and physics-supplied extras
+ # (e.g. shielding model props). Not part of the public API.
+ self._metadata: dict = {}
def __repr__(self):
"""Return detailed string representation of this reaction.
@@ -271,57 +302,6 @@ def elements(self) -> Elements:
"""
return Elements(self.reactants._list + self.products._list)
- def rtype(self) -> str:
- """Classify this reaction by inspecting its rate expression.
-
- Returns
- -------
- str
- One of ``"photo"``, ``"cosmic_ray"``, ``"photo_av"``,
- ``"3_body"``, or ``"unknown"``.
-
- Notes
- -----
- Classification rules (evaluated in order):
-
- - ``"photo"`` — rate is or contains ``photorates(...)``
- - ``"cosmic_ray"`` — rate contains the free symbol ``crate``
- - ``"photo_av"`` — rate contains the free symbol ``av``
- - ``"3_body"`` — rate contains the free symbol ``ntot``
- - ``"unknown"`` — none of the above match
-
- The result is also cached in ``self.metadata["type"]``.
- """
- if "type" in self.metadata:
- return self.metadata["type"]
-
- rtype = "unknown"
-
- if type(self.rate) is str:
- if "photo" in self.rate:
- rtype = "photo"
- else:
- if hasattr(self.rate, "func") and isinstance(
- self.rate.func, type(Function("f"))
- ):
- if self.rate.func.__name__ == "photorates":
- rtype = "photo"
- elif any(
- getattr(s, "name", None) in ("photden", "radeden")
- for s in self.rate.free_symbols
- ):
- rtype = "photo"
- elif self.rate.has(symbols("crate")):
- rtype = "cosmic_ray"
- elif self.rate.has(symbols("av")):
- rtype = "photo_av"
- elif self.rate.has(symbols("ntot")):
- rtype = "3_body"
-
- self.metadata["type"] = rtype
-
- return rtype
-
def is_isomer_version(self, other: "Reaction") -> bool:
"""Check whether *other* is an isomer variant of this reaction.
@@ -353,29 +333,29 @@ def serialize_exploded(self) -> str:
Each species is replaced by its ``Specie.serialized`` form (e.g.
H2O+ → ``"+/H/H/O"``), then species tokens are sorted and joined
- with ``"_"``. Reactants and products are separated by ``"__"``.
+ with ``"."``. Reactants and products are separated by ``"__"``.
Returns
-------
str
"""
- sr = "_".join(sorted([x.serialized for x in self.reactants]))
- sp = "_".join(sorted([x.serialized for x in self.products]))
+ sr = ".".join(sorted([x.serialized for x in self.reactants]))
+ sp = ".".join(sorted([x.serialized for x in self.products]))
return f"{sr}__{sp}"
def serialize(self) -> str:
"""Build the name-level serialized form (isomer-sensitive).
- Species names are sorted alphabetically and joined with ``"_"``.
+ Species names are sorted alphabetically and joined with ``"."``.
Reactants and products are separated by ``"__"``.
Returns
-------
str
"""
- sr = "_".join(sorted([x.name for x in self.reactants]))
- sp = "_".join(sorted([x.name for x in self.products]))
+ sr = ".".join(sorted([x.name for x in self.reactants]))
+ sp = ".".join(sorted([x.name for x in self.products]))
return f"{sr}__{sp}"
@@ -467,7 +447,9 @@ def get_flux_expression(
"""Return a source-code string for the reaction flux.
The flux has the form ``k[idx] * y[idx_R1] * y[idx_R2] * ...``,
- where ``idx_Ri`` is derived from each reactant's ``fidx`` attribute.
+ where ``idx_Ri`` is derived from each core reactant's ``fidx``
+ attribute. Special pseudo-species (``_PHOTON``, ``_CR``, ...) are
+ excluded — they carry the reaction's identity but not its kinetics.
Parameters
----------
@@ -500,7 +482,10 @@ def get_flux_expression(
lb, rb = brackets[0], brackets[1]
flux = f"{rate_variable}{lb}{idx}{rb} * " + " * ".join(
- [f"{species_variable}{lb}{idx_prefix + x.fidx}{rb}" for x in self.reactants]
+ [
+ f"{species_variable}{lb}{idx_prefix + x.fidx}{rb}"
+ for x in self.reactants.core
+ ]
)
return flux
@@ -595,23 +580,13 @@ def get_code(self, lang="cpp") -> str:
Raises
------
- ValueError
+ InvalidLanguageError
If *lang* is not one of the supported language keys.
"""
- fmap = {
- "python": pycode,
- "c": ccode,
- "cxx": cxxcode,
- "fortran": fcode,
- "rust": rust_code,
- "julia": julia_code,
- "r": rcode,
- }
-
- if not fmap.get(lang, ""):
- raise ValueError(
- f"{lang} is not supported. Supported languages are:\n\n{fmap.keys()}"
- )
+ from ..codegen import Language
+
+ language = Language(lang)
+
if (
hasattr(self.rate, "func")
and isinstance(self.rate.func, type(Function("f")))
@@ -622,7 +597,7 @@ def get_code(self, lang="cpp") -> str:
f"photorates($IDX$, {', '.join(str(arg) for arg in self.rate.args[1:])})"
)
- return fmap[lang](self.get_sympy(), strict=False)
+ return language.code_gen(self.get_sympy(), strict=False)
def get_sympy(self) -> Basic:
"""Return the rate as a canonical SymPy expression.
@@ -633,6 +608,55 @@ def get_sympy(self) -> Basic:
"""
return sympify(self.rate)
+ @property
+ def band_xsecs(self) -> pd.DataFrame:
+ """Band-averaged cross sections for this reaction, one row per band.
+
+ Assembles a tidy table from the :class:`~jaff.physics.photo_reactions._radiation.RadiationGroup`
+ back-references in :attr:`rad_groups` (populated when a radiation field
+ is configured). Intended as the data source for band bar plots.
+
+ Returns
+ -------
+ pandas.DataFrame
+ One row per radiation band this reaction contributes to, with
+ columns:
+
+ - ``lower`` : lower band edge in eV.
+ - ``upper`` : upper band edge in eV (``inf`` for an open top band).
+ - ``eavg`` : photon-number-weighted band-average energy in eV.
+ - ``xsec`` : photon-number-weighted band-average cross section in
+ cm² (``NaN`` for custom-rate reactions, which carry no tabulated
+ cross section).
+ - ``xsec_frac`` : fraction of the total cross section (or ``dRad``)
+ attributed to the band.
+
+ Empty (with the columns above) when the reaction contributes to no
+ band, e.g. no radiation field is configured.
+
+ Notes
+ -----
+ The rows are ordered by ascending band index, matching
+ :attr:`rad_groups`.
+ """
+ import pandas as pd
+
+ columns = ["lower", "upper", "eavg", "xsec", "xsec_frac"]
+ rows = [
+ {
+ "lower": _to_float_or_none(group.lower),
+ "upper": _to_float_or_none(group.upper),
+ "eavg": _to_float_or_none(group.eavg),
+ "xsec": _to_float_or_none(group.props.get(self, {}).get("xsec")),
+ "xsec_frac": _to_float_or_none(
+ group.props.get(self, {}).get("xsec_frac")
+ ),
+ }
+ for group in self.rad_groups
+ ]
+
+ return pd.DataFrame(rows, columns=columns)
+
def plot_rate_coefficient(
self,
fig: plt.Figure | None = None,
@@ -642,11 +666,12 @@ def plot_rate_coefficient(
show: bool = True,
save: bool = False,
filename: str = "",
- ) -> tuple[plt.Figure, plt.Axes]:
+ ) -> tuple[plt.Figure, plt.Axes] | None:
"""Plot the rate coefficient as a function of gas temperature.
- The styled :class:`jaff.plotting.Plotter` is used so the figure
- matches the publication house style.
+ A thin wrapper around :func:`jaff.plotting.plot_rates`, which applies
+ the publication house style. To compare several reactions on one axes,
+ call ``plot_rates`` (or :meth:`Reactions.plot_rates`) directly.
Parameters
----------
@@ -665,38 +690,31 @@ def plot_rate_coefficient(
Returns
-------
- tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]
+ tuple[matplotlib.figure.Figure, matplotlib.axes.Axes] or None
+ ``None`` if the rate cannot be evaluated numerically (e.g. a photo
+ reaction, whose rate carries the symbolic radiation-density
+ variable).
Notes
-----
The temperature axis spans [``tmin``, ``tmax``] on a log scale.
When ``tmin`` or ``tmax`` is ``None``, defaults of 2.73 K and 1e6 K
- are used respectively.
+ are used respectively. Drawing is delegated to
+ :func:`jaff.plotting.plot_rates`, which also plots lists of reactions
+ on shared axes.
"""
- from ..plotting import Plotter
-
- tmin = 2.73 if self.tmin is None else self.tmin
- tmax = 1e6 if self.tmax is None else self.tmax
-
- tgas = np.logspace(np.log10(tmin), np.log10(tmax), 100)
- r = lambdify("tgas", self.rate, "numpy")
- y = np.array([r(t) for t in tgas])
+ from ..plotting import plot_rates
- return Plotter().plot(
- x=tgas,
- y=y,
+ return plot_rates(
+ [self],
fig=fig,
ax=ax,
- xlabel="Temperature (K)",
- ylabel=r"Rate coefficient $k$",
- xscale="log",
- yscale="log",
title=title or self.get_latex(),
grid=grid,
show=show,
save=save,
filename=filename or f"{self}_rate.png",
- )
+ ) # type: ignore
def plot_xsecs(
self,
@@ -708,6 +726,8 @@ def plot_xsecs(
xsec_unit: str = "Mb",
energy_log: bool = True,
xsecs_log: bool = True,
+ shade: bool | float = False,
+ show_bands: bool = False,
title: str | None = None,
grid: bool = True,
show: bool = True,
@@ -736,6 +756,13 @@ def plot_xsecs(
(megabarn); ``"cm^2"`` and ``"barn"`` are also accepted.
energy_log, xsecs_log : bool, optional
Log-scale the energy / cross-section axis (default ``True``).
+ shade : bool or float, optional
+ Shade the area under each curve. ``True`` uses a default alpha; a
+ float sets the alpha explicitly. Default ``False``.
+ show_bands : bool, optional
+ Overlay the band-averaged cross section (from :attr:`band_xsecs`)
+ as bars. Only meaningful when a radiation field is configured;
+ silently draws nothing otherwise. Default ``False``.
Returns
-------
@@ -746,55 +773,24 @@ def plot_xsecs(
-----
Does nothing (logs a message) if ``self.xsecs_dict`` is ``None`` or no
requested process has data. Drawing, unit conversion and labelling are
- delegated to :meth:`jaff.plotting.Plotter.plot_xsec`.
+ delegated to :func:`jaff.plotting.plot_xsecs`, which also plots lists of
+ reactions on shared axes.
"""
- from ..plotting import Plotter
-
- if self.xsecs_dict is None:
- self.logger.info(f"No cross sections available for: {self}")
- return None
-
- _XSEC_PROCESSES = (
- "photo_absorption",
- "photodecay",
- )
+ from ..plotting import plot_xsecs
- # Normalise the process selection to a list of valid keys.
- if processes is None or processes == "all":
- procs = list(_XSEC_PROCESSES)
- elif isinstance(processes, str):
- procs = [processes]
- else:
- procs = list(processes)
-
- invalid = [p for p in procs if p not in _XSEC_PROCESSES]
- if invalid:
- raise KeyError(
- f"Invalid cross-section(s) {invalid}. Supported: "
- f"{', '.join(_XSEC_PROCESSES)}"
- )
-
- # Keep only processes that actually carry data for this reaction.
- available = [p for p in procs if self.xsecs_dict.get(p) is not None]
- if not available:
- self.logger.info(f"No data for requested cross-section(s) {procs} in: {self}")
- return
-
- if not filename:
- stem = available[0] if len(available) == 1 else "cross_sections"
- filename = f"{self}_{stem}.png"
-
- return Plotter().plot_xsec(
- self.xsecs_dict,
- processes=available,
+ return plot_xsecs(
+ [self],
+ processes=processes,
layout=layout,
fig=fig,
ax=ax,
energy_unit=energy_unit,
xsec_unit=xsec_unit,
energy_log=energy_log,
- xsec_log=xsecs_log,
- title=title or self.get_latex(),
+ xsecs_log=xsecs_log,
+ shade=shade,
+ show_bands=show_bands,
+ title=title,
grid=grid,
show=show,
save=save,
@@ -864,14 +860,14 @@ def from_serialized(self, serialized: str) -> Reaction:
"""
return self._by_serialized[serialized]
- def from_verbatim(self, verbatim: str, rtype: str | None = None) -> Reaction | None:
+ def from_verbatim(self, verbatim: str, type: str | None = None) -> Reaction | None:
"""Look up a reaction by its verbatim string.
Parameters
----------
verbatim : str
Human-readable string (e.g. ``"H + H2O+ -> H2 + OH+"``).
- rtype : str or None, optional
+ type : str or None, optional
If supplied, return ``None`` when the reaction type does not match.
Returns
@@ -879,7 +875,7 @@ def from_verbatim(self, verbatim: str, rtype: str | None = None) -> Reaction | N
Reaction or None
"""
rea = self._by_name[verbatim]
- if rtype is None or rea.rtype() == rtype:
+ if type is None or rea.type == type:
return rea
def get_list(self) -> list[Reaction]:
@@ -891,14 +887,14 @@ def get_list(self) -> list[Reaction]:
"""
return self._list
- def get(self, reaction: str, rtype: str | None = None) -> Reaction | None:
+ def get(self, reaction: str, type: str | None = None) -> Reaction | None:
"""Look up a reaction by name or serialized form, with optional type filter.
Parameters
----------
reaction : str
Verbatim string or serialized form.
- rtype : str or None, optional
+ type : str or None, optional
If given, return ``None`` when the reaction type does not match.
Returns
@@ -906,23 +902,22 @@ def get(self, reaction: str, rtype: str | None = None) -> Reaction | None:
Reaction or None
"""
rea = self[reaction]
- if rtype is None or rea.rtype() == rtype:
+ if type is None or rea.type == type:
return rea
- def with_rtype(self, rtype: str):
+ def with_type(self, type: str):
"""Return all reactions matching the given reaction type.
Parameters
----------
- rtype : str
- One of ``"photo"``, ``"cosmic_ray"``, ``"photo_av"``,
- ``"3_body"``, ``"unknown"``.
+ type : str
+ One of ``"photo"``, ``"cosmic_ray"``, ``"3_body"``, ``"unknown"``.
Returns
-------
Vector[Reaction]
"""
- return Vector([r for r in self if r.rtype() == rtype])
+ return Vector([r for r in self if r.type == type])
def verbatim(self) -> Vector[str]:
"""Return a ``Vector`` of verbatim reaction strings.
@@ -933,14 +928,14 @@ def verbatim(self) -> Vector[str]:
"""
return Vector([r.verbatim for r in self])
- def rtypes(self) -> Vector[str]:
+ def types(self) -> Vector[str]:
"""Return a ``Vector`` of reaction type strings.
Returns
-------
Vector[str]
"""
- return Vector([r.rtype() for r in self])
+ return Vector([r.type for r in self])
def reactants(self) -> Vector[Species]:
"""Return a ``Vector`` of reactant ``Species`` catalogues.
@@ -1024,13 +1019,13 @@ def serialized_exploded(self) -> Vector[str]:
return Vector([r.serialized_exploded for r in self])
def photo_reactions(self) -> Vector[Reaction]:
- """Return all photo-reactions (``rtype == "photo"``).
+ """Return all photo-reactions (``type == "photo"``).
Returns
-------
Vector[Reaction]
"""
- return Vector([r for r in self if r.rtype() == "photo"])
+ return Vector([r for r in self if r.type == "photo"])
def photo_reaction_truths(self) -> Vector[int]:
"""Return a binary ``Vector`` marking photo-reactions with ``1``.
@@ -1039,7 +1034,7 @@ def photo_reaction_truths(self) -> Vector[int]:
-------
Vector[int]
"""
- return Vector([int(reaction.rtype() == "photo") for reaction in self])
+ return Vector([int(reaction.type == "photo") for reaction in self])
def photo_reaction_indices(self) -> Vector[int]:
"""Return the integer indices of photo-reactions within this catalogue.
@@ -1048,6 +1043,37 @@ def photo_reaction_indices(self) -> Vector[int]:
-------
Vector[int]
"""
- return Vector(
- [i for i, reaction in enumerate(self) if reaction.rtype() == "photo"]
- )
+ return Vector([i for i, reaction in enumerate(self) if reaction.type == "photo"])
+
+ def plot_rates(self, **kwargs: Any) -> tuple[plt.Figure, Any] | None:
+ """Plot the rate coefficients of every reaction in the catalogue.
+
+ Thin wrapper over :func:`jaff.plotting.plot_rates` that passes all
+ reactions in this catalogue as one overlay. Accepts the same keyword
+ arguments (``tmin``, ``tmax``, ``shade``, ``save``, ``filename`` ...).
+
+ Reactions whose rate cannot be evaluated numerically (e.g. photo
+ reactions) are skipped with a warning.
+
+ Returns
+ -------
+ tuple[matplotlib.figure.Figure, matplotlib.axes.Axes] or None
+ """
+ from ..plotting import plot_rates
+
+ return plot_rates(list(self._list), **kwargs)
+
+ def plot_xsecs(self, **kwargs: Any) -> tuple[plt.Figure, Any] | None:
+ """Plot the photo cross sections of the catalogue's reactions.
+
+ Thin wrapper over :func:`jaff.plotting.plot_xsecs`. Reactions without
+ cross-section data are skipped. Accepts the same keyword arguments
+ (``processes``, ``energy_unit``, ``shade``, ``show_bands`` ...).
+
+ Returns
+ -------
+ tuple[matplotlib.figure.Figure, matplotlib.axes.Axes] or None
+ """
+ from ..plotting import plot_xsecs
+
+ return plot_xsecs(list(self._list), **kwargs)
diff --git a/src/jaff/core/species.py b/src/jaff/core/species.py
index 480feab5..b9b7dd58 100644
--- a/src/jaff/core/species.py
+++ b/src/jaff/core/species.py
@@ -237,6 +237,33 @@ def get_fidx(self) -> str:
else f"idx_{self.name.replace('+', 'j').replace('-', 'k').strip().lower()}"
)
+ @property
+ def is_special(self) -> bool:
+ """Whether this is a special pseudo-species.
+
+ Special pseudo-species (``_PHOTON``, ``_CR``, ``_GRAIN``, ``_DUMMY``,
+ ...) are radiation/cosmic-ray/grain agents and markers; they carry the
+ reaction's identity but do not participate in the mass-action kinetics
+ or the integrated ODE state. They are identified by a leading
+ underscore — real species never start with ``_`` (underscore-suffixed
+ grain/ice species such as ``H2O_DUST`` have the underscore mid-name).
+
+ Returns
+ -------
+ bool
+ """
+ return self.name.startswith("_")
+
+ @property
+ def is_core(self) -> bool:
+ """Whether this is a core (real) species, i.e. not :attr:`is_special`.
+
+ Returns
+ -------
+ bool
+ """
+ return not self.is_special
+
def serialize(self) -> str:
"""Build and store the canonical serialized form of this species.
@@ -357,7 +384,7 @@ def is_number(s: str) -> bool:
if "_DUST" in latex:
latex = latex.replace("_DUST", "") + "ice"
- latex = latex.replace("GRAIN", "g")
+ latex = latex.replace("_GRAIN", "g")
self.__latex = f"{{\\rm {latex}}}"
@@ -461,6 +488,33 @@ def add(self, specie: Specie) -> None:
self._by_serialized[specie.serialized] = specie
self._list.append(specie)
self.count = len(self._list)
+ # Invalidate the cached core/special sub-catalogues on mutation.
+ self.__dict__.pop("core", None)
+ self.__dict__.pop("special", None)
+
+ @cached_property
+ def special(self) -> "Species":
+ """Sub-catalogue of the special pseudo-species (``is_special``).
+
+ Returns
+ -------
+ Species
+ """
+ return Species([s for s in self._list if s.is_special], check_length=False)
+
+ @cached_property
+ def core(self) -> "Species":
+ """Sub-catalogue of the core (real) species (``is_core``).
+
+ Used wherever only physically integrated species should participate —
+ e.g. the mass-action density product and the ODE assembly — so the
+ special pseudo-species are excluded from the kinetics.
+
+ Returns
+ -------
+ Species
+ """
+ return Species([s for s in self._list if s.is_core], check_length=False)
def from_serialized(self, serialized: str) -> Specie:
"""Return the species matching the given serialized form.
@@ -611,17 +665,24 @@ def e_idx(self) -> int | None:
if "e-" in self:
return self["e-"].index
- def normalized_names(self) -> Vector[str]:
+ def normalized_names(self, pos: str = "p", neg: str = "n") -> Vector[str]:
"""Return species names normalized for use as code identifiers.
- All characters are lower-cased; ``"+"`` is replaced with ``"p"`` and
- ``"-"`` with ``"n"``.
+ All characters are lower-cased; ``"+"`` is replaced with *pos* and
+ ``"-"`` with *neg*.
+
+ Parameters
+ ----------
+ pos : str, optional
+ Replacement for ``"+"``, by default ``"p"``.
+ neg : str, optional
+ Replacement for ``"-"``, by default ``"n"``.
Returns
-------
Vector[str]
"""
- return Vector([s.name.lower().replace("+", "p").replace("-", "n") for s in self])
+ return Vector([s.name.lower().replace("+", pos).replace("-", neg) for s in self])
def neutral(self, attr: str = "") -> Vector[Specie | int]:
"""Return neutral (charge == 0) species or one of their attributes.
diff --git a/src/jaff/data/atom_mass.csv b/src/jaff/data/atom_mass.csv
index aaea9689..2c63c5c6 100644
--- a/src/jaff/data/atom_mass.csv
+++ b/src/jaff/data/atom_mass.csv
@@ -1,19 +1,19 @@
Symbol Name Mass AtomicMass Protons Neutrons Electrons
-dummy Dummy 0e0 None None None None
-CRP CosmicRayParticle 0e0 None None None None
-CR CosmicRay 0e0 None None None None
-Photon Photon 0e0 None None None None
-PHOTON Photon 0e0 None None None None
+_DUMMY Dummy 0e0 None None None None
+_CRP CosmicRayParticle 0e0 None None None None
+_CR CosmicRay 0e0 None None None None
+_CRPHOT CRInducedPhoton 0e0 None None None None
+_PHOTON Photon 0e0 None None None None
_PARA Para 0e0 None None None None
_ORTHO Ortho 0e0 None None None None
_META Meta 0e0 None None None None
-GRAIN Grain 0e0 None None None None
+_GRAIN Grain 0e0 None None None None
_DUST Dust 0e0 None None None None
_BULK Bulk 0e0 None None None None
e- Electron 9.109383e-28 None None None None
+GRAIN Grain 0e0 None None None None
+ Cation 0e0 None None None None
- Anion 0e0 None None None None
-dust Dust 0e0 None None None None
c- Cyclic 0e0 None None None None
l- Linear 0e0 None None None None
t- Terminal 0e0 None None None None
diff --git a/src/jaff/db/jaff.db b/src/jaff/db/jaff.db
index 12da023b..ed074f06 100644
Binary files a/src/jaff/db/jaff.db and b/src/jaff/db/jaff.db differ
diff --git a/src/jaff/drivers/pooch.py b/src/jaff/drivers/pooch.py
index 7d049229..4edcecdf 100644
--- a/src/jaff/drivers/pooch.py
+++ b/src/jaff/drivers/pooch.py
@@ -6,6 +6,7 @@
from rich.filesize import decimal
from rich.progress import TaskID
+from ..config import DATA_DIR
from ..io._logger import jaff_progress
pooch.get_logger().setLevel(logging.WARNING)
@@ -147,7 +148,7 @@ def download_xsecs() -> None:
"""
pooch = Pooch(
"https://www.mso.anu.edu.au/~anishs",
- Path(__file__).parent.parent / "data",
+ DATA_DIR,
)
for file in ["xsecs/leiden.hdf5", "xsecs/norad.hdf5", "xsecs/verner_1996.csv"]:
pooch.fetch_file(file)
@@ -162,7 +163,7 @@ def download_shielding() -> None:
"""
pooch = Pooch(
"https://www.mso.anu.edu.au/~anishs",
- Path(__file__).parent.parent / "data",
+ DATA_DIR,
)
for file in ["shielding/leiden.hdf5"]:
diff --git a/src/jaff/drivers/sqlite.py b/src/jaff/drivers/sqlite.py
index a83ed480..f9dbfc86 100644
--- a/src/jaff/drivers/sqlite.py
+++ b/src/jaff/drivers/sqlite.py
@@ -34,6 +34,7 @@
import pandas as pd
+from ..config import DB_DIR
from .csv import csv_to_df
@@ -763,5 +764,5 @@ def __init__(self):
The path is resolved automatically relative to the JAFF package
directory; no arguments are required.
"""
- jaff_db_path = Path(__file__).parent.parent / "db" / "jaff.db"
+ jaff_db_path = DB_DIR / "jaff.db"
super().__init__(jaff_db_path)
diff --git a/src/jaff/errors/__init__.py b/src/jaff/errors/__init__.py
index 71644c0f..99dbbbc2 100644
--- a/src/jaff/errors/__init__.py
+++ b/src/jaff/errors/__init__.py
@@ -1,7 +1,11 @@
+from ._language import InvalidLanguageError
from ._parser import NotJaffFileError, ParserError, SympyJsonError
+from ._shielding import RegistrationError
__all__ = [
"NotJaffFileError",
"ParserError",
"SympyJsonError",
+ "InvalidLanguageError",
+ "RegistrationError",
]
diff --git a/src/jaff/errors/_language.py b/src/jaff/errors/_language.py
new file mode 100644
index 00000000..d61698bc
--- /dev/null
+++ b/src/jaff/errors/_language.py
@@ -0,0 +1,2 @@
+class InvalidLanguageError(Exception):
+ pass
diff --git a/src/jaff/errors/_shielding.py b/src/jaff/errors/_shielding.py
new file mode 100644
index 00000000..cbbb0eb6
--- /dev/null
+++ b/src/jaff/errors/_shielding.py
@@ -0,0 +1,2 @@
+class RegistrationError(Exception):
+ pass
diff --git a/src/jaff/io/_io.py b/src/jaff/io/_io.py
index b1707020..5b7af206 100644
--- a/src/jaff/io/_io.py
+++ b/src/jaff/io/_io.py
@@ -190,8 +190,8 @@ def jsonable(obj):
],
"reactions": [
{
- "reactants": [int(s.index) for s in r.reactants],
- "products": [int(s.index) for s in r.products],
+ "reactants": [s.name for s in r.reactants],
+ "products": [s.name for s in r.products],
"rate": encode_maybe_sympy(r.rate),
"tmin": r.tmin,
"tmax": r.tmax,
@@ -199,7 +199,7 @@ def jsonable(obj):
"dRad": encode_maybe_sympy(r.dRad),
"custom_rad_rate": r.custom_rad_rate,
"original_string": r.original_string,
- "xsecs": jsonable(r.xsecs_dict),
+ "type": r.type,
}
for r in net.reactions
],
@@ -294,17 +294,24 @@ def from_jaff_file(filename: str | Path, errors=False):
raise ValueError(f"Duplicate species index {idx}")
by_index[idx] = name
- species_by_index = {}
species_list = Species()
for idx in sorted(by_index.keys()):
name = by_index[idx]
- sp_obj = Specie(name, idx)
- species_list.add(sp_obj)
- species_by_index[idx] = sp_obj
+ species_list.add(Specie(name, idx))
net_data["species"] = species_list
+ species_by_name = {sp.name: sp for sp in species_list}
+ special_by_name: dict[str, "Specie"] = {}
+
+ def resolve_specie(name: str) -> "Specie":
+ if name in species_by_name:
+ return species_by_name[name]
+ if name not in special_by_name:
+ special_by_name[name] = Specie(name, -1)
+ return special_by_name[name]
+
rate_symbols_payload = payload.get("rate_symbols") or []
rate_symbol_assumptions = {}
if isinstance(rate_symbols_payload, list):
@@ -379,15 +386,15 @@ def decode_maybe_sympy(node):
for rj in reactions_payload:
if not isinstance(rj, dict):
raise ValueError("Invalid reaction entry in JSON")
- reactants_idx = rj.get("reactants") or []
- products_idx = rj.get("products") or []
- if not isinstance(reactants_idx, list) or not isinstance(products_idx, list):
+ reactants_names = rj.get("reactants") or []
+ products_names = rj.get("products") or []
+ if not isinstance(reactants_names, list) or not isinstance(products_names, list):
raise ValueError("Invalid reactants/products list in JSON")
try:
- reactants = [species_by_index[int(i)] for i in reactants_idx]
- products = [species_by_index[int(i)] for i in products_idx]
+ reactants = [resolve_specie(str(n)) for n in reactants_names]
+ products = [resolve_specie(str(n)) for n in products_names]
except Exception as e:
- raise ValueError(f"Invalid species indices in reaction: {e}") from e
+ raise ValueError(f"Invalid species in reaction: {e}") from e
rate = decode_maybe_sympy(rj.get("rate"))
dE = decode_maybe_sympy(rj.get("dE"))
@@ -396,6 +403,7 @@ def decode_maybe_sympy(node):
tmin = rj.get("tmin")
tmax = rj.get("tmax")
original_string = rj.get("original_string") or ""
+ reaction_type = rj.get("type") or "unknown"
xsecs = rj.get("xsecs")
# Cross-section arrays are JSON-serialized as plain lists; restore them
@@ -420,6 +428,7 @@ def decode_maybe_sympy(node):
"tmin": tmin,
"tmax": tmax,
"original_string": original_string,
+ "reaction_type": reaction_type,
"xsecs_dict": xsecs,
}
)
@@ -792,10 +801,10 @@ def write_data_table(
reactants = []
products = []
for i in react_list:
- if reactions[i].rtype() == "unknown":
+ if reactions[i].type == "unknown":
rtype.append("2_body")
else:
- rtype.append(reactions[i].rtype())
+ rtype.append(reactions[i].type)
reactants_ = {}
for r in reactions[i].reactants:
if r.name in reactants_.keys():
diff --git a/src/jaff/physics/_equations.py b/src/jaff/physics/_equations.py
index 4e43c368..44f3ffa4 100644
--- a/src/jaff/physics/_equations.py
+++ b/src/jaff/physics/_equations.py
@@ -39,7 +39,8 @@ def get_sfluxes(reactions: "Reactions", species: Species) -> list[Expr]:
flux_i = k_i * nden[idx_A] * nden[idx_B]
The number densities are represented as entries of the SymPy
- ``MatrixSymbol`` ``nden`` (shape ``(species.count, 1)``), so the returned
+ ``MatrixSymbol`` ``nden`` (shape ``(species.core.count, 1)`` — only core
+ species enter the integrated state), so the returned
expressions reference ``nden[j]`` symbolically and can be differentiated or
printed by any SymPy backend.
@@ -67,12 +68,11 @@ def get_sfluxes(reactions: "Reactions", species: Species) -> list[Expr]:
are applied in :func:`get_sodes`.
"""
fluxes: list[Expr] = [Float(0.0) for _ in range(reactions.count)]
- nden_matrix = MatrixSymbol("nden", species.count, 1)
+ nden_matrix = MatrixSymbol("nden", species.core.count, 1)
for i, reaction in enumerate(reactions):
flux = reaction.rate
- # Multiply by number density of every reactant (mass-action kinetics)
- for reactant in reaction.reactants:
+ for reactant in reaction.reactants.core:
flux *= nden_matrix[species[str(reactant)].index]
fluxes[i] = flux
@@ -99,8 +99,9 @@ def get_sodes(reactions: "Reactions", species: Species) -> list[Basic]:
Returns
-------
list of sympy.Basic
- List of length ``species.count``. ``sodes[j]`` is the symbolic
- time-derivative of the *j*-th species number density.
+ List of length ``species.core.count`` (special pseudo-species are not
+ integrated). ``sodes[j]`` is the symbolic time-derivative of the
+ *j*-th core species number density.
Notes
-----
@@ -116,11 +117,10 @@ def get_sodes(reactions: "Reactions", species: Species) -> list[Basic]:
and fixed-layout networks produced by certain code-generation backends.
"""
fluxes = get_sfluxes(reactions, species)
- sodes: list[Basic] = [Float(0.0) for _ in range(species.count)]
+ sodes: list[Basic] = [Float(0.0) for _ in range(species.core.count)]
for i, reaction in enumerate(reactions):
- # Subtract flux from every reactant (destruction term)
- for rr in reaction.reactants:
+ for rr in reaction.reactants.core:
# Choose the output-array slot: either the species' runtime index
# (when fidx is a "idx_*" tag) or a literal integer position.
idx = (
@@ -131,7 +131,7 @@ def get_sodes(reactions: "Reactions", species: Species) -> list[Basic]:
sodes[idx] -= fluxes[i]
# Add flux to every product (creation term)
- for pp in reaction.products:
+ for pp in reaction.products.core:
idx = (
pp.index
if isinstance(pp.fidx, str) and pp.fidx.startswith("idx_")
@@ -218,7 +218,7 @@ def get_sradodes(
raise ValueError("Invalid order: Supported orders are 0, 1, 2, 3")
rad_groups = radiation.groups
- nden = MatrixSymbol("nden", species.count, 1)
+ nden = MatrixSymbol("nden", species.core.count, 1)
# Choose the symbolic name for the radiation density variable based on
# whether the field is tracked as energy density (erg/cm³) or photon
@@ -247,7 +247,7 @@ def get_sradodes(
# Accumulate any user-supplied radiation source terms
group_dRad_dt_extra += rrate * props["delta_rad"]
# Multiply by all reactant number densities (mass-action kinetics)
- for reactant in reaction.reactants:
+ for reactant in reaction.reactants.core:
rrate *= nden[Idx(species[str(reactant)].index)]
# Photochemical reactions *remove* radiation, hence the minus sign.
diff --git a/src/jaff/physics/photo_reactions/_photochemistry.py b/src/jaff/physics/photo_reactions/_photochemistry.py
index 2f309f80..96c86b27 100644
--- a/src/jaff/physics/photo_reactions/_photochemistry.py
+++ b/src/jaff/physics/photo_reactions/_photochemistry.py
@@ -14,18 +14,15 @@
from __future__ import annotations
-import json
-from pathlib import Path
from typing import TYPE_CHECKING
from sympy import Basic, Expr, sympify
-from ...common._helper import load_module_from_path
-from ...config import SHIELDING_FUNCTIONS_DIR, SRC_DIR
+from ...config import JAFF_DIR
from ...drivers import HDF5, JaffDb
from ...drivers.pooch import download_shielding, download_xsecs
-from ...errors import ParserError
from ._typing import XsecsProps
+from .shielding import _get_shielding_function
if TYPE_CHECKING:
from ...core import Reaction
@@ -124,8 +121,7 @@ def get_xsec(self, reaction: Reaction) -> XsecsProps | None:
row = rows[0]
loc: str = row["leiden"] if row["leiden"] else row["norad"]
- jaff_dir = Path(__file__).parent.parent.parent.resolve()
- h5group = str(jaff_dir / loc)
+ h5group = str(JAFF_DIR.resolve() / loc)
pr_xsec = HDF5().to_dict(h5group)
xsecs: XsecsProps = {
@@ -148,15 +144,17 @@ def get_xsec(self, reaction: Reaction) -> XsecsProps | None:
def shielding(reaction: Reaction, network: Network) -> Expr:
"""Build the symbolic shielding factor for a photo-reaction.
- The ``photo_reaction_shielding`` table records, per reaction, the
- available shielding function names split into ``global`` (shared across
- reactions, loaded from ``SHIELDING_FUNCTIONS_DIR/.py``) and
- ``local`` (reaction-specific, loaded from
- ``SHIELDING_FUNCTIONS_DIR//.py``). The function named by
- ``reaction.metadata["shielding"]["type"]`` is loaded dynamically and its
- ``get_shielding(reaction, network)`` is called to produce the factor.
-
- The result is cached on ``reaction.metadata["shielding"]["value"]`` so
+ The shielding function named by
+ ``reaction._metadata["shielding"]["type"]`` is resolved from the
+ shielding registry via
+ :func:`~jaff.physics.photo_reactions.shielding._get_shielding_function`.
+ Lookup is keyed by ``(type, reaction.serialized)`` and prefers a
+ reaction-specific (local) function, falling back to a global one
+ registered with ``reaction=None``. The resolved
+ :class:`~jaff.physics.photo_reactions.shielding._base.ShieldingFunction`
+ instance's ``get_shielding(reaction, network)`` produces the factor.
+
+ The result is cached on ``reaction._metadata["shielding"]["value"]`` so
repeated calls (e.g. once per radiation band) reuse it.
Parameters
@@ -176,32 +174,13 @@ def shielding(reaction: Reaction, network: Network) -> Expr:
Raises
------
ParserError
- If the reaction has no shielding entry, or ``type`` is not listed in
- the reaction's ``global`` or ``local`` shielding functions.
+ If no shielding function is registered for ``type`` either locally
+ (for this reaction) or as a global fallback.
"""
- with JaffDb() as jdb:
- table = jdb.table("photo_reaction_shielding")
- rows: list = table.rows(conditions=f"reaction = '{reaction.serialized}'")
+ sprops = reaction._metadata["shielding"]
- if not rows:
- raise ParserError(f"{reaction} doesn't have a shielding function")
-
- row = rows[0]
- sprops = reaction.metadata["shielding"]
-
- global_types = json.loads(row["global"])
- local_types = json.loads(row["local"])
-
- if sprops["type"] in global_types:
- fpath = SHIELDING_FUNCTIONS_DIR / f"{sprops['type']}.py"
- elif sprops["type"] in local_types:
- fpath = SHIELDING_FUNCTIONS_DIR / reaction.serialized / f"{sprops['type']}.py"
- else:
- raise ParserError(f"Invalid shielding type: {sprops['type']}")
-
- module_name = ".".join(fpath.resolve().relative_to(SRC_DIR).with_suffix("").parts)
- smod = load_module_from_path(fpath, module_name)
- shielding_expr = smod.get_shielding(reaction, network)
- reaction.metadata["shielding"]["value"] = shielding_expr
+ shielding_fn = _get_shielding_function(sprops["type"], reaction.serialized)
+ shielding_expr = shielding_fn.get_shielding(reaction, network)
+ reaction._metadata["shielding"]["value"] = shielding_expr
return shielding_expr
diff --git a/src/jaff/physics/photo_reactions/_radiation.py b/src/jaff/physics/photo_reactions/_radiation.py
index 6f9d4ca7..2e5aa712 100644
--- a/src/jaff/physics/photo_reactions/_radiation.py
+++ b/src/jaff/physics/photo_reactions/_radiation.py
@@ -276,7 +276,9 @@ def set_reaction_rate_coefficient(self, reaction: Reaction) -> None:
-------
None
Results are stored in ``reaction.rate``, ``reaction.rad_xsecs``,
- and ``self.groups[i].props[reaction]`` in-place.
+ ``self.groups[i].props[reaction]``, and ``reaction.rad_groups``
+ (back-references to the bands this reaction contributes to)
+ in-place.
Notes
-----
@@ -326,6 +328,9 @@ def set_reaction_rate_coefficient(self, reaction: Reaction) -> None:
"radeden" if self.energy_density else "photden", self.nbands, 1
)
+ # Reset any back-references from a previous run before repopulating.
+ reaction.rad_groups = []
+
for i, lower in enumerate(self.bands[:-1]):
upper = self.bands[i + 1]
@@ -351,9 +356,9 @@ def set_reaction_rate_coefficient(self, reaction: Reaction) -> None:
# Symbolic rate coefficient: k_i = c · den[i] · <σ>_i
# (units: s⁻¹ for photon-density mode, cm³ s⁻¹ for two-body)
k = self.c * den[sp.Idx(i)] * rad_xsec_avg
- if "shielding" in reaction.metadata:
- if "value" in reaction.metadata["shielding"]:
- k *= reaction.metadata["shielding"]["value"]
+ if "shielding" in reaction._metadata:
+ if "value" in reaction._metadata["shielding"]:
+ k *= reaction._metadata["shielding"]["value"]
else:
k *= Photochemistry.shielding(reaction, self.network)
@@ -363,6 +368,7 @@ def set_reaction_rate_coefficient(self, reaction: Reaction) -> None:
"xsec_frac": rad_xsec_avg / xsec_tot, # fraction of total cross section
"delta_rad": delta_rad_band,
}
+ reaction.rad_groups.append(self.groups[i])
# Compute the band-average photon energy once per band (shared
# across all reactions): _i = ∫ E n(E) dE / ∫ n(E) dE
@@ -402,7 +408,8 @@ def set_custom_rate(self, reaction: Reaction) -> None:
Returns
-------
None
- Results are stored in ``self.groups[i].props[reaction]`` in-place.
+ Results are stored in ``self.groups[i].props[reaction]`` and
+ ``reaction.rad_groups`` in-place.
Notes
-----
@@ -422,6 +429,9 @@ def set_custom_rate(self, reaction: Reaction) -> None:
# Guard against zero-denominator case (no radiation coupling).
delta_rad_total_is_zero = delta_rad_total == 0.0
+ # Reset any back-references from a previous run before repopulating.
+ reaction.rad_groups = []
+
for i, lower in enumerate(self.bands[:-1]):
upper = self.bands[i + 1]
# Band-integrated dRad (numerator of the fraction).
@@ -439,6 +449,7 @@ def set_custom_rate(self, reaction: Reaction) -> None:
"xsec_frac": xsec_frac,
"delta_rad": delta_rad_band,
}
+ reaction.rad_groups.append(self.groups[i])
def ordered_index(self, idx: int, order: int) -> tuple[int, int]:
"""
diff --git a/src/jaff/physics/photo_reactions/shielding/H2__H_H/db1996.py b/src/jaff/physics/photo_reactions/shielding/H2__H_H/db1996.py
deleted file mode 100644
index 49b54b1a..00000000
--- a/src/jaff/physics/photo_reactions/shielding/H2__H_H/db1996.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""
-H2 shielding by Draine & Bertoldi 1996
-DOI:https://ui.adsabs.harvard.edu/link_gateway/1996ApJ...468..269D/doi:10.1086/177689
-"""
-
-from typing import Any
-
-from sympy import Expr
-
-from ..... import Network, Reaction
-from .....errors import ParserError
-from ._utils import shielding
-
-
-def get_shielding(reaction: Reaction, network: Network) -> Expr:
- """Return the Draine & Bertoldi (1996) H2 self-shielding factor.
-
- Thin wrapper over :func:`shielding` with ``alpha = 2.0``. Reads the
- optional floors ``shielding.min_ncol`` (cm^-2) and ``shielding.min_vdisp``
- (cm/s) from the reaction metadata.
-
- Parameters
- ----------
- reaction : Reaction
- Reaction to shield; provides the ``shielding`` metadata.
- network : Network
- Owning network (unused; kept for the shielding-function interface).
-
- Returns
- -------
- sympy.Expr
- Dimensionless self-shielding factor.
-
- Raises
- ------
- ParserError
- If ``min_ncol`` or ``min_vdisp`` is set to a non-numeric value.
- """
- sprops: dict[str, Any] = reaction.metadata["shielding"]
- if "min_ncol" in sprops and not isinstance(sprops["min_ncol"], (float, int)):
- raise ParserError(
- f"Minimum column density must be a float or int for: {reaction}"
- )
- if "min_vdisp" in sprops and not isinstance(sprops["min_vdisp"], (float, int)):
- raise ParserError(
- f"Minimum velocity dispersion must be a float or int for: {reaction}"
- )
-
- return shielding(
- alpha=2.0,
- min_ncol=sprops.get("min_ncol", 1e-50),
- min_vdisp=sprops.get("min_vdisp", 1e-50),
- )
diff --git a/src/jaff/physics/photo_reactions/shielding/H2__H_H/hg2015.py b/src/jaff/physics/photo_reactions/shielding/H2__H_H/hg2015.py
deleted file mode 100644
index 1376dc46..00000000
--- a/src/jaff/physics/photo_reactions/shielding/H2__H_H/hg2015.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""
-H2 shielding by Hartwig et.al. 2015
-DOI:https://doi.org/10.1093/mnras/stv1368
-"""
-
-from typing import Any
-
-from sympy import Expr
-
-from ..... import Network, Reaction
-from .....errors import ParserError
-from ._utils import shielding
-
-
-def get_shielding(reaction: Reaction, network: Network) -> Expr:
- """Return the Hartwig et al. (2015) H2 self-shielding factor.
-
- Thin wrapper over :func:`shielding` with ``alpha = 1.1``. Reads the
- optional floors ``shielding.min_ncol`` (cm^-2) and ``shielding.min_vdisp``
- (cm/s) from the reaction metadata.
-
- Parameters
- ----------
- reaction : Reaction
- Reaction to shield; provides the ``shielding`` metadata.
- network : Network
- Owning network (unused; kept for the shielding-function interface).
-
- Returns
- -------
- sympy.Expr
- Dimensionless self-shielding factor.
-
- Raises
- ------
- ParserError
- If ``min_ncol`` or ``min_vdisp`` is set to a non-numeric value.
- """
- sprops: dict[str, Any] = reaction.metadata["shielding"]
- if "min_ncol" in sprops and not isinstance(sprops["min_ncol"], (float, int)):
- raise ParserError(
- f"Minimum column density must be a float or int for: {reaction}"
- )
- if "min_vdisp" in sprops and not isinstance(sprops["min_vdisp"], (float, int)):
- raise ParserError(
- f"Minimum velocity dispersion must be a float or int for: {reaction}"
- )
-
- return shielding(
- alpha=1.1,
- min_ncol=sprops.get("min_ncol", 1e-50),
- min_vdisp=sprops.get("min_vdisp", 1e-50),
- )
diff --git a/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/__init__.py b/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/__init__.py
new file mode 100644
index 00000000..b78e6bfc
--- /dev/null
+++ b/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/__init__.py
@@ -0,0 +1,4 @@
+from .db1996 import DB1996
+from .hg2015 import HG2015
+
+__all__ = ["DB1996", "HG2015"]
diff --git a/src/jaff/physics/photo_reactions/shielding/H2__H_H/_utils/__init__.py b/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/_utils/__init__.py
similarity index 100%
rename from src/jaff/physics/photo_reactions/shielding/H2__H_H/_utils/__init__.py
rename to src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/_utils/__init__.py
diff --git a/src/jaff/physics/photo_reactions/shielding/H2__H_H/_utils/db_shielding_function.py b/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/_utils/db_shielding_function.py
similarity index 100%
rename from src/jaff/physics/photo_reactions/shielding/H2__H_H/_utils/db_shielding_function.py
rename to src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/_utils/db_shielding_function.py
diff --git a/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/db1996.py b/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/db1996.py
new file mode 100644
index 00000000..bf0d4863
--- /dev/null
+++ b/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/db1996.py
@@ -0,0 +1,55 @@
+"""
+H2 shielding by Draine & Bertoldi 1996
+DOI:https://ui.adsabs.harvard.edu/link_gateway/1996ApJ...468..269D/doi:10.1086/177689
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from sympy import Expr
+
+from jaff.errors import ParserError
+
+from .. import ShieldingFunction, _register
+from ._utils import shielding
+
+if TYPE_CHECKING:
+ from jaff.core.network import Network
+ from jaff.core.reaction import Reaction
+
+
+@_register
+class DB1996(ShieldingFunction):
+ """Draine & Bertoldi (1996) H2 self-shielding (``shielding.type = "db1996"``)."""
+
+ name = "db1996"
+ reaction = "H2._PHOTON__H.H"
+
+ def get_shielding(self, reaction: "Reaction", network: "Network") -> Expr:
+ """Return the Draine & Bertoldi (1996) H2 self-shielding factor.
+
+ Thin wrapper over :func:`shielding` with ``alpha = 2.0``. Reads the
+ optional floors ``shielding.min_ncol`` (cm^-2) and
+ ``shielding.min_vdisp`` (cm/s) from the reaction metadata.
+
+ Raises
+ ------
+ ParserError
+ If ``min_ncol`` or ``min_vdisp`` is set to a non-numeric value.
+ """
+ sprops: dict[str, Any] = reaction._metadata["shielding"]
+ if "min_ncol" in sprops and not isinstance(sprops["min_ncol"], (float, int)):
+ raise ParserError(
+ f"Minimum column density must be a float or int for: {reaction}"
+ )
+ if "min_vdisp" in sprops and not isinstance(sprops["min_vdisp"], (float, int)):
+ raise ParserError(
+ f"Minimum velocity dispersion must be a float or int for: {reaction}"
+ )
+
+ return shielding(
+ alpha=2.0,
+ min_ncol=sprops.get("min_ncol", 1e-50),
+ min_vdisp=sprops.get("min_vdisp", 1e-50),
+ )
diff --git a/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/hg2015.py b/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/hg2015.py
new file mode 100644
index 00000000..f2bea800
--- /dev/null
+++ b/src/jaff/physics/photo_reactions/shielding/H2__PHOTON__H_H/hg2015.py
@@ -0,0 +1,56 @@
+"""
+H2 shielding by Hartwig et.al. 2015
+DOI:https://doi.org/10.1093/mnras/stv1368
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from sympy import Expr
+
+from jaff.errors import ParserError
+from jaff.physics.photo_reactions.shielding import _register
+from jaff.physics.photo_reactions.shielding._base import ShieldingFunction
+
+from ._utils import shielding
+
+if TYPE_CHECKING:
+ from jaff.core.network import Network
+ from jaff.core.reaction import Reaction
+
+
+@_register
+class HG2015(ShieldingFunction):
+ """Hartwig et al. (2015) H2 self-shielding (``shielding.type = "hg2015"``)."""
+
+ name = "hg2015"
+ reaction = "H2._PHOTON__H.H"
+
+ def get_shielding(self, reaction: "Reaction", network: "Network") -> Expr:
+ """Return the Hartwig et al. (2015) H2 self-shielding factor.
+
+ Thin wrapper over :func:`shielding` with ``alpha = 1.1``. Reads the
+ optional floors ``shielding.min_ncol`` (cm^-2) and
+ ``shielding.min_vdisp`` (cm/s) from the reaction metadata.
+
+ Raises
+ ------
+ ParserError
+ If ``min_ncol`` or ``min_vdisp`` is set to a non-numeric value.
+ """
+ sprops: dict[str, Any] = reaction._metadata["shielding"]
+ if "min_ncol" in sprops and not isinstance(sprops["min_ncol"], (float, int)):
+ raise ParserError(
+ f"Minimum column density must be a float or int for: {reaction}"
+ )
+ if "min_vdisp" in sprops and not isinstance(sprops["min_vdisp"], (float, int)):
+ raise ParserError(
+ f"Minimum velocity dispersion must be a float or int for: {reaction}"
+ )
+
+ return shielding(
+ alpha=1.1,
+ min_ncol=sprops.get("min_ncol", 1e-50),
+ min_vdisp=sprops.get("min_vdisp", 1e-50),
+ )
diff --git a/src/jaff/physics/photo_reactions/shielding/__init__.py b/src/jaff/physics/photo_reactions/shielding/__init__.py
new file mode 100644
index 00000000..fb745f07
--- /dev/null
+++ b/src/jaff/physics/photo_reactions/shielding/__init__.py
@@ -0,0 +1,45 @@
+from jaff.common._helper import import_subpackages
+from jaff.errors import ParserError, RegistrationError
+
+from ._base import ShieldingFunction
+
+_REGISTER: dict[tuple[str, str | None], ShieldingFunction] = {}
+_DISCOVERED: bool = False
+
+
+def _register(cls: type[ShieldingFunction]) -> type[ShieldingFunction]:
+ key = (cls.name, cls.reaction)
+ if key in _REGISTER:
+ raise RegistrationError(
+ f"Key already exists for shielding function: {cls.name}, {cls.reaction}"
+ )
+
+ _REGISTER[key] = cls()
+ return cls
+
+
+def _discover_shielding() -> None:
+ global _DISCOVERED
+ if not _DISCOVERED:
+ import_subpackages(__name__)
+ _DISCOVERED = True
+
+
+def _get_shielding_function(name: str, reaction: str | None):
+ _discover_shielding()
+ name = name.lower()
+ key = (name, reaction)
+
+ if key not in _REGISTER:
+ # Fall back to a global function registered with reaction=None.
+ key = (name, None)
+
+ if key not in _REGISTER:
+ raise ParserError(
+ f"Invalid shielding function type {name} or reaction {reaction} is not supported"
+ )
+
+ return _REGISTER[key]
+
+
+__all__ = [_register, ShieldingFunction]
diff --git a/src/jaff/physics/photo_reactions/shielding/_base.py b/src/jaff/physics/photo_reactions/shielding/_base.py
new file mode 100644
index 00000000..41ea639b
--- /dev/null
+++ b/src/jaff/physics/photo_reactions/shielding/_base.py
@@ -0,0 +1,42 @@
+"""Base interface for a line-shielding function plugin."""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING
+
+from sympy import Expr
+
+if TYPE_CHECKING:
+ from ....core.network import Network
+ from ....core.reaction import Reaction
+
+
+class ShieldingFunction(ABC):
+ """A single photo-reaction line-shielding model.
+
+ Subclasses live in their own module under ``shielding`` and register
+ themselves via the :func:`~.register` decorator, keyed by their ``name``
+ (the ``shielding.type`` value used in the network config). The lookup is a
+ dict, so resolving a type to its function is O(1) and independent of import
+ order or any directory layout.
+
+ Class attributes
+ ----------------
+ name : str
+ Shielding-type identifier, matched case-insensitively against the
+ ``shielding.type`` config value.
+ reaction : str | None
+ Serialized reaction this function is bound to (a *local* model), or
+ ``None`` for a *global* model that applies to any reaction. A function
+ is resolved by the pair ``(reaction, name)`` with a fallback to
+ ``(None, name)``, so a local model only matches its own reaction.
+ """
+
+ name: str
+ reaction: str | None = None
+
+ @abstractmethod
+ def get_shielding(self, reaction: "Reaction", network: "Network") -> Expr:
+ """Return the dimensionless shielding factor for *reaction*."""
+ ...
diff --git a/src/jaff/physics/photo_reactions/shielding/global_/__init__.py b/src/jaff/physics/photo_reactions/shielding/global_/__init__.py
new file mode 100644
index 00000000..530dced8
--- /dev/null
+++ b/src/jaff/physics/photo_reactions/shielding/global_/__init__.py
@@ -0,0 +1,3 @@
+from .leiden import Leiden
+
+__all__ = ["Leiden"]
diff --git a/src/jaff/physics/photo_reactions/shielding/global_/leiden.py b/src/jaff/physics/photo_reactions/shielding/global_/leiden.py
new file mode 100644
index 00000000..9cd0da49
--- /dev/null
+++ b/src/jaff/physics/photo_reactions/shielding/global_/leiden.py
@@ -0,0 +1,112 @@
+"""Leiden tabulated line-shielding (global shielding function).
+
+Builds the shielding factor for a photo-reaction from the collapsed Leiden
+shielding tables (``data/shielding/leiden.hdf5``, one group per reaction). For
+each requested shielding species the relevant column-density grid (``N``) and
+shielding-factor column are extracted into a per-reaction
+``shielding_.hdf5`` next to the generated code, and the total factor
+is the product of one interpolation call per shielding species.
+
+A reaction selects this function via ``shielding.type = "leiden"`` and must
+declare ``shielding.shielded_by`` (a subset of :data:`LEIDEN_SPECIES_MAP`); the
+optional ``shielding.radiation`` field picks the radiation-field subgroup
+(default ``"ISRF"``).
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from sympy import Expr, Float, parse_expr
+
+from jaff.config import SHIELDING_DATA_DIR
+from jaff.drivers import HDF5
+from jaff.errors import ParserError
+
+from .. import ShieldingFunction, _register
+
+if TYPE_CHECKING:
+ from jaff.core.network import Network
+ from jaff.core.reaction import Reaction
+
+LEIDEN_SPECIES_MAP: dict[str, str] = {
+ "H2": "H2",
+ "H": "H",
+ "C": "C",
+ "N2": "N2",
+ "CO": "CO",
+ "self": "",
+}
+
+
+@_register
+class Leiden(ShieldingFunction):
+ """Leiden tabulated line-shielding (``shielding.type = "leiden"``)."""
+
+ name = "leiden"
+ reaction = None # global: applies to any reaction
+
+ def get_shielding(self, reaction: "Reaction", network: "Network") -> Expr:
+ """Return the Leiden tabulated shielding factor for *reaction*.
+
+ Extracts the column-density grid and one shielding-factor column per
+ species in ``reaction._metadata["shielding"]["shielded_by"]`` from
+ ``data/shielding/leiden.hdf5``, writes them to a per-reaction
+ ``shielding_.hdf5`` in the generator output directory, and
+ returns the product of one
+ ``interp__shielding_(ncol_)`` interpolation
+ call per shielding species.
+
+ Recognised ``shielded_by`` entries are the keys of
+ :data:`LEIDEN_SPECIES_MAP`; ``"self"`` resolves to the reaction's
+ reactant (its own column density). The optional ``shielding.radiation``
+ field selects the radiation-field subgroup and defaults to ``"ISRF"``.
+
+ Raises
+ ------
+ ParserError
+ If ``shielded_by`` is missing, or names a species not in
+ :data:`LEIDEN_SPECIES_MAP`.
+ """
+ sprops = reaction._metadata["shielding"]
+ if "shielded_by" not in sprops:
+ raise ParserError(
+ "'shielded_by' must be specified for Leiden shielding tables"
+ )
+
+ if "radiation" not in sprops:
+ sprops["radiation"] = "ISRF"
+
+ # "self" shields by the reaction's own reactant column density.
+ species_map = {**LEIDEN_SPECIES_MAP, "self": f"{reaction.reactants[0]}"}
+ if any(sp not in species_map for sp in sprops["shielded_by"]):
+ raise ParserError(
+ f"Invalid shielding specie detected for reaction: {reaction}"
+ )
+
+ sprops["radiation"] = sprops["radiation"].lower()
+ # The N grid is shared at the reaction-group level; the shielding-factor
+ # columns live under the radiation-field subgroup.
+ h5group_core = f"{SHIELDING_DATA_DIR}/leiden.hdf5::{reaction.serialized}"
+ h5group_rad = f"{h5group_core}/{sprops['radiation']}"
+ h5obj = HDF5()
+
+ ncol = h5obj.to_dict(h5group_core, include="N")
+ shielding_table = h5obj.to_dict(h5group_rad, include=sprops["shielded_by"])
+ h5dict = {**ncol, **shielding_table}
+
+ # Emit a per-reaction shielding table the generated code interpolates over.
+ h5obj.from_dict(
+ f"{reaction._metadata['jaffgen']['jaffgen_object'].jaffgen_config['output_dir']}/shielding_{reaction.serialized}.hdf5",
+ h5dict,
+ mode="w",
+ )
+
+ # Total factor = product of one interpolation call per shielding species.
+ shielding: Expr = Float(1.0)
+ for specie in sprops["shielded_by"]:
+ shielding *= parse_expr(
+ f"interp_{reaction.index}_shielding_{specie}(ncol_{species_map[specie]})"
+ )
+
+ return shielding
diff --git a/src/jaff/physics/photo_reactions/shielding/leiden.py b/src/jaff/physics/photo_reactions/shielding/leiden.py
deleted file mode 100644
index 7e3108d9..00000000
--- a/src/jaff/physics/photo_reactions/shielding/leiden.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""Leiden tabulated line-shielding (global shielding function).
-
-Builds the shielding factor for a photo-reaction from the collapsed Leiden
-shielding tables (``data/shielding/leiden.hdf5``, one group per reaction). For
-each requested shielding species the relevant column-density grid (``N``) and
-shielding-factor column are extracted into a per-reaction
-``shielding_.hdf5`` next to the generated code, and the total factor
-is the product of one interpolation call per shielding species.
-
-A reaction selects this function via ``shielding.type = "leiden"`` and must
-declare ``shielding.shielded_by`` (a subset of :data:`LEIDEN_SPECIES_MAP`); the
-optional ``shielding.radiation`` field picks the radiation-field subgroup
-(default ``"ISRF"``).
-"""
-
-from typing import TYPE_CHECKING
-
-from astropy.units.cds import K
-from sympy import Expr, Float, parse_expr
-
-from ....config import SHIELDING_DATA_DIR
-from ....drivers import HDF5
-from ....errors import ParserError
-
-if TYPE_CHECKING:
- from .... import Reaction
- from ....core.network import Network
-
-LEIDEN_SPECIES_MAP: dict[str, str] = {
- "H2": "H2",
- "H": "H",
- "C": "C",
- "N2": "N2",
- "CO": "CO",
- "self": "",
-}
-
-
-def get_shielding(reaction: Reaction, network: Network) -> Expr:
- """Return the Leiden tabulated shielding factor for *reaction*.
-
- Extracts the column-density grid and one shielding-factor column per species
- in ``reaction.metadata["shielding"]["shielded_by"]`` from
- ``data/shielding/leiden.hdf5``, writes them to a per-reaction
- ``shielding_.hdf5`` in the generator output directory, and returns
- the product of one ``interp__shielding_(ncol_)``
- interpolation call per shielding species.
-
- Recognised ``shielded_by`` entries are the keys of
- :data:`LEIDEN_SPECIES_MAP`; ``"self"`` resolves to the reaction's reactant
- (its own column density). The optional ``shielding.radiation`` field selects
- the radiation-field subgroup and defaults to ``"ISRF"``.
-
- Parameters
- ----------
- reaction : Reaction
- Reaction to shield; provides the serialised key, reactant and
- ``shielding`` metadata.
- network : Network
- Owning network (unused here; kept for the shielding-function interface).
-
- Returns
- -------
- sympy.Expr
- Product of the per-species interpolation calls (dimensionless).
-
- Raises
- ------
- ParserError
- If ``shielded_by`` is missing, or names a species not in
- :data:`LEIDEN_SPECIES_MAP`.
- """
- sprops = reaction.metadata["shielding"]
- if "shielded_by" not in sprops:
- raise ParserError("'shielded_by' must be specified for Leiden shielding tables")
-
- if "radiation" not in sprops:
- sprops["radiation"] = "ISRF"
-
- # "self" shields by the reaction's own reactant column density.
- species_map = {**LEIDEN_SPECIES_MAP, "self": f"{reaction.reactants[0]}"}
- if any(sp not in species_map for sp in sprops["shielded_by"]):
- raise ParserError(f"Invalid shielding specie detected for reaction: {reaction}")
-
- sprops["radiation"] = sprops["radiation"].lower()
- # The N grid is shared at the reaction-group level; the shielding-factor
- # columns live under the radiation-field subgroup.
- h5group_core = f"{SHIELDING_DATA_DIR}/leiden.hdf5::{reaction.serialized}"
- h5group_rad = f"{h5group_core}/{sprops['radiation']}"
- h5obj = HDF5()
-
- ncol = h5obj.to_dict(h5group_core, include="N")
- shielding_table = h5obj.to_dict(h5group_rad, include=sprops["shielded_by"])
- h5dict = {**ncol, **shielding_table}
-
- # Emit a per-reaction shielding table the generated code interpolates over.
- h5obj.from_dict(
- f"{reaction.metadata['jaffgen']['jaffgen_object'].jaffgen_config['output_dir']}/shielding_{reaction.serialized}.hdf5",
- h5dict,
- mode="w",
- )
-
- # Total factor = product of one interpolation call per shielding species.
- shielding: Expr = Float(1.0)
- for specie in sprops["shielded_by"]:
- shielding *= parse_expr(
- f"interp_{reaction.index}_shielding_{specie}(ncol_{species_map[specie]})"
- )
-
- return shielding
diff --git a/src/jaff/plotting/__init__.py b/src/jaff/plotting/__init__.py
index 86362dfc..89f32c71 100644
--- a/src/jaff/plotting/__init__.py
+++ b/src/jaff/plotting/__init__.py
@@ -1,3 +1,13 @@
+from ._api import plot_rates, plot_xsecs
+from ._theme import DEEP_PALETTE, LOGO_PALETTE, MUTED_PALETTE, apply_global_theme
from .plotter import Plotter
-__all__ = [Plotter]
+__all__ = [
+ "Plotter",
+ "plot_rates",
+ "plot_xsecs",
+ "apply_global_theme",
+ "MUTED_PALETTE",
+ "DEEP_PALETTE",
+ "LOGO_PALETTE",
+]
diff --git a/src/jaff/plotting/_api.py b/src/jaff/plotting/_api.py
new file mode 100644
index 00000000..0bc35708
--- /dev/null
+++ b/src/jaff/plotting/_api.py
@@ -0,0 +1,377 @@
+"""High-level plotting functions for rates and cross sections.
+
+These are the preferred entry points for plotting. They accept a single item
+or a list, build a tidy long frame, and delegate rendering to
+:meth:`jaff.plotting.plotter.Plotter.render_series`.
+
+The functions are *domain-agnostic* by duck typing: a "reaction" is anything
+exposing the attributes used here (``rate`` / ``tmin`` / ``tmax`` /
+``get_latex`` for rates; ``xsecs_dict`` / ``band_xsecs`` / ``get_latex`` for
+cross sections). Nothing in this module imports :mod:`jaff.core`, so plotting
+stays a leaf dependency and can also plot bare SymPy expressions and arrays.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+from sympy import Basic, lambdify
+
+from ..io import JaffLogger
+from . import _frames, _units
+from .plotter import Plotter
+
+# Valid photo cross-section process keys.
+_XSEC_PROCESSES: tuple[str, ...] = ("photo_absorption", "photodecay")
+
+# Default temperature bounds for rate coefficients when none are supplied.
+_DEFAULT_TMIN: float = 2.73
+_DEFAULT_TMAX: float = 1e6
+
+
+def _as_list(obj: Any) -> list[Any]:
+ """Wrap a single item in a list; pass a list/tuple through as a list."""
+ if isinstance(obj, (list, tuple)):
+ return list(obj)
+ return [obj]
+
+
+def _rate_series(
+ item: Any,
+ tmin: float | None,
+ tmax: float | None,
+ var: str,
+ npoints: int,
+ label: str | None,
+ logger: Any,
+) -> tuple[np.ndarray, np.ndarray, str] | None:
+ """Coerce one rate input to an ``(x, y, label)`` triple, or ``None``.
+
+ Accepts (duck-typed, dispatched per item):
+
+ - a reaction-like object (has ``rate``): evaluated over its temperature
+ range on a log grid; label defaults to its LaTeX equation;
+ - a SymPy expression: evaluated over ``[tmin, tmax]`` (required);
+ - an ``(x, y)`` pair of arrays: used directly.
+ """
+ # (x, y) array pair -- used verbatim.
+ if isinstance(item, tuple) and len(item) == 2:
+ x, y = item
+ return np.asarray(x, dtype=float), np.asarray(y, dtype=float), label or ""
+
+ # Reaction-like: has a symbolic `rate` (and usually tmin/tmax/get_latex).
+ if hasattr(item, "rate"):
+ t0 = (
+ tmin
+ if tmin is not None
+ else _coalesce(getattr(item, "tmin", None), _DEFAULT_TMIN)
+ )
+ t1 = (
+ tmax
+ if tmax is not None
+ else _coalesce(getattr(item, "tmax", None), _DEFAULT_TMAX)
+ )
+ expr = item.rate
+ lab = label if label is not None else _latex_or_str(item)
+ return _eval_expr(expr, var, t0, t1, npoints, lab, logger)
+
+ # Bare SymPy expression.
+ if isinstance(item, Basic):
+ if tmin is None or tmax is None:
+ raise ValueError(
+ "tmin and tmax are required when plotting bare SymPy expressions."
+ )
+ syms = list(item.free_symbols)
+ sym = syms[0].name if len(syms) == 1 else var
+ lab = label if label is not None else str(item)
+ return _eval_expr(item, sym, tmin, tmax, npoints, lab, logger)
+
+ raise TypeError(
+ f"Cannot plot rate input of type {type(item).__name__!r}; expected a "
+ "reaction-like object, a SymPy expression, or an (x, y) array pair."
+ )
+
+
+def _eval_expr(
+ expr: Basic,
+ var: str,
+ t0: float,
+ t1: float,
+ npoints: int,
+ label: str,
+ logger: Any,
+) -> tuple[np.ndarray, np.ndarray, str] | None:
+ """Evaluate *expr* over a log-spaced grid in ``[t0, t1]``.
+
+ Returns ``None`` (with a warning) if the expression cannot be evaluated
+ numerically -- e.g. a photo-reaction rate that still carries the symbolic
+ radiation-density variable.
+ """
+ grid = np.logspace(np.log10(t0), np.log10(t1), npoints)
+ try:
+ f = lambdify(var, expr, "numpy")
+ y = np.array([float(f(t)) for t in grid])
+ except (NameError, TypeError, ValueError) as exc:
+ logger.warning(f"Cannot evaluate rate {label!r}: {exc}. Skipping.")
+ return None
+ return grid, y, label
+
+
+def _coalesce(value: Any, default: float) -> float:
+ """Return *value* as a float, or *default* when *value* is ``None``."""
+ return default if value is None else float(value)
+
+
+def _latex_or_str(item: Any) -> str:
+ """LaTeX label for a reaction-like object, falling back to ``str``."""
+ return item.get_latex() if hasattr(item, "get_latex") else str(item)
+
+
+def plot_rates(
+ rates: Any,
+ *,
+ tmin: float | None = None,
+ tmax: float | None = None,
+ var: str = "tgas",
+ npoints: int = 100,
+ labels: list[str] | None = None,
+ palette: list[str] | None = None,
+ xlabel: str = "Temperature (K)",
+ ylabel: str = r"Rate coefficient $k$",
+ xscale: str = "log",
+ yscale: str = "log",
+ shade: bool | float = False,
+ title: str = "",
+ fig: Any = None,
+ ax: Any = None,
+ grid: bool = True,
+ show: bool = True,
+ save: bool = False,
+ filename: str = "rates.png",
+) -> tuple[Any, Any] | None:
+ """Plot one or more reaction rate coefficients on shared axes.
+
+ Parameters
+ ----------
+ rates
+ A single item or a list of: reaction-like objects (exposing ``rate``),
+ SymPy expressions, or ``(x, y)`` array pairs. All are drawn on the
+ same axes with a legend.
+ tmin, tmax : float or None, optional
+ Temperature range (K). For reactions, falls back to each reaction's
+ own bounds, then to ``2.73`` / ``1e6``. Required for bare SymPy
+ expressions.
+ var : str, optional
+ Symbol name to substitute when evaluating reaction rates, by default
+ ``"tgas"``.
+ npoints : int, optional
+ Number of log-spaced sample points, by default ``100``.
+ labels : list[str] or None, optional
+ Legend labels aligned to *rates*. Defaults to each reaction's LaTeX
+ equation (or ``str``).
+ palette : list[str] or None, optional
+ Colour cycle override.
+ shade : bool or float, optional
+ Shade under each curve.
+ title, xlabel, ylabel, xscale, yscale, fig, ax, grid, show, save, filename
+ Standard rendering controls.
+
+ Returns
+ -------
+ tuple[matplotlib.figure.Figure, matplotlib.axes.Axes] or None
+ ``None`` when no input could be evaluated.
+
+ Notes
+ -----
+ Photo-reaction rates carry a symbolic radiation-density variable and cannot
+ be evaluated as a function of temperature; such inputs are skipped with a
+ warning.
+ """
+ logger = JaffLogger().get_logger()
+ items = _as_list(rates)
+ label_list = labels if labels is not None else [None] * len(items)
+ if len(label_list) != len(items):
+ raise ValueError("labels must have the same length as rates.")
+
+ series = []
+ for item, label in zip(items, label_list):
+ result = _rate_series(item, tmin, tmax, var, npoints, label, logger)
+ if result is not None:
+ series.append(result)
+
+ if not series:
+ logger.info("plot_rates: nothing to plot.")
+ return None
+
+ df = _frames.long_frame(series, "x", "y", "series")
+ return Plotter(palette=palette).render_series(
+ df,
+ x_col="x",
+ y_col="y",
+ group_col="series",
+ xlabel=xlabel,
+ ylabel=ylabel,
+ log_x=(xscale == "log"),
+ log_y=(yscale == "log"),
+ dynamic_x=False,
+ trim=False,
+ shade=shade,
+ title=title,
+ fig=fig,
+ ax=ax,
+ grid=grid,
+ show=show,
+ save=save,
+ filename=filename,
+ )
+
+
+def _normalize_processes(processes: str | list[str] | None) -> list[str]:
+ """Normalise a process selection to a validated list of keys."""
+ if processes is None or processes == "all":
+ return list(_XSEC_PROCESSES)
+ procs = [processes] if isinstance(processes, str) else list(processes)
+ invalid = [p for p in procs if p not in _XSEC_PROCESSES]
+ if invalid:
+ raise KeyError(
+ f"Invalid cross-section(s) {invalid}. Supported: {', '.join(_XSEC_PROCESSES)}"
+ )
+ return procs
+
+
+def plot_xsecs(
+ reactions: Any,
+ *,
+ processes: str | list[str] | None = "all",
+ layout: str = "overlay",
+ energy_unit: str = "eV",
+ xsec_unit: str = "Mb",
+ energy_log: bool = True,
+ xsecs_log: bool = True,
+ trim: bool = True,
+ shade: bool | float = False,
+ show_bands: bool = False,
+ palette: list[str] | None = None,
+ title: str | None = None,
+ fig: Any = None,
+ ax: Any = None,
+ grid: bool = True,
+ show: bool = True,
+ save: bool = False,
+ filename: str = "",
+) -> tuple[Any, Any] | None:
+ """Plot photo cross sections for one or more reactions on shared axes.
+
+ Parameters
+ ----------
+ reactions
+ A single reaction-like object (exposing ``xsecs_dict`` and
+ ``band_xsecs``) or a list of them. Their cross sections are overlaid.
+ processes : str | list[str] | None, optional
+ Which processes to draw: ``"all"`` (default), a single key, or a list.
+ Valid keys: ``"photo_absorption"``, ``"photodecay"``.
+ layout : str, optional
+ ``"overlay"`` (default) or ``"subplots"`` (one panel per curve).
+ energy_unit, xsec_unit : str, optional
+ Axis units; defaults ``"eV"`` and ``"Mb"``.
+ energy_log, xsecs_log : bool, optional
+ Log-scale the energy / cross-section axis (default ``True``).
+ trim : bool, optional
+ Tighten the energy axis to the positive data span (default ``True``).
+ shade : bool or float, optional
+ Shade under each curve.
+ show_bands : bool, optional
+ Overlay band-averaged cross sections as bars for every reaction that
+ has them.
+ palette : list[str] or None, optional
+ Colour cycle override.
+ title, fig, ax, grid, show, save, filename
+ Standard rendering controls.
+
+ Returns
+ -------
+ tuple[matplotlib.figure.Figure, matplotlib.axes.Axes] or None
+ ``None`` when no reaction has cross-section data to plot.
+
+ Notes
+ -----
+ With a single reaction the legend uses the process names; with several it
+ prefixes each with the reaction so curves stay distinguishable.
+ """
+ logger = JaffLogger().get_logger()
+ reaction_list = _as_list(reactions)
+ procs = _normalize_processes(processes)
+ multi_reaction = len(reaction_list) > 1
+
+ series: list[tuple[np.ndarray, np.ndarray, str]] = []
+ band_frames = []
+ for r in reaction_list:
+ xsecs = getattr(r, "xsecs_dict", None)
+ if xsecs is None:
+ logger.info(f"No cross sections available for: {r}")
+ continue
+ energy = xsecs.get("photon_energy")
+ if energy is None:
+ continue
+ available = [p for p in procs if xsecs.get(p) is not None]
+ if not available:
+ logger.info(f"No data for requested cross-section(s) {procs} in: {r}")
+ continue
+
+ x = np.asarray(_units.convert_energy(np.asarray(energy), "eV", energy_unit))
+ for p in available:
+ y = np.asarray(_units.convert_xsec(np.asarray(xsecs[p]), "cm2", xsec_unit))
+ proc_label = _units.PROCESS_LABELS.get(p, p)
+ label = f"{r} — {proc_label}" if multi_reaction else proc_label
+ series.append((x, y, label))
+
+ if show_bands and hasattr(r, "band_xsecs"):
+ band_frames.append(_frames.band_frame(r.band_xsecs, energy_unit, xsec_unit))
+
+ if not series:
+ logger.info("plot_xsecs: nothing to plot.")
+ return None
+
+ df = _frames.long_frame(series, "energy", "xsec", "process")
+
+ band_df = None
+ if band_frames:
+ import pandas as pd
+
+ band_df = pd.concat(band_frames, ignore_index=True)
+ if band_df.empty:
+ band_df = None
+
+ if not filename:
+ if not multi_reaction:
+ stem = "cross_sections"
+ filename = f"{reaction_list[0]}_{stem}.png"
+ else:
+ filename = "cross_sections.png"
+
+ if title is None:
+ title = _latex_or_str(reaction_list[0]) if not multi_reaction else ""
+
+ return Plotter(palette=palette).render_series(
+ df,
+ x_col="energy",
+ y_col="xsec",
+ group_col="process",
+ xlabel=_units.energy_label(energy_unit),
+ ylabel=_units.xsec_label(xsec_unit),
+ log_x=energy_log,
+ log_y=xsecs_log,
+ dynamic_x=True,
+ trim=trim,
+ shade=shade,
+ bands_df=band_df,
+ layout=layout,
+ title=title,
+ fig=fig,
+ ax=ax,
+ grid=grid,
+ show=show,
+ save=save,
+ filename=filename,
+ )
diff --git a/src/jaff/plotting/_frames.py b/src/jaff/plotting/_frames.py
new file mode 100644
index 00000000..c9753aec
--- /dev/null
+++ b/src/jaff/plotting/_frames.py
@@ -0,0 +1,129 @@
+"""Tidy-DataFrame builders for the seaborn objects plotting layer.
+
+The seaborn objects interface (``seaborn.objects``) consumes long/tidy
+DataFrames. These helpers assemble them and convert to the caller's requested
+units.
+
+.. important::
+ Every frame returned here has a unique ``RangeIndex`` (``reset_index`` /
+ ``ignore_index``). The objects interface fails on duplicate index labels
+ (``ValueError: Must have equal len keys and value``) when unscaling
+ coordinates, which happens if per-process frames are concatenated without
+ resetting the index.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from . import _units
+
+
+def long_frame(
+ series: list[tuple[np.ndarray, np.ndarray, str]],
+ x_col: str,
+ y_col: str,
+ group_col: str,
+) -> pd.DataFrame:
+ """Assemble a tidy long frame from labelled ``(x, y, label)`` triples.
+
+ Parameters
+ ----------
+ series : list[tuple[numpy.ndarray, numpy.ndarray, str]]
+ One ``(x, y, label)`` per curve. The ``x``/``y`` arrays may differ in
+ length and range across curves.
+ x_col, y_col, group_col : str
+ Column names for the x, y, and grouping/label columns.
+
+ Returns
+ -------
+ pandas.DataFrame
+ Long frame with a unique index (duplicate indices break seaborn
+ objects).
+ """
+ frames = [
+ pd.DataFrame(
+ {
+ x_col: np.asarray(x, dtype=float),
+ y_col: np.asarray(y, dtype=float),
+ group_col: label,
+ }
+ )
+ for x, y, label in series
+ ]
+ if not frames:
+ return pd.DataFrame({x_col: [], y_col: [], group_col: []})
+ # ignore_index=True is required: duplicate indices break seaborn objects.
+ return pd.concat(frames, ignore_index=True)
+
+
+def line_frame(
+ x: np.ndarray, y: np.ndarray, x_col: str = "x", y_col: str = "y"
+) -> pd.DataFrame:
+ """Build a two-column frame for a single line.
+
+ Parameters
+ ----------
+ x, y : numpy.ndarray
+ Coordinates.
+ x_col, y_col : str, optional
+ Column names.
+
+ Returns
+ -------
+ pandas.DataFrame
+ Frame ``{x_col, y_col}`` with a fresh ``RangeIndex``.
+ """
+ return pd.DataFrame({x_col: np.asarray(x), y_col: np.asarray(y)})
+
+
+def band_frame(
+ band_xsecs: pd.DataFrame,
+ energy_unit: str,
+ xsec_unit: str,
+) -> pd.DataFrame:
+ """Convert a reaction's band-averaged cross sections to plotting units.
+
+ Parameters
+ ----------
+ band_xsecs : pandas.DataFrame
+ As returned by :attr:`jaff.core.reaction.Reaction.band_xsecs`
+ (``lower``/``upper``/``eavg`` in eV, ``xsec`` in cm²).
+ energy_unit : str
+ Target unit for the band-edge / mid-point columns.
+ xsec_unit : str
+ Target unit for the cross-section column.
+
+ Returns
+ -------
+ pandas.DataFrame
+ A copy with converted units and added ``mid`` (geometric mid-point,
+ for log axes) and ``width`` columns, restricted to rows with a finite
+ cross section. Empty if no band has a finite cross section.
+
+ Notes
+ -----
+ Bands with a non-finite edge (an open top band with ``upper = inf``) keep
+ that edge; the plotter is responsible for clipping the drawn width to the
+ axis range.
+ """
+ df = band_xsecs.copy()
+ # Drop bands without a tabulated cross section (custom-rate reactions).
+ df = df[np.isfinite(df["xsec"])].reset_index(drop=True)
+ if df.empty:
+ return df
+
+ df["lower"] = _units.convert_energy(df["lower"].to_numpy(), "eV", energy_unit)
+ df["upper"] = _units.convert_energy(df["upper"].to_numpy(), "eV", energy_unit)
+ df["eavg"] = _units.convert_energy(df["eavg"].to_numpy(), "eV", energy_unit)
+ df["xsec"] = _units.convert_xsec(df["xsec"].to_numpy(), "cm2", xsec_unit)
+
+ # Geometric mid-point works on both linear and log energy axes; falls back
+ # to the arithmetic mean if an edge is non-positive.
+ lo, hi = df["lower"].to_numpy(), df["upper"].to_numpy()
+ with np.errstate(invalid="ignore"):
+ geo = np.sqrt(lo * hi)
+ df["mid"] = np.where((lo > 0) & np.isfinite(hi), geo, (lo + hi) / 2.0)
+ df["width"] = hi - lo
+ return df
diff --git a/src/jaff/plotting/_theme.py b/src/jaff/plotting/_theme.py
new file mode 100644
index 00000000..81167eba
--- /dev/null
+++ b/src/jaff/plotting/_theme.py
@@ -0,0 +1,166 @@
+"""House plotting theme for JAFF.
+
+The theme is built from seaborn's own style machinery
+(:func:`seaborn.axes_style` + :func:`seaborn.plotting_context`) so figures get
+the genuine seaborn look -- soft grid, scaled typography, muted spines --
+rather than the boxy matplotlib default. The default style is ``"darkgrid"``
+(see :data:`DEFAULT_STYLE`) with the JAFF brand palette; a thin layer of JAFF
+overrides (palette, figure size, save DPI) sits on top.
+
+By default the theme is applied *scoped* (only while a JAFF plot is being
+drawn, via :func:`theme_context`), so importing or instantiating the plotter
+never mutates global matplotlib state. Call :func:`apply_global_theme` to opt
+into a sticky, session-wide theme instead.
+"""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import Any, Iterator
+
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import seaborn as sns
+import seaborn.objects as so
+from cycler import cycler
+
+DEFAULT_STYLE: str = "darkgrid"
+DEFAULT_CONTEXT: str = "notebook"
+
+#: seaborn "muted" palette -- the default JAFF colour cycle (soft, polished).
+MUTED_PALETTE: list[str] = [
+ "#4878d0",
+ "#ee854a",
+ "#6acc64",
+ "#d65f5f",
+ "#956cb4",
+ "#8c613c",
+ "#dc7ec0",
+ "#797979",
+ "#d5bb67",
+ "#82c6e2",
+]
+
+#: seaborn "deep" palette -- a more saturated alternative colour cycle.
+DEEP_PALETTE: list[str] = [
+ "#4C72B0",
+ "#DD8452",
+ "#55A868",
+ "#C44E52",
+ "#8172B3",
+ "#937860",
+ "#DA8BC3",
+ "#8C8C8C",
+ "#CCB974",
+ "#64B5CD",
+]
+
+#: JAFF logo palette -- warm, brand-matching alternative colour cycle.
+LOGO_PALETTE: list[str] = [
+ "#8b6cff",
+ "#e05fb0",
+ "#ff6a5a",
+ "#ffc24b",
+]
+
+
+def theme_rc(
+ palette: list[str] | None = None,
+ style: str = DEFAULT_STYLE,
+ context: str = DEFAULT_CONTEXT,
+ font_scale: float = 1.05,
+ **overrides: Any,
+) -> dict[str, Any]:
+ """Return the JAFF ``rcParams`` dictionary, built from a seaborn theme.
+
+ Parameters
+ ----------
+ palette : list[str] or None, optional
+ Colour cycle to use. Defaults to :data:`LOGO_PALETTE` (the JAFF brand
+ colours).
+ style : str, optional
+ seaborn axes style (``"whitegrid"``, ``"darkgrid"``, ``"white"``,
+ ``"ticks"``). Defaults to :data:`DEFAULT_STYLE` (``"darkgrid"``).
+ context : str, optional
+ seaborn plotting context (``"paper"``, ``"notebook"``, ``"talk"``,
+ ``"poster"``). Scales fonts and line widths. Defaults to
+ :data:`DEFAULT_CONTEXT`.
+ font_scale : float, optional
+ Extra font scaling on top of *context*, by default ``1.05``.
+ **overrides
+ Individual ``rcParams`` entries to override.
+
+ Returns
+ -------
+ dict
+ A dictionary suitable for :meth:`matplotlib.rcParams.update`,
+ :func:`matplotlib.pyplot.rc_context`, or the seaborn objects
+ ``Plot.theme`` / ``Plot.config.theme``.
+ """
+ colors = palette if palette is not None else LOGO_PALETTE
+ rc: dict[str, Any] = {}
+ # Seaborn's style (grid/spines/background) and context (font/line scale).
+ rc.update(sns.axes_style(style))
+ rc.update(sns.plotting_context(context, font_scale=font_scale))
+ # JAFF overrides: palette + publication figure/output defaults.
+ rc.update(
+ {
+ "axes.prop_cycle": cycler(color=colors),
+ "figure.figsize": (6.4, 4.0),
+ "figure.dpi": 150,
+ "savefig.dpi": 400,
+ "savefig.bbox": "tight",
+ "mathtext.fontset": "dejavusans",
+ "legend.frameon": False,
+ }
+ )
+ rc.update(overrides)
+ return rc
+
+
+def despine(ax: plt.Axes) -> None:
+ """Remove the top and right spines for the trimmed seaborn finish."""
+ sns.despine(ax=ax)
+
+
+@contextmanager
+def theme_context(rc: dict[str, Any]) -> Iterator[None]:
+ """Apply *rc* only for the duration of the ``with`` block.
+
+ Wraps :func:`matplotlib.pyplot.rc_context` so figures, axes, and raw
+ matplotlib artists (bars, fills) created inside the block pick up the
+ theme without any global state being mutated.
+
+ The seaborn objects color cycle is not read from ``rcParams``, so this is
+ paired with an explicit ``Plot.theme(rc)`` call in the plotter.
+ """
+ with plt.rc_context(rc):
+ yield
+
+
+def apply_global_theme(
+ palette: list[str] | None = None,
+ style: str = DEFAULT_STYLE,
+ context: str = DEFAULT_CONTEXT,
+ font_scale: float = 1.05,
+ **overrides: Any,
+) -> None:
+ """Apply the JAFF seaborn theme globally and persistently for the session.
+
+ Mutates the global ``matplotlib.rcParams`` *and* the seaborn objects
+ ``Plot.config.theme`` so every subsequent plot -- JAFF or otherwise --
+ inherits the house style. This is the opt-in counterpart to the default
+ scoped behaviour.
+
+ Parameters
+ ----------
+ palette : list[str] or None, optional
+ Colour cycle; defaults to :data:`LOGO_PALETTE`.
+ style, context, font_scale
+ Forwarded to :func:`theme_rc`.
+ **overrides
+ Individual ``rcParams`` entries to override.
+ """
+ rc = theme_rc(palette, style, context, font_scale, **overrides)
+ mpl.rcParams.update(rc)
+ so.Plot.config.theme.update(rc)
diff --git a/src/jaff/plotting/_units.py b/src/jaff/plotting/_units.py
new file mode 100644
index 00000000..9967f3d2
--- /dev/null
+++ b/src/jaff/plotting/_units.py
@@ -0,0 +1,133 @@
+"""Unit conversion and axis-label helpers for cross-section plots.
+
+Photo cross sections are stored internally as photon energy in eV and cross
+section in cm². The plotting layer lets callers request other units; the pure
+helpers here perform the conversion (via :mod:`astropy.units`) and render the
+matching axis labels with mathtext.
+"""
+
+from __future__ import annotations
+
+import astropy.units as u
+import numpy as np
+
+# Mapping from JAFF unit strings to astropy units.
+_ENERGY_UNITS: dict[str, u.UnitBase] = {
+ "eV": u.eV,
+ "erg": u.erg,
+ "nm": u.nm,
+ "um": u.um,
+}
+_XSEC_UNITS: dict[str, u.UnitBase] = {
+ "cm2": u.cm**2,
+ "cm^2": u.cm**2,
+ "Mb": u.Mbarn,
+ "barn": u.barn,
+}
+
+# Mathtext rendering of unit strings for axis labels.
+_UNIT_TEX: dict[str, str] = {
+ "cm^2": r"cm$^2$",
+ "cm2": r"cm$^2$",
+ "Mb": "Mb",
+ "barn": "barn",
+ "eV": "eV",
+ "erg": "erg",
+ "nm": "nm",
+ "um": r"$\mu$m",
+}
+
+_ENERGY_LABELS: dict[str, str] = {
+ "eV": "Photon energy (eV)",
+ "erg": "Photon energy (erg)",
+ "nm": "Wavelength (nm)",
+ "um": r"Wavelength ($\mu$m)",
+}
+
+#: Display labels for the photo cross-section processes.
+PROCESS_LABELS: dict[str, str] = {
+ "photo_absorption": "Photoabsorption",
+ "photodecay": "Photodecay",
+}
+
+
+def convert_energy(
+ value: float | np.ndarray, from_unit: str, to_unit: str
+) -> float | np.ndarray:
+ """Convert between photon energies and wavelengths via astropy.
+
+ Energy <-> wavelength conversions use the :func:`astropy.units.spectral`
+ equivalency.
+
+ Parameters
+ ----------
+ value : float or numpy.ndarray
+ Value(s) in *from_unit*.
+ from_unit, to_unit : str
+ Source and target units; one of ``"eV"``, ``"erg"``, ``"nm"``,
+ ``"um"``.
+
+ Returns
+ -------
+ float or numpy.ndarray
+ Value(s) expressed in *to_unit*.
+
+ Raises
+ ------
+ ValueError
+ If either unit is not a recognised energy/wavelength unit.
+ """
+ if from_unit not in _ENERGY_UNITS:
+ raise ValueError(f"Unknown energy unit: {from_unit}")
+ if to_unit not in _ENERGY_UNITS:
+ raise ValueError(f"Unknown energy unit: {to_unit}")
+
+ q = np.asarray(value) * _ENERGY_UNITS[from_unit]
+ return q.to(_ENERGY_UNITS[to_unit], equivalencies=u.spectral()).value
+
+
+def convert_xsec(
+ value: float | np.ndarray, from_unit: str, to_unit: str
+) -> float | np.ndarray:
+ """Convert a cross section between area units via astropy.
+
+ Parameters
+ ----------
+ value : float or numpy.ndarray
+ Value(s) in *from_unit*.
+ from_unit, to_unit : str
+ Source and target units; one of ``"cm^2"``/``"cm2"``, ``"Mb"``,
+ ``"barn"``.
+
+ Returns
+ -------
+ float or numpy.ndarray
+ Value(s) expressed in *to_unit*.
+
+ Raises
+ ------
+ ValueError
+ If either unit is not a recognised cross-section unit.
+ """
+ if from_unit not in _XSEC_UNITS:
+ raise ValueError(f"Unknown cross-section unit: {from_unit}")
+ if to_unit not in _XSEC_UNITS:
+ raise ValueError(f"Unknown cross-section unit: {to_unit}")
+
+ q = np.asarray(value) * _XSEC_UNITS[from_unit]
+ return q.to(_XSEC_UNITS[to_unit]).value
+
+
+def fmt_unit(unit: str) -> str:
+ """Render a unit string with mathtext superscripts where known."""
+ return _UNIT_TEX.get(unit, unit)
+
+
+def energy_label(unit: str) -> str:
+ """Axis label for a photon energy/wavelength *unit*."""
+ return _ENERGY_LABELS.get(unit, f"Photon energy ({fmt_unit(unit)})")
+
+
+def xsec_label(unit: str) -> str:
+ """Axis label for a cross-section *unit*."""
+ return rf"Cross section $\sigma$ ({fmt_unit(unit)})"
diff --git a/src/jaff/plotting/_xsec.py b/src/jaff/plotting/_xsec.py
new file mode 100644
index 00000000..63cf1a57
--- /dev/null
+++ b/src/jaff/plotting/_xsec.py
@@ -0,0 +1,102 @@
+"""Domain helpers for cross-section axis scaling.
+
+Cross-section grids often pad the high-energy tail with zeros, which would
+stretch a plot far past the meaningful data. These pure functions decide the
+energy range to show and whether a log x-axis is warranted, independent of any
+matplotlib state.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+
+def positive_span(
+ df: pd.DataFrame, x_col: str = "energy", y_col: str = "xsec"
+) -> tuple[float | None, float | None]:
+ """Range of *x_col* over which any curve has finite, positive *y_col*.
+
+ Parameters
+ ----------
+ df : pandas.DataFrame
+ Long frame with the given x/y columns.
+ x_col, y_col : str, optional
+ Column names, by default ``"energy"`` / ``"xsec"``.
+
+ Returns
+ -------
+ tuple[float | None, float | None]
+ ``(lo, hi)`` x bounds, or ``(None, None)`` if no point qualifies.
+ """
+ x = df[x_col].to_numpy()
+ y = df[y_col].to_numpy()
+ mask = np.isfinite(y) & (y > 0) & np.isfinite(x)
+ if not mask.any():
+ return None, None
+ xm = x[mask]
+ return float(xm.min()), float(xm.max())
+
+
+def finite_span(
+ df: pd.DataFrame, x_col: str = "energy"
+) -> tuple[float | None, float | None]:
+ """Full finite range of *x_col* (fallback when nothing is positive)."""
+ x = df[x_col].to_numpy()
+ finite = x[np.isfinite(x)]
+ if not finite.size:
+ return None, None
+ return float(finite.min()), float(finite.max())
+
+
+def use_log_x(energy_log: bool, lo: float | None, hi: float | None) -> bool:
+ """Decide whether the x-axis should actually be log-scaled.
+
+ A log axis is dropped to linear when the data spans less than one decade,
+ which keeps narrow ranges readable.
+
+ Parameters
+ ----------
+ energy_log : bool
+ Caller's requested preference.
+ lo, hi : float or None
+ Energy span (e.g. from :func:`positive_span`).
+
+ Returns
+ -------
+ bool
+ ``True`` to use a log x-axis.
+ """
+ if not energy_log:
+ return False
+ if lo is not None and hi is not None and lo > 0 and np.log10(hi / lo) < 1.0:
+ return False
+ return True
+
+
+def padded_limits(
+ lo: float, hi: float, log: bool, pad: float = 0.03
+) -> tuple[float, float]:
+ """Pad an energy range so data does not sit flush against the spines.
+
+ Pads multiplicatively on a log axis, additively on a linear one.
+
+ Parameters
+ ----------
+ lo, hi : float
+ Energy bounds (``hi > lo``).
+ log : bool
+ Whether the axis is log-scaled.
+ pad : float, optional
+ Fractional padding, by default ``0.03``.
+
+ Returns
+ -------
+ tuple[float, float]
+ Padded ``(lo, hi)``.
+ """
+ if log:
+ factor = (hi / lo) ** pad
+ return lo / factor, hi * factor
+ margin = pad * (hi - lo)
+ return lo - margin, hi + margin
diff --git a/src/jaff/plotting/plotter.py b/src/jaff/plotting/plotter.py
index 7806a455..d70186c6 100644
--- a/src/jaff/plotting/plotter.py
+++ b/src/jaff/plotting/plotter.py
@@ -1,165 +1,93 @@
+"""Publication-quality plotting for JAFF, built on the seaborn objects API.
+
+:class:`Plotter` renders tidy long DataFrames of labelled curves onto
+caller-supplied or freshly created matplotlib axes via ``Plot.on(ax)``. The
+generic entry point is :meth:`Plotter.render_series`; the higher-level free
+functions :func:`jaff.plotting.plot_rates` and :func:`jaff.plotting.plot_xsecs`
+build frames and delegate to it. Band bars and shaded fills are drawn with
+matplotlib directly (the objects ``Area`` mark does not render reliably onto an
+existing axes).
+
+By default the house theme is applied *scoped* -- only while a figure is being
+drawn -- so instantiating :class:`Plotter` never mutates global matplotlib
+state. Pass ``global_theme=True`` (or call
+:func:`jaff.plotting.apply_global_theme`) to opt into a sticky session theme.
+"""
+
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
-import astropy.units as u
-import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
-from cycler import cycler
-
-if TYPE_CHECKING:
- from ..physics._typing import XsecsProps
+import seaborn.objects as so
+from . import _frames, _units, _xsec
+from ._theme import (
+ LOGO_PALETTE,
+ apply_global_theme,
+ despine,
+ theme_context,
+ theme_rc,
+)
-# Mapping from JAFF unit strings to astropy units.
-_ENERGY_UNITS: dict[str, u.UnitBase] = {
- "eV": u.eV,
- "erg": u.erg,
- "nm": u.nm,
- "um": u.um,
-}
-_XSEC_UNITS: dict[str, u.UnitBase] = {
- "cm2": u.cm**2,
- "cm^2": u.cm**2,
- "Mb": u.Mbarn,
- "barn": u.barn,
-}
+if TYPE_CHECKING:
+ import pandas as pd
+ from ..physics.photo_reactions._photochemistry import XsecsProps
-def _convert_energy(value: float | np.ndarray, from_unit: str, to_unit: str) -> float | np.ndarray:
- """Convert between photon energies and wavelengths via astropy.
- Energy <-> wavelength conversions use the :func:`astropy.units.spectral`
- equivalency.
+class Plotter:
+ """Publication-quality plotter using the seaborn objects interface.
+
+ Parameters
+ ----------
+ palette : list[str] or None, optional
+ Colour cycle for curves. Defaults to the JAFF brand palette
+ (:data:`jaff.plotting._theme.LOGO_PALETTE`). Pass
+ :data:`jaff.plotting._theme.MUTED_PALETTE` or
+ :data:`jaff.plotting._theme.DEEP_PALETTE` for the seaborn cycles.
+ global_theme : bool, optional
+ If ``True``, apply the house theme globally and persistently on
+ construction (mutating ``matplotlib.rcParams``). If ``False``
+ (default), the theme is applied only for the duration of each plot
+ call, leaving global state untouched.
+ **rc_overrides
+ Individual ``rcParams`` entries to override in the theme.
"""
- if from_unit not in _ENERGY_UNITS:
- raise ValueError(f"Unknown energy unit: {from_unit}")
- if to_unit not in _ENERGY_UNITS:
- raise ValueError(f"Unknown energy unit: {to_unit}")
- q = np.asarray(value) * _ENERGY_UNITS[from_unit]
- return q.to(_ENERGY_UNITS[to_unit], equivalencies=u.spectral()).value
-
-
-def _convert_xsec(value: float | np.ndarray, from_unit: str, to_unit: str) -> float | np.ndarray:
- """Convert a cross section between area units via astropy."""
- if from_unit not in _XSEC_UNITS:
- raise ValueError(f"Unknown cross-section unit: {from_unit}")
- if to_unit not in _XSEC_UNITS:
- raise ValueError(f"Unknown cross-section unit: {to_unit}")
-
- q = np.asarray(value) * _XSEC_UNITS[from_unit]
- return q.to(_XSEC_UNITS[to_unit]).value
+ _RASTER: frozenset[str] = frozenset({"png", "jpg", "jpeg", "tif", "tiff"})
+ def __init__(
+ self,
+ palette: list[str] | None = None,
+ global_theme: bool = False,
+ **rc_overrides: Any,
+ ) -> None:
+ self._palette = palette if palette is not None else LOGO_PALETTE
+ self._rc = theme_rc(self._palette, **rc_overrides)
+ self._global = global_theme
+ if global_theme:
+ apply_global_theme(self._palette, **rc_overrides)
-class Plotter:
- """Publication-quality matplotlib wrapper with a clean, seaborn-like style.
+ # -- theming -----------------------------------------------------------
- Instantiating applies the house ``rcParams`` globally. Any keyword
- arguments override individual ``rcParams`` entries, e.g.
- ``Plotter(**{"font.size": 13})``.
- """
+ def _theme_scope(self):
+ """Context manager that scopes the theme unless it is applied globally."""
+ if self._global:
+ # Already applied globally; no scoping needed.
+ from contextlib import nullcontext
- #: Display labels for the cross-section processes.
- _PROC_LABELS: dict[str, str] = {
- "photo_absorption": "Photoabsorption",
- "photodecay": "Photodecay",
- }
-
- _PALETTE: list[str] = [
- "#4C72B0",
- "#DD8452",
- "#55A868",
- "#C44E52",
- "#8172B3",
- "#937860",
- "#DA8BC3",
- "#8C8C8C",
- "#CCB974",
- "#64B5CD",
- ]
+ return nullcontext()
+ return theme_context(self._rc)
- _RASTER: frozenset[str] = frozenset({"png", "jpg", "jpeg", "tif", "tiff"})
+ def _apply_plot_theme(self, plot: so.Plot) -> so.Plot:
+ """Attach the house rc theme to a seaborn ``Plot`` (objects color cycle
+ is independent of rcParams, so it is set separately via ``.scale``)."""
+ return plot.theme(self._rc)
- _UNIT_TEX: dict[str, str] = {
- "cm^2": r"cm$^2$",
- "cm2": r"cm$^2$",
- "Mb": "Mb",
- "barn": "barn",
- "eV": "eV",
- "erg": "erg",
- "nm": "nm",
- "um": r"$\mu$m",
- }
-
- _ENERGY_LABELS: dict[str, str] = {
- "eV": "Photon energy (eV)",
- "erg": "Photon energy (erg)",
- "nm": "Wavelength (nm)",
- "um": r"Wavelength ($\mu$m)",
- }
-
- _RC_PARAMS: dict[str, Any] = {
- # Figure.
- "figure.figsize": (6.4, 4.0),
- "figure.dpi": 110,
- "savefig.dpi": 300,
- "savefig.bbox": "tight",
- # Fonts -- sans-serif body, mathtext for math.
- "font.family": "sans-serif",
- "font.size": 11,
- "mathtext.fontset": "dejavusans",
- "axes.titlesize": 13,
- "axes.titleweight": "bold",
- "axes.labelsize": 12,
- "legend.fontsize": 10,
- "xtick.labelsize": 10,
- "ytick.labelsize": 10,
- # Lines + colour cycle.
- "lines.linewidth": 2.0,
- "lines.markersize": 5,
- "axes.prop_cycle": cycler(color=_PALETTE),
- # Spines -- full box (all four sides drawn).
- "axes.spines.top": True,
- "axes.spines.right": True,
- "axes.linewidth": 0.9,
- "axes.edgecolor": "#333333",
- # Grid.
- "axes.grid": True,
- "grid.color": "#B0B0B0",
- "grid.linestyle": "-",
- "grid.linewidth": 0.6,
- "grid.alpha": 0.35,
- "axes.axisbelow": True,
- # Ticks.
- "xtick.direction": "out",
- "ytick.direction": "out",
- "xtick.major.size": 4,
- "ytick.major.size": 4,
- # Legend.
- "legend.frameon": False,
- "legend.loc": "best",
- }
-
- def __init__(self, **kwargs: Any) -> None:
- mpl.rcParams.update({**self._RC_PARAMS, **kwargs})
-
- def __fmt_unit(self, unit: str) -> str:
- """Render a unit string with mathtext superscripts where known."""
-
- return self._UNIT_TEX.get(unit, unit)
-
- def __energy_label(self, unit: str) -> str:
- """Axis label for a photon energy/wavelength *unit*."""
-
- return self._ENERGY_LABELS.get(unit, f"Photon energy ({self.__fmt_unit(unit)})")
-
- def __xsec_label(self, unit: str) -> str:
- """Axis label for a cross-section *unit*."""
-
- return rf"Cross section $\sigma$ ({self.__fmt_unit(unit)})"
+ # -- output ------------------------------------------------------------
def __finish(
self,
@@ -167,7 +95,7 @@ def __finish(
show: bool,
save: bool,
filename: str,
- dpi: int = 300,
+ dpi: int = 400,
) -> None:
"""Lay out, optionally save (format from extension), optionally show."""
fig.tight_layout()
@@ -183,6 +111,18 @@ def __finish(
if show:
plt.show()
+ @staticmethod
+ def __scale_axes(plot: so.Plot, xscale: str, yscale: str) -> so.Plot:
+ """Apply log scales to a ``Plot`` where requested (linear is the default)."""
+ scales: dict[str, str] = {}
+ if xscale == "log":
+ scales["x"] = "log"
+ if yscale == "log":
+ scales["y"] = "log"
+ return plot.scale(**scales) if scales else plot
+
+ # -- generic single line plot (back-compat) ----------------------------
+
def plot(
self,
x: list | float | np.ndarray,
@@ -199,9 +139,12 @@ def plot(
show: bool = True,
save: bool = False,
filename: str = "plot.png",
- **plot_kw: Any,
+ **line_kw: Any,
) -> tuple[plt.Figure, plt.Axes]:
- """Generic line plot.
+ """Generic single line plot.
+
+ Retained for backward compatibility; :func:`jaff.plotting.plot_rates`
+ is the preferred entry point for one or many curves.
Parameters
----------
@@ -211,110 +154,290 @@ def plot(
Existing figure/axes to draw onto. Created if ``None``.
label
Legend entry; a legend is drawn when non-empty.
- save
- Write to ``filename``. Output format is inferred from the
- extension (``.png``, ``.pdf``, ``.svg``, ``.jpg`` ...).
- **plot_kw
- Forwarded to :meth:`matplotlib.axes.Axes.plot`.
+ **line_kw
+ Forwarded to :class:`seaborn.objects.Line`.
"""
- if fig is None or ax is None:
- fig, ax = plt.subplots()
-
- ax.set_xlabel(xlabel)
- ax.set_ylabel(ylabel)
- ax.set_xscale(xscale)
- ax.set_yscale(yscale)
- ax.set_title(title)
- ax.grid(grid)
- ax.plot(x, y, label=label, **plot_kw)
+ x = np.asarray(x, dtype=float)
+ y = np.asarray(y, dtype=float)
+ df = _frames.line_frame(x, y)
- if label:
- ax.legend()
+ with self._theme_scope():
+ if fig is None or ax is None:
+ fig, ax = plt.subplots()
- self.__finish(fig, show, save, filename)
+ plot = so.Plot(df, x="x", y="y").add(
+ so.Line(color=self._palette[0], **line_kw)
+ )
+ plot = self.__scale_axes(plot, xscale, yscale)
+ plot = plot.label(x=xlabel, y=ylabel, title=title)
+ self._apply_plot_theme(plot).on(ax).plot()
+
+ ax.grid(grid)
+ despine(ax)
+ if label and ax.lines:
+ # Objects marks carry no legend label for a single group; attach
+ # the entry to the drawn line directly.
+ ax.lines[-1].set_label(label)
+ ax.legend()
+
+ self.__finish(fig, show, save, filename)
return fig, ax
- def __draw_xsec_axes(
+ # -- generic multi-series renderer -------------------------------------
+
+ def render_series(
+ self,
+ df: pd.DataFrame,
+ *,
+ x_col: str,
+ y_col: str,
+ group_col: str,
+ xlabel: str,
+ ylabel: str,
+ log_x: bool,
+ log_y: bool,
+ dynamic_x: bool = False,
+ trim: bool = False,
+ shade: bool | float = False,
+ bands_df: pd.DataFrame | None = None,
+ layout: str = "overlay",
+ title: str = "",
+ fig: plt.Figure | None = None,
+ ax: plt.Axes | None = None,
+ grid: bool = True,
+ show: bool = True,
+ save: bool = False,
+ filename: str = "plot.png",
+ ) -> tuple[plt.Figure, Any]:
+ """Render a tidy long frame of labelled curves.
+
+ The single home for multi-curve rendering. Both
+ :func:`jaff.plotting.plot_rates` and :func:`jaff.plotting.plot_xsecs`
+ build a frame and delegate here.
+
+ Parameters
+ ----------
+ df : pandas.DataFrame
+ Long frame with columns *x_col*, *y_col*, *group_col* (one group
+ per curve).
+ x_col, y_col, group_col : str
+ Column names for the x, y, and grouping/label columns.
+ xlabel, ylabel : str
+ Axis labels.
+ log_x, log_y : bool
+ Log-scale the respective axis.
+ dynamic_x : bool, optional
+ If ``True``, drop the x-axis to linear when the positive data spans
+ less than one decade (cross-section behaviour). Default ``False``.
+ trim : bool, optional
+ Tighten the x-axis to the positive data span. Default ``False``.
+ shade : bool or float, optional
+ Shade under each curve (``True`` = default alpha; float = alpha).
+ bands_df : pandas.DataFrame or None, optional
+ Band-averaged bars to overlay (columns ``lower``/``upper``/``xsec``
+ already in plot units).
+ layout : str, optional
+ ``"overlay"`` (all curves on one axes) or ``"subplots"`` (one
+ stacked panel per group). Default ``"overlay"``.
+ title, fig, ax, grid, show, save, filename
+ Standard rendering controls.
+
+ Returns
+ -------
+ tuple[Figure, Axes | numpy.ndarray]
+ For ``"overlay"`` the second item is the single axes; for
+ ``"subplots"`` it is the array of per-group axes.
+ """
+ if layout not in ("overlay", "subplots"):
+ raise ValueError(f"layout must be 'overlay' or 'subplots', got {layout!r}")
+
+ draw_kw = dict(
+ x_col=x_col,
+ y_col=y_col,
+ group_col=group_col,
+ xlabel=xlabel,
+ ylabel=ylabel,
+ log_x=log_x,
+ log_y=log_y,
+ dynamic_x=dynamic_x,
+ trim=trim,
+ shade=shade,
+ )
+
+ with self._theme_scope():
+ if layout == "subplots":
+ labels = list(dict.fromkeys(df[group_col]))
+ n = len(labels)
+ fig, axes = plt.subplots(
+ n, 1, sharex=True, figsize=(6.4, 2.6 * n), squeeze=False
+ )
+ axes = axes[:, 0]
+ for i, (a, label) in enumerate(zip(axes, labels)):
+ sub = df[df[group_col] == label].reset_index(drop=True)
+ self.__draw_series(
+ a,
+ sub,
+ set_xlabel=(i == n - 1), # only the bottom panel
+ title=label,
+ grid=grid,
+ **draw_kw,
+ )
+ self.__draw_bands(a, bands_df)
+ if title:
+ fig.suptitle(title)
+ self.__finish(fig, show, save, filename)
+ return fig, axes
+
+ if fig is None or ax is None:
+ fig, ax = plt.subplots()
+ self.__draw_series(ax, df, set_xlabel=True, title=title, grid=grid, **draw_kw)
+ self.__draw_bands(ax, bands_df)
+ self.__finish(fig, show, save, filename)
+ return fig, ax
+
+ def __draw_series(
self,
ax: plt.Axes,
- x: np.ndarray,
- series: list[tuple[str, np.ndarray]],
+ df: pd.DataFrame,
*,
- energy_unit: str,
- xsec_unit: str,
- energy_log: bool,
- xsec_log: bool,
+ x_col: str,
+ y_col: str,
+ group_col: str,
+ xlabel: str,
+ ylabel: str,
+ log_x: bool,
+ log_y: bool,
+ dynamic_x: bool,
trim: bool,
+ shade: bool | float,
grid: bool,
- legend: bool,
- set_xlabel: bool = True,
- title: str = "",
- **plot_kw: Any,
+ set_xlabel: bool,
+ title: str,
) -> None:
- """Draw one or more cross-section curves onto a single axes.
-
- Shared by both the overlay and subplot layouts. *series* is a list of
- ``(label, sigma)`` pairs already converted to *xsec_unit*; *x* is the
- photon energy already converted to *energy_unit*.
- """
- x = np.asarray(x)
- x_lo: float | None = None
- x_hi: float | None = None
-
- for label, y in series:
- y = np.asarray(y)
- ax.plot(x, y, label=label, **plot_kw)
-
- # Track the energy span where this curve has positive data.
- mask = np.isfinite(y) & (y > 0)
- if mask.any():
- lo, hi = float(x[mask].min()), float(x[mask].max())
- x_lo = lo if x_lo is None else min(x_lo, lo)
- x_hi = hi if x_hi is None else max(x_hi, hi)
-
- # Slight padding on the sides
- if trim and x_lo is not None and x_hi is not None and x_hi > x_lo:
- xr_lo, xr_hi = x_lo, x_hi
+ """Draw the labelled curves in *df* onto a single axes."""
+ labels = list(dict.fromkeys(df[group_col]))
+ multi = len(labels) > 1
+
+ # Span drives both dynamic-log and trim; compute once if either needs it.
+ lo = hi = None
+ if dynamic_x or trim:
+ lo, hi = _xsec.positive_span(df, x_col, y_col)
+ if lo is None:
+ lo, hi = _xsec.finite_span(df, x_col)
+ eff_log_x = _xsec.use_log_x(log_x, lo, hi) if dynamic_x else log_x
+
+ plot = so.Plot(df, x=x_col, y=y_col)
+ if multi:
+ # Cycle the palette so more curves than colours still render.
+ colors = [self._palette[i % len(self._palette)] for i in range(len(labels))]
+ plot = plot.add(so.Line(), color=group_col).scale(
+ color=so.Nominal(colors, order=labels)
+ )
else:
- finite = x[np.isfinite(x)]
- xr_lo = float(finite.min()) if finite.size else None
- xr_hi = float(finite.max()) if finite.size else None
-
- # Dynamic x-scale
- use_log_x = energy_log
- if (
- energy_log
- and xr_lo is not None
- and xr_hi is not None
- and xr_lo > 0
- and np.log10(xr_hi / xr_lo) < 1.0
- ):
- use_log_x = False
-
- ax.set_xscale("log" if use_log_x else "linear")
- ax.set_yscale("log" if xsec_log else "linear")
- if set_xlabel:
- ax.set_xlabel(self.__energy_label(energy_unit))
- ax.set_ylabel(self.__xsec_label(xsec_unit))
- if title:
- ax.set_title(title)
+ plot = plot.add(so.Line(color=self._palette[0]))
+
+ scales: dict[str, str] = {}
+ if eff_log_x:
+ scales["x"] = "log"
+ if log_y:
+ scales["y"] = "log"
+ if scales:
+ plot = plot.scale(**scales)
+
+ plot = plot.label(
+ x=xlabel if set_xlabel else "",
+ y=ylabel,
+ title=title,
+ )
+ self._apply_plot_theme(plot).on(ax).plot()
+
+ if multi:
+ self.__vary_linewidths(ax, len(labels))
+
ax.grid(grid)
+ despine(ax)
+ if trim and lo is not None and hi is not None and hi > lo:
+ ax.set_xlim(*_xsec.padded_limits(lo, hi, eff_log_x))
+
+ if shade:
+ self.__shade(ax, df, labels, multi, x_col, y_col, group_col, alpha=shade)
+ if not set_xlabel:
+ ax.set_xlabel("")
+
+ def __vary_linewidths(self, ax: plt.Axes, n: int) -> None:
+ """Give each overlaid curve a distinct width, thinnest drawn in front.
+
+ Mirrors the natural line-width variation seen in seaborn ``relplot``
+ overlays: the first curve is drawn thick, later curves get
+ progressively thinner and a higher z-order so the thin line sits on top
+ of the thick one where they overlap.
+
+ Assigned by draw order: at this point ``ax.lines`` holds exactly the
+ ``n`` series lines (in the ``Nominal`` group order), before any shade
+ fills or band bars are added.
+ """
+ base = float(self._rc.get("lines.linewidth", 2.0))
+ widths = np.linspace(base * 1.6, base * 0.75, n)
+ lines = ax.lines
+ for i in range(min(n, len(lines))):
+ lines[i].set_linewidth(widths[i])
+ lines[i].set_zorder(2.0 + i) # later = thinner = higher zorder = front
+
+ def __shade(
+ self,
+ ax: plt.Axes,
+ df: pd.DataFrame,
+ labels: list[str],
+ multi: bool,
+ x_col: str,
+ y_col: str,
+ group_col: str,
+ *,
+ alpha: bool | float,
+ ) -> None:
+ """Fill the area under each curve down to the axis bottom."""
+ a = 0.18 if alpha is True else float(alpha)
+ base = ax.get_ylim()[0]
+ for i, label in enumerate(labels):
+ sub = df[df[group_col] == label]
+ color = self._palette[i % len(self._palette)] if multi else self._palette[0]
+ ax.fill_between(
+ sub[x_col].to_numpy(),
+ base,
+ sub[y_col].to_numpy(),
+ color=color,
+ alpha=a,
+ linewidth=0,
+ )
+
+ def __draw_bands(self, ax: plt.Axes, band_df: pd.DataFrame | None) -> None:
+ """Overlay band-averaged cross sections as bars, clipping open bands."""
+ if band_df is None or band_df.empty:
+ return
+ _, xmax = ax.get_xlim()
+ ybottom = ax.get_ylim()[0]
+ lower = band_df["lower"].to_numpy(dtype=float)
+ upper = band_df["upper"].to_numpy(dtype=float)
+ # Clip an open (inf) or over-wide top edge to the visible axis range.
+ upper = np.where(np.isfinite(upper), upper, xmax)
+ upper = np.minimum(upper, xmax)
+ width = np.clip(upper - lower, a_min=0.0, a_max=None)
+ ax.bar(
+ lower,
+ band_df["xsec"].to_numpy(dtype=float),
+ width=width,
+ bottom=ybottom,
+ align="edge",
+ facecolor="none",
+ edgecolor="#555555",
+ linewidth=1.1,
+ alpha=0.9,
+ zorder=1.5,
+ label="Band average",
+ )
- if trim and x_lo is not None and x_hi is not None and x_hi > x_lo:
- # Pad the limits by a few percent so the data does not sit flush
- # against the spines. Pad multiplicatively on a log axis,
- # additively on a linear one.
- pad = 0.03
- if use_log_x:
- factor = (x_hi / x_lo) ** pad
- ax.set_xlim(x_lo / factor, x_hi * factor)
- else:
- margin = pad * (x_hi - x_lo)
- ax.set_xlim(x_lo - margin, x_hi + margin)
-
- if legend and len(series) > 1:
- ax.legend()
+ # -- cross-section plot (back-compat single-xsecs entry) ---------------
def plot_xsec(
self,
@@ -328,17 +451,19 @@ def plot_xsec(
energy_log: bool = True,
xsec_log: bool = True,
trim: bool = True,
+ shade: bool | float = False,
+ show_bands: bool = False,
+ bands: pd.DataFrame | None = None,
title: str = "",
grid: bool = True,
show: bool = True,
save: bool = False,
filename: str = "xsec.png",
- **plot_kw: Any,
) -> tuple[plt.Figure, Any]:
- """Plot photo cross sections sigma(E) on log-log axes.
+ """Plot the cross sections of a single ``xsecs`` mapping on log-log axes.
- Single home for cross-section plotting: handles unit conversion,
- axis scaling and labelling. ``Reaction.plot_xsecs`` delegates here.
+ Retained for backward compatibility; :func:`jaff.plotting.plot_xsecs`
+ is the preferred entry point (it accepts one or many reactions).
Parameters
----------
@@ -347,91 +472,60 @@ def plot_xsec(
``photon_energy`` (eV) plus any of ``photo_absorption``,
``photodecay`` (all in cm^2).
processes
- Subset of the process keys to draw. Default: every
- process present (non-``None``) in ``xsecs``.
- layout
- ``"overlay"`` (default) draws every process on one axes;
- ``"subplots"`` draws one stacked panel per process sharing the
- energy axis.
- energy_unit
- Horizontal-axis unit: ``"eV"``, ``"erg"``, ``"nm"``, ``"um"``.
- xsec_unit
- Cross-section unit: ``"cm^2"``, ``"Mb"``, ``"barn"``.
- energy_log, xsec_log
- Log-scale the respective axis (default ``True``).
- trim
- Tighten the energy axis to the range where the cross section is
- positive (default ``True``). Cross-section grids often pad the
- high-energy tail with zeros, which would otherwise stretch the
- axis far past the meaningful data.
- **plot_kw
- Forwarded to :meth:`matplotlib.axes.Axes.plot`.
+ Subset of process keys to draw. Default: every process with data.
+ layout, energy_unit, xsec_unit, energy_log, xsec_log, trim, shade,
+ show_bands, bands, title, grid, show, save, filename
+ See :meth:`render_series` and :func:`jaff.plotting.plot_xsecs`.
Returns
-------
tuple[Figure, Axes | numpy.ndarray]
- For ``layout="overlay"`` the second item is the single axes; for
- ``layout="subplots"`` it is the array of per-process axes.
"""
- if layout not in ("overlay", "subplots"):
- raise ValueError(f"layout must be 'overlay' or 'subplots', got {layout!r}")
-
energy = xsecs["photon_energy"]
if energy is None:
raise ValueError("xsecs has no 'photon_energy' data to plot.")
if processes is None:
- processes = [k for k in self._PROC_LABELS if xsecs.get(k) is not None]
- # Keep only requested processes that actually carry data.
+ processes = [k for k in _units.PROCESS_LABELS if xsecs.get(k) is not None]
processes = [p for p in processes if xsecs.get(p) is not None]
if not processes:
raise ValueError("xsecs has no cross-section data to plot.")
- # Data are stored as eV + cm^2; convert to the requested units.
- x = np.asarray(_convert_energy(energy, "eV", energy_unit))
+ x = np.asarray(_units.convert_energy(np.asarray(energy), "eV", energy_unit))
series = [
(
- self._PROC_LABELS.get(k, k),
- np.asarray(_convert_xsec(xsecs[k], "cm2", xsec_unit)), # type: ignore
+ x,
+ np.asarray(_units.convert_xsec(np.asarray(xsecs[k]), "cm2", xsec_unit)),
+ _units.PROCESS_LABELS.get(k, k),
)
for k in processes
]
+ df = _frames.long_frame(series, "energy", "xsec", "process")
+ band_df = (
+ _frames.band_frame(bands, energy_unit, xsec_unit)
+ if show_bands and bands is not None
+ else None
+ )
- common = dict(
- energy_unit=energy_unit,
- xsec_unit=xsec_unit,
- energy_log=energy_log,
- xsec_log=xsec_log,
+ return self.render_series(
+ df,
+ x_col="energy",
+ y_col="xsec",
+ group_col="process",
+ xlabel=_units.energy_label(energy_unit),
+ ylabel=_units.xsec_label(xsec_unit),
+ log_x=energy_log,
+ log_y=xsec_log,
+ dynamic_x=True,
trim=trim,
+ shade=shade,
+ bands_df=band_df,
+ layout=layout,
+ title=title,
+ fig=fig,
+ ax=ax,
grid=grid,
- **plot_kw,
+ show=show,
+ save=save,
+ filename=filename,
)
-
- if layout == "subplots":
- n = len(series)
- fig, axes = plt.subplots(
- n, 1, sharex=True, figsize=(6.4, 2.6 * n), squeeze=False
- )
- axes = axes[:, 0]
- for i, (a, (label, y)) in enumerate(zip(axes, series)):
- self.__draw_xsec_axes(
- a,
- x,
- [(label, y)],
- legend=False,
- set_xlabel=(i == n - 1), # only the bottom panel
- title=label,
- **common, # type: ignore
- )
- if title:
- fig.suptitle(title)
- self.__finish(fig, show, save, filename)
- return fig, axes
-
- # overlay
- if fig is None or ax is None:
- fig, ax = plt.subplots()
- self.__draw_xsec_axes(ax, x, series, legend=True, title=title, **common) # type: ignore
- self.__finish(fig, show, save, filename)
-
- return fig, ax
diff --git a/src/jaff/registry.txt b/src/jaff/registry.txt
index cce993c8..29b48bc4 100644
--- a/src/jaff/registry.txt
+++ b/src/jaff/registry.txt
@@ -1,4 +1,4 @@
-xsecs/leiden.hdf5 3e5f430b5fcf2ed60dd48844cff893da8a360e2925d661e9166be2a47e9ff819
-xsecs/norad.hdf5 1737f95fbb4b3f8017e09c7056056c287c2d7cdb98263cb19174257efe9e034c
+xsecs/leiden.hdf5 14bb33b6251c2f876dd02b4d9595a1da6416b086a88512fb8e0d8f402827b4f6
+xsecs/norad.hdf5 b954dd6edbd07fddfe96ae57c5dcbf6daa205269e3efbb2f89ab5d3fb9b983d9
xsecs/verner_1996.csv 51a47ed60030ddabd8ce7dbb89e116f19676dc94c85f522156b7d445f5764ef2
-shielding/leiden.hdf5 66e1394484d0a0baeaad129d09529dca9d187869c683a9860b247fe8fe49a9b6
+shielding/leiden.hdf5 ac0ab02603c63e2b607653f46a71887047d702b06291f6b3addcf0d40e4c54a6
diff --git a/tests/test_languages.py b/tests/test_languages.py
index 8e984ac9..602bcaa1 100644
--- a/tests/test_languages.py
+++ b/tests/test_languages.py
@@ -30,20 +30,20 @@ class TestCLanguage:
def test_c_initialization(self, simple_network):
"""Test that C codegen initializes correctly."""
cg = Codegen(simple_network, lang="c")
- assert cg.lang == "c"
- assert cg.lb == "["
- assert cg.rb == "]"
- assert cg.ioff == 0 # C uses 0-based indexing
- assert cg.line_end == ";"
- assert cg.comment == "//"
+ assert cg.lang.name == "c"
+ assert cg.lang.lb == "["
+ assert cg.lang.rb == "]"
+ assert cg.lang.idx_offset == 0 # C uses 0-based indexing
+ assert cg.lang.line_end == ";"
+ assert cg.lang.comment == "//"
def test_c_types(self, simple_network):
"""Test C type declarations."""
cg = Codegen(simple_network, lang="c")
- assert cg.types.get("int") == "int "
- assert cg.types.get("float") == "float "
- assert cg.types.get("double") == "double "
- assert cg.types.get("bool") == "_Bool "
+ assert cg.lang.types.get("int") == "int "
+ assert cg.lang.types.get("float") == "float "
+ assert cg.lang.types.get("double") == "double "
+ assert cg.lang.types.get("bool") == "_Bool "
def test_c_rate_generation(self, simple_network):
"""Test basic rate code generation for C."""
@@ -58,7 +58,7 @@ def test_c_rate_generation(self, simple_network):
def test_c_matrix_separator(self, simple_network):
"""Test that C uses ][ for matrix indexing."""
cg = Codegen(simple_network, lang="c")
- assert cg.matrix_sep == "]["
+ assert cg.lang.sep == "]["
class TestCxxLanguage:
@@ -67,28 +67,28 @@ class TestCxxLanguage:
def test_cxx_initialization(self, simple_network):
"""Test that C++ codegen initializes correctly."""
cg = Codegen(simple_network, lang="cxx")
- assert cg.lang == "cxx"
- assert cg.lb == "["
- assert cg.rb == "]"
- assert cg.ioff == 0 # C++ uses 0-based indexing
- assert cg.line_end == ";"
- assert cg.comment == "//"
+ assert cg.lang.name == "cxx"
+ assert cg.lang.lb == "["
+ assert cg.lang.rb == "]"
+ assert cg.lang.idx_offset == 0 # C++ uses 0-based indexing
+ assert cg.lang.line_end == ";"
+ assert cg.lang.comment == "//"
def test_cxx_aliases(self, simple_network):
"""Test that 'c++' and 'cpp' aliases work for C++."""
cg_cpp = Codegen(simple_network, lang="cpp")
- assert cg_cpp.lang == "cxx"
+ assert cg_cpp.lang.name == "cxx"
cg_cplus = Codegen(simple_network, lang="c++")
- assert cg_cplus.lang == "cxx"
+ assert cg_cplus.lang.name == "cxx"
def test_cxx_types(self, simple_network):
"""Test C++ type declarations."""
cg = Codegen(simple_network, lang="cxx")
- assert cg.types.get("int") == "int "
- assert cg.types.get("float") == "float "
- assert cg.types.get("double") == "double "
- assert cg.types.get("bool") == "bool "
+ assert cg.lang.types.get("int") == "int "
+ assert cg.lang.types.get("float") == "float "
+ assert cg.lang.types.get("double") == "double "
+ assert cg.lang.types.get("bool") == "bool "
def test_cxx_rate_generation(self, simple_network):
"""Test basic rate code generation for C++."""
@@ -103,7 +103,7 @@ def test_cxx_rate_generation(self, simple_network):
def test_cxx_matrix_separator(self, simple_network):
"""Test that C++ uses ][ for matrix indexing."""
cg = Codegen(simple_network, lang="cxx")
- assert cg.matrix_sep == "]["
+ assert cg.lang.sep == "]["
class TestFortranLanguage:
@@ -112,25 +112,25 @@ class TestFortranLanguage:
def test_fortran_initialization(self, simple_network):
"""Test that Fortran codegen initializes correctly."""
cg = Codegen(simple_network, lang="fortran")
- assert cg.lang == "fortran"
- assert cg.lb == "("
- assert cg.rb == ")"
- assert cg.ioff == 1 # Fortran uses 1-based indexing
- assert cg.line_end == "" # No semicolons
- assert cg.comment == "!"
+ assert cg.lang.name == "fortran"
+ assert cg.lang.lb == "("
+ assert cg.lang.rb == ")"
+ assert cg.lang.idx_offset == 1 # Fortran uses 1-based indexing
+ assert cg.lang.line_end == "" # No semicolons
+ assert cg.lang.comment == "!"
def test_fortran_alias(self, simple_network):
"""Test that 'f90' alias works for Fortran."""
cg = Codegen(simple_network, lang="f90")
- assert cg.lang == "fortran"
+ assert cg.lang.name == "fortran"
def test_fortran_types(self, simple_network):
"""Test Fortran type declarations."""
cg = Codegen(simple_network, lang="fortran")
- assert cg.types.get("int") is None
- assert cg.types.get("float") is None
- assert cg.types.get("double") is None
- assert cg.types.get("bool") is None
+ assert cg.lang.types.get("int") is None
+ assert cg.lang.types.get("float") is None
+ assert cg.lang.types.get("double") is None
+ assert cg.lang.types.get("bool") is None
def test_fortran_indexing(self, simple_network):
"""Test that Fortran uses 1-based indexing."""
@@ -153,7 +153,7 @@ def test_fortran_rate_generation(self, simple_network):
def test_fortran_matrix_separator(self, simple_network):
"""Test that Fortran uses comma for matrix indexing."""
cg = Codegen(simple_network, lang="fortran")
- assert cg.matrix_sep == ", "
+ assert cg.lang.sep == ", "
class TestPythonLanguage:
@@ -162,26 +162,26 @@ class TestPythonLanguage:
def test_python_initialization(self, simple_network):
"""Test that Python codegen initializes correctly."""
cg = Codegen(simple_network, lang="python")
- assert cg.lang == "python"
- assert cg.lb == "["
- assert cg.rb == "]"
- assert cg.ioff == 0 # Python uses 0-based indexing
- assert cg.line_end == "" # No semicolons
- assert cg.comment == "#"
+ assert cg.lang.name == "python"
+ assert cg.lang.lb == "["
+ assert cg.lang.rb == "]"
+ assert cg.lang.idx_offset == 0 # Python uses 0-based indexing
+ assert cg.lang.line_end == "" # No semicolons
+ assert cg.lang.comment == "#"
def test_python_alias(self, simple_network):
"""Test that 'py' alias works for Python."""
cg = Codegen(simple_network, lang="py")
- assert cg.lang == "python"
+ assert cg.lang.name == "python"
def test_python_types(self, simple_network):
"""Test Python type declarations."""
cg = Codegen(simple_network, lang="python")
# Python uses empty string for types (dynamically typed)
- assert cg.types.get("int") is None
- assert cg.types.get("float") is None
- assert cg.types.get("double") is None
- assert cg.types.get("bool") is None
+ assert cg.lang.types.get("int") is None
+ assert cg.lang.types.get("float") is None
+ assert cg.lang.types.get("double") is None
+ assert cg.lang.types.get("bool") is None
def test_python_rate_generation(self, simple_network):
"""Test basic rate code generation for Python."""
@@ -195,7 +195,7 @@ def test_python_rate_generation(self, simple_network):
def test_python_matrix_separator(self, simple_network):
"""Test that Python uses ][ for matrix indexing."""
cg = Codegen(simple_network, lang="python")
- assert cg.matrix_sep == "]["
+ assert cg.lang.sep == "]["
class TestRustLanguage:
@@ -204,25 +204,25 @@ class TestRustLanguage:
def test_rust_initialization(self, simple_network):
"""Test that Rust codegen initializes correctly."""
cg = Codegen(simple_network, lang="rust")
- assert cg.lang == "rust"
- assert cg.lb == "["
- assert cg.rb == "]"
- assert cg.ioff == 0 # Rust uses 0-based indexing
- assert cg.line_end == ";"
- assert cg.comment == "//"
+ assert cg.lang.name == "rust"
+ assert cg.lang.lb == "["
+ assert cg.lang.rb == "]"
+ assert cg.lang.idx_offset == 0 # Rust uses 0-based indexing
+ assert cg.lang.line_end == ";"
+ assert cg.lang.comment == "//"
def test_rust_alias(self, simple_network):
"""Test that 'rs' alias works for Rust."""
cg = Codegen(simple_network, lang="rs")
- assert cg.lang == "rust"
+ assert cg.lang.name == "rust"
def test_rust_types(self, simple_network):
"""Test Rust type declarations."""
cg = Codegen(simple_network, lang="rust")
- assert cg.types.get("int") == "i32 "
- assert cg.types.get("float") == "f32 "
- assert cg.types.get("double") == "f64 "
- assert cg.types.get("bool") == "bool "
+ assert cg.lang.types.get("int") == "i32 "
+ assert cg.lang.types.get("float") == "f32 "
+ assert cg.lang.types.get("double") == "f64 "
+ assert cg.lang.types.get("bool") == "bool "
def test_rust_rate_generation(self, simple_network):
"""Test basic rate code generation for Rust."""
@@ -241,25 +241,25 @@ class TestJuliaLanguage:
def test_julia_initialization(self, simple_network):
"""Test that Julia codegen initializes correctly."""
cg = Codegen(simple_network, lang="julia")
- assert cg.lang == "julia"
- assert cg.lb == "["
- assert cg.rb == "]"
- assert cg.ioff == 1 # Julia uses 1-based indexing
- assert cg.line_end == "" # No semicolons
- assert cg.comment == "#"
+ assert cg.lang.name == "julia"
+ assert cg.lang.lb == "["
+ assert cg.lang.rb == "]"
+ assert cg.lang.idx_offset == 1 # Julia uses 1-based indexing
+ assert cg.lang.line_end == "" # No semicolons
+ assert cg.lang.comment == "#"
def test_julia_alias(self, simple_network):
"""Test that 'jl' alias works for Julia."""
cg = Codegen(simple_network, lang="jl")
- assert cg.lang == "julia"
+ assert cg.lang.name == "julia"
def test_julia_types(self, simple_network):
"""Test Julia type declarations."""
cg = Codegen(simple_network, lang="julia")
- assert cg.types.get("int") == "Int64 "
- assert cg.types.get("float") == "Float32 "
- assert cg.types.get("double") == "Float64 "
- assert cg.types.get("bool") == "Bool "
+ assert cg.lang.types.get("int") == "Int64 "
+ assert cg.lang.types.get("float") == "Float32 "
+ assert cg.lang.types.get("double") == "Float64 "
+ assert cg.lang.types.get("bool") == "Bool "
def test_julia_indexing(self, simple_network):
"""Test that Julia uses 1-based indexing."""
@@ -287,17 +287,17 @@ class TestRLanguage:
def test_r_initialization(self, simple_network):
"""Test that R codegen initializes correctly."""
cg = Codegen(simple_network, lang="r")
- assert cg.lang == "r"
- assert cg.lb == "["
- assert cg.rb == "]"
- assert cg.ioff == 1 # R uses 1-based indexing
- assert cg.line_end == "" # No semicolons
- assert cg.comment == "#"
+ assert cg.lang.name == "r"
+ assert cg.lang.lb == "["
+ assert cg.lang.rb == "]"
+ assert cg.lang.idx_offset == 1 # R uses 1-based indexing
+ assert cg.lang.line_end == "" # No semicolons
+ assert cg.lang.comment == "#"
def test_r_assignment_operator(self, simple_network):
"""Test that R uses <- assignment operator."""
cg = Codegen(simple_network, lang="r")
- assert cg.assignment_op == "<-"
+ assert cg.lang.assignment_op == "<-"
def test_r_indexing(self, simple_network):
"""Test that R uses 1-based indexing."""
@@ -326,24 +326,24 @@ def test_indexing_comparison(self, simple_network):
# 0-based languages
for lang in ["cxx", "c", "python", "rust"]:
cg = Codegen(simple_network, lang=lang)
- assert cg.ioff == 0, f"{lang} should use 0-based indexing"
+ assert cg.lang.idx_offset == 0, f"{lang} should use 0-based indexing"
# 1-based languages
for lang in ["fortran", "julia", "r"]:
cg = Codegen(simple_network, lang=lang)
- assert cg.ioff == 1, f"{lang} should use 1-based indexing"
+ assert cg.lang.idx_offset == 1, f"{lang} should use 1-based indexing"
def test_semicolon_usage(self, simple_network):
"""Compare semicolon usage across languages."""
# Languages with semicolons
for lang in ["cxx", "c", "rust"]:
cg = Codegen(simple_network, lang=lang)
- assert cg.line_end == ";", f"{lang} should use semicolons"
+ assert cg.lang.line_end == ";", f"{lang} should use semicolons"
# Languages without semicolons
for lang in ["python", "fortran", "julia", "r"]:
cg = Codegen(simple_network, lang=lang)
- assert cg.line_end == "", f"{lang} should not require semicolons"
+ assert cg.lang.line_end == "", f"{lang} should not require semicolons"
def test_matrix_separator(self, simple_network):
"""Compare matrix indexing across languages."""
@@ -352,10 +352,10 @@ def test_matrix_separator(self, simple_network):
cg_r = Codegen(simple_network, lang="r")
# C++ uses ][
- assert cg_cxx.matrix_sep == "]["
+ assert cg_cxx.lang.sep == "]["
# Julia and R use comma separation
- assert cg_julia.matrix_sep == ", "
- assert cg_r.matrix_sep == ", "
+ assert cg_julia.lang.sep == ", "
+ assert cg_r.lang.sep == ", "
def test_all_languages_aliases(simple_network):
@@ -378,7 +378,7 @@ def test_all_languages_aliases(simple_network):
for alias, canonical in aliases.items():
cg = Codegen(simple_network, lang=alias)
- assert cg.lang == canonical, f"Alias '{alias}' should map to '{canonical}'"
+ assert cg.lang.name == canonical, f"Alias '{alias}' should map to '{canonical}'"
@pytest.mark.parametrize("lang", ["c", "cxx", "fortran", "python", "rust", "julia", "r"])
diff --git a/tests/test_network_parsers.py b/tests/test_network_parsers.py
index f91bf9bd..14e59ab8 100644
--- a/tests/test_network_parsers.py
+++ b/tests/test_network_parsers.py
@@ -68,9 +68,9 @@ def test_udfa_format_detection(self, fixtures_dir):
found = False
for reaction in network.reactions:
if (
- reaction.reactants.count == 1
- and reaction.reactants[0].name == "H2"
- and reaction.products.count == 2
+ reaction.reactants.core.count == 1
+ and reaction.reactants.core[0].name == "H2"
+ and reaction.products.core.count == 2
and "H" in reaction.products
):
found = True
@@ -169,7 +169,7 @@ def test_custom_variables_parsing(self, fixtures_dir):
if (
reaction.reactants.count == 2
and "H2" in reaction.reactants
- and "CR" in reaction.reactants
+ and "_CR" in reaction.reactants
):
found = True
# The rate should be the zeta value (1.3e-17)
@@ -191,7 +191,7 @@ def test_photo_chemistry_parsing(self, fixtures_dir):
# Verify the photo reactions are correctly identified
for reaction in photo_reactions:
- assert reaction.rtype() == "photo"
+ assert reaction.type == "photo"
def test_temperature_limits_application(self, fixtures_dir):
"""Test that temperature limits (tmin/tmax) are correctly applied."""
@@ -292,9 +292,9 @@ def test_species_creation_from_reactions(self, fixtures_dir):
species_names = [s.name for s in network.species]
for reaction in network.reactions:
- for reactant in reaction.reactants:
+ for reactant in reaction.reactants.core:
assert reactant.name in species_names
- for product in reaction.products:
+ for product in reaction.products.core:
assert product.name in species_names
# Check species dictionary
@@ -349,4 +349,11 @@ def test_special_species_handling(self, fixtures_dir):
special in network.reactions[i].verbatim
for i in range(len(network.reactions))
):
- assert special in species_names
+ if special == "PHOTON":
+ mapped = f"_{special}"
+ assert any(
+ mapped in network.reactions[i].verbatim
+ for i in range(len(network.reactions))
+ ), f"{mapped} should appear in reaction verbatim"
+ else:
+ assert special in species_names
diff --git a/zensical.toml b/zensical.toml
index 4fcac2f5..f8a0836f 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -107,7 +107,6 @@ nav = [
"api/core/reaction/index.md",
"api/core/reaction/species.md",
"api/core/reaction/elements.md",
- "api/core/reaction/rtype.md",
"api/core/reaction/is_isomer_version.md",
"api/core/reaction/serialize_exploded.md",
"api/core/reaction/serialize.md",
@@ -122,6 +121,7 @@ nav = [
"api/core/reaction/has_product.md",
"api/core/reaction/get_code.md",
"api/core/reaction/get_sympy.md",
+ "api/core/reaction/band_xsecs.md",
"api/core/reaction/plot_rate_coefficient.md",
"api/core/reaction/plot_xsecs.md"
] },
@@ -132,9 +132,9 @@ nav = [
"api/core/reactions/from_verbatim.md",
"api/core/reactions/get_list.md",
"api/core/reactions/get.md",
- "api/core/reactions/with_rtype.md",
+ "api/core/reactions/with_type.md",
"api/core/reactions/verbatim.md",
- "api/core/reactions/rtypes.md",
+ "api/core/reactions/types.md",
"api/core/reactions/reactants.md",
"api/core/reactions/products.md",
"api/core/reactions/rates.md",
@@ -245,6 +245,12 @@ nav = [
{ "Physics" = [
"api/physics/index.md",
{ "physics.Constants" = ["api/physics/constants/index.md"] },
+ ] },
+ { "Plotting" = [
+ "api/plotting/index.md",
+ "api/plotting/plot_rates.md",
+ "api/plotting/plot_xsecs.md",
+ { "plotting.Plotter" = ["api/plotting/plotter.md"] },
] }
] },
{ "Development" = [