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

- Logo
- (Just Another Fancy Format) -

+ JAFF logo + +

JAFF

+ +

Just Another Fancy Format

+ +

A fast, multi-format astrochemical network parser with analysis, code generation, and explicit photochemistry.

+ +

+ License: MIT + Python 3.11+ + Version + Status + Docs +

+ +

+ 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. --- -![xkcd:927](./assets/xkcd.png) +
+ xkcd 927: Standards
+ xkcd 927 +
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/+KNdff7089dRTMT56AAA6RtWYO+otrQAAAEAcBGsff/yxqUw74YQTfK7XNksrVBs2bJikpqbKvHnzTDXXSSedJHvttZfEA23f3H333UPa5l//+pcJ1YYOHSqvvfaapKenm+v32Wcf81wvu+wy+e677+T777+Xgw46KEZHDgBAcMINpAKGcu7wWkmty1Fe8Q0AAABoEzFZ9Gv69OnmfP/99/e5/qOPPjLnRxxxhLz//vtmLbLjjjvOXKc/J6rVq1ebijT1pz/9yROqWQ477DDZd999zeV33323XY4RAIBA2n8xhii3ggIAAACJHKxt2rTJrJnWvXt3n+ut8OnMM8/0rKl29tlnm3OtXEtUul6cysjIkEMOOcTvfXQtOe/7AgCQqK2gcd9+GeeHBwAAgOQR9VbQ6upqs3h/ZmamT+VWXV2dLFy40ARquvaapW/fvuZ88+bNEi++/fZbmTRpkmzdulVycnJMK+fo0aNl11139Xv/RYsWmfMhQ4Y0q1azWNtu375dNmzY0Cx0BACgLYUdjrn97yvosM4dzWq5uKi1AwAAQAcW9WAtLS3NnNfU1JiQLSsry7O4v65B1r9/f8nPz/fcXyeDqniYCmrRUM3blClT5Nlnn5XTTjvNDFnQyjRv69evN+c9e/Zscd02y7p16wjWAAAdssTLLTaxU1IGAACAJBH1YE0HEvTu3VvWrFlj1lrT9dTU119/bc6bDijYuHGjOS8uLpb2pqHgscceK/vtt5/06dPHhILLly8366LNmjXLDF7QyrsHH3zQZzut0FPZ2dkB9+19W2VlZYyOP0WKivJisu9Ew+sQH3gf4gfvRfyIh/ci0L9lBXNsWeavs+pm12dnZ0hRke8/PPmTkS5S7+f6vLwsKSqSkKzP1Zo1R7Pru3bNlZxO8f8+oAHvRfzgvYgPvA8AkFhiMhVUF+vXyZh33nmnCZ20/VEnf6pRo0b53FfbIlWvXr2kvT311FPNwjFtWz3jjDPkvvvuk//+97/yySefyFlnnWXCN0t9fb1P9V2gwNGiE1MBAGgvblf7rZPmToA15AAAAIB2DdbGjh1rAihtkbz22ms91++yyy5y5JFH+tx36tSp5vyAAw6Q9hao4kzbVP/617+aqjtt4/zss898gjVrO61mC0RbYy1We2y0ORxOKStrqJ7r6P/CV1JS3t6H0qHxPsQP3ov4EU/vhdud6zfhCubYqirT/YZylZW1UlIS+O9BS11tlqQ0PyDZvr1GSkr81bIFVl6uy080/0etLVsqpLrOHffvQ0fHexE/eC/iQ6K/D1TaAeioYjIVVNcTe/nll01Ypov5FxQUyAknnCDPPfdcs6ouaz2zQw89VOKZVpztv//+5vKyZct8btPnp0pKSgJuv2XLFs/lzp07x+w4AQDJQyuvXK7Y7Dt+VjZtRJUZAAAAElBMKtbU0KFD5aWXXmrxPm632wRwGra1tPB/vLAq02pra32uHzhwoN/AzZt1W0pKihngAABAS1YtTJGfvsyUmiqbDNzdIfuNrpUWVhyIKMkKJWRzu23NKtZCaSvVsDBqbaiEcQAAAEjGirVgaYulDjpIhFBNLV682Jx369bN53prIINWrC1dutTvtjrIQe26666mig8AgEBqq0Umvp0lXUrWyu7b58rSGXZZOKNh6nZ7i9X6ZdHcLWusAQAAoEMEa4lkxowZ8uOPP5rLVkuoZZ999pEuXbqYy6+88orfNtBPP/3UXNapowAAtGTxz+nictlkv5qfZbBjhQytWyw/fpEZ1RfNFu1Yqx2qx9zx2NIKAACADoVgrdFHH30kzz//vKxdu9bnBXI4HPLBBx/IlVdeaVpXtVrttNNOa7b+2mWXXWYuv/HGG/K///1PXI2L4mgV25/+9CepqKgw4dvZZ5/dNu8sACBhlZX4/vXcxVkW1f03VHSFmYSZVs5IHrz5Q5vWUNo6AQAAkIBitsZaolm5cqU8/vjjcv/995upncXFxaZlc/Xq1VJdXe0ZOvDkk0/6nR46ZswYmTZtmkycOFHuvPNOeeyxx0yQpvvVcC4tLU0eeughycnJaYdnBwBIKG1chhXSGmvmP81TMHeEVWbusEM6EjkAAAC0H4K1RieddJIJwMaPH2/WSVuxYoXnRSoqKjItnJdffrm57I8OJXjiiSfk6aefNhVr2v5ZWlpq1pHbd9995cYbb5Tddtutbd5VAECSiX54ZItixVpIFWeRVMsFi6wNAAAAbYRgrVG/fv3k2muvNae6ujrZsGGDqVQrKCgw1WvB0Kq0q666SsaNGyfr1q0z2+u2+fn5sXwPAQCIUAhTPUO8fwwOIZJNAAAAgKgiWPNDW0D79u0b9ouq1Wt9+vSJ5H0BACCmbOF2nkaYZkU1DAuw3huBGwAAANoKwwsAAOiQ3GElURFP4gzUSgoAAAAkIII1AADijK2NhxeEnKz5ub/bHdlBhz+DwM+G5HQAAABoIwRrAAB0QOG2gmoAFnnuR/IFAACA5NDma6w5nU554403ZPLkyWK32+Woo46SU0891UzPBAAAzcXf35DusFs5w69Mi+2+AAAAgLgJ1mbMmCFjxoyRkSNHyssvv+xz26233irvvfee5+dvvvlG5s6dK3fccUcsDgUAgATkbuPHCLUVNL6DQgI3AAAAJHQr6Mcff2wq00444QSf6xcsWOAJ1YYNGya77767ufz666/Lzz//HItDAQAgCUQ3aGvazhlSK2iA+0cSZpmKN3eyVPMBAACgI4lJsDZ9+nRzvv/++/tc/9FHH5nzI444Qt5//31555135LjjjjPX6c8AAKCthheEmWQFGF7QZtsDAAAAyR6sbdq0yayZ1r17d5/rf/zxR3N+5plnetZUO/vss835vHnzYnEoAADAj3Czu0iHFwSK1MKK2gjpAAAAkGzBWnV1tVRVVUlGRoakp6d7rq+rq5OFCxeaQE3XXrP07dvXnG/evDnahwIAAKT1JCuU4QP+YjVbNIrQGGoAAACABBT1YC0tLc2c19TUmJDNogMKHA6H9OvXT/Lz83ccgL3hEJgKCgCAf7HpDA0vyXJHoRU0Ws+HhlIAAAAkXbCWmpoqvXv39llrTX399dfmfK+99vK5/8aNG815cXFxtA8FAIDEZIvvh2i2rdsddMjVcD/fe2vFHCEZAAAAElFqLHZ62GGHyWuvvSZ33nmnaQvdvn27mfypRo0a5XPfDRs2mPNevXrF4lAAAEhMkYzZjKFoVKxF72CYCgoAAIAkDNbGjh0rn3zyiaxfv16uvfZaz/W77LKLHHnkkT73nTp1qjk/4IADYnEoAACglYQrpDXWNMyKReYX5cANAAAASNipoD169JCXX37ZhGU6wKCgoEBOOOEEee655zxrqlkmTZpkzg899NBYHAoAAAkn5p2g7Vzp5f+xQz8i8jMAAAAkZcWaGjp0qLz00kst3sftdpsATsO2nj17xupQAABIcO64bgUNZSqo223rQK8WAAAAkl3MgrVg6CRQa9ABAABow7+Dw2wF1WAt2tGYLdwl5SJd7w0AAACIx1bQYNTW1kpdXV17PTwAAEnB5RLZXmoz58FyR31eQqhTPWMchpG1AQAAIJEr1mpqamTKlCmSmZkpBx98sM9tq1evlptvvll++uknU7Gma6vdddddUlxcHItDAQAg8QRZEla6wS7j/5cl1RV2ycx2yahzq6Wwpyu2wZOpWItsKmjzpxf+/tpzrTgAAAAgJhVrX3/9tfzxj3+UDz/80Od6p9Mp48aNkx9//NGsr+ZyuWTChAlmimh9fT3vBgAAIZj+aabklpXKkZWTpNP2zTLt08yIWkGDqVgLeJdg11iLcHufTQJuQ9wGAACABA7WPv30U3N+4okn+lz/7bffyuLFi82k0GuuuUauv/56ycjIkIULF8rHH38ci0MBACBplaxNkcOrv5eurq1yWPU0KV2fIs4g/p0q/JZP2iwBAACAmLeCLliwwDMZtGklmxozZoxceeWVniq2hx9+WMaPHy+nnnoq7w4AABJ+OBZcaBagoiuYijU/raBm+EAwD+t5jAi297/DZscIAAAAJGTFmrZ4btmyRdLS0qSwsNDntpkzZ5rzo48+2nPd4Ycfbs6XLFkS7UMBACAp2EIJ1lpZYs3azqcVtPFiROFWCK2g1uMBAAAAiS7qwVp1dbU4HI6Gndt37L60tFTWrFlj2kCHDRvmud4K37Zt2xbtQwEAIGkFCtZCmQ7qtTefs9YeN6LhBYF2Gs4u3TZWUwMAAEByBWtZWVmmWk3DtZKSEs/1OgVUaaim4ZqlsrLSnOfl5UX7UAAASFithlcBK9ZsIVesxYP4OhoAAACgnYI1m80mO++8s7n8/vvve65/9913zfm+++7rc38rfOvWrVu0DwUAgKQVqDItnIo1K4oLaiqoqRKLIAbzs8ZaRLvydz0pHQAAABJ5eIFOA50/f778+9//Nufbt2+XadOmmdCt6aTQ5cuXm/NBgwbF4lAAAEhK4a6x5j+NCiGJ8nNXM3wglDXW/GxPyRoAAAASUdQr1tS5554rI0eONBM/v/zySxOqqfPPP99TzWaZOHGiOT/00ENjcSgAACSczWtTWr2PO8yKNSv/8jdAIOEqvaJY/QYAAADETcWarqH24osvmvbPGTNmSE5OjhxyyCHyu9/9rtkEUR1asPvuu8sBBxwQi0MBACDhlG5IkR7ODTGuWAsvkPI/vCCyijfCMQAAACSqmARrVrh2zjnnmFMg2hr6yiuvxOoQAABIOFZgdnD1D63cz/+QApcZXhBaaNbyuINYiF6Vmd9jp4gNAAAAidwKCgAAwhP0WmWu8CrWdkwF9bnW56y17f21kUYaZiVcGyoAAAAQy4q1ptatWyelpaVit9ulsLCQKaAAAPgTbLDmDnONtRZaQd1tsK5ZQytpdCrmArbDhrk/AAAAIK6CtZKSEnn22Wflk08+kS1btvjc1rNnTznttNPk4osvltzc3FgeBgAACSPYUCjsNdb8hFmey8FUrAXYF2EWAAAAOqKYtYLOmTNHTj75ZPnvf//bLFSzKtgef/xxOfXUU2XVqlWxOgwAABJLhK2grrCGF4Q2fCCiNdkCDS8IO5nzN9403H0BAAAAcVCxpi2fV1xxhTlPSUmRo48+2kwF1So1p9Mpa9askW+//VYmTpxoQrXLLrtMPvroIzPwAAAANOUOoWKt5djLHZV1zgizAAAAgJgFa88//7wJ1QoKCuSZZ56RESNGNLvP2WefbcK1a665RpYvXy7vvvtuixNEAQDoCNxtVLHmtxU0mMf1W7EWfImY3tMW7vpu/o6F6jQAAAAkWyvo+PHjzflNN93kN1SzHHnkkXL55Zf7bAMAQEcWdLDmtkW0xlooVXDNN3M3X2Mt6MXhml9lngkBGQAAABJQ1IM1l8tl2jttNpsce+yxrd5/9OjR5lyr1gAAQFtNBfW5NujaNf8P6w4Y9LWH4FtaAQAAgDgL1mpra024lpGRIZmZma3ePz8/35zX1NRE+1AAAOhwraCxngrqb3iBtnYGfdwhXt/6zkjRAAAAkETBWlZWlgnUNChbu3Ztq/dftmyZOe/cuXO0DwUAgMQTqFUy2Iq1VirHWgrAgsrV/LWC6o8htIL6W2MNAAAASEQxWWPNWlft6aefbvF+Wtmmww3UHnvsEYtDAQAgKfkNyNzu1ivWGoM333Cr8XKQeVckFWv+9+cOq/CsYRACAAAAkGTB2llnnWXO33zzTbn99tvNhNCm1q9fbyaCTp482fx85plnxuJQAADoMK2gGlC1usaan+tCmwra/N4mWAt6aEKg/YazUYjXAwAAAFGWKjFw/PHHy6effirffvutvPHGG/Luu+/KrrvuKt27dxe32y1r1qyRBQsWmIo1dcEFF1CxBgBASFNBJbyAy7Odu3nBmjvGE0Ubg7mmraANFWvRqz0jVwMAAEBCB2s6EfTf//633HPPPfL222+Lw+GQ2bNnN7tfWlqajB07Vq6++upYHAYAAAkouIDJ7Wp+P7u4Wq1YC+1RYjC8IMBU0nAq1tyB1msjWQMAAEAiB2tKp4LefffdcvHFF8tnn30m8+fPNy2hdrtdunbtairUTjzxRFPFBgAAGvldOy2UirUghxe4w0ui/A4vCDFYa16xFp5Aj0muBgAAgIQN1qqrq+XWW281k0H//ve/y8CBA+Wqq66SRFBZWSnTp0831XW6BpwGgbm5ubLTTjvJ6NGjZdCgQQG31RBx0aJFLe7/hBNOkN///vcxOHIAQLIIerimv2DN3foaa/4eyQq2ggnHzMAAt5+13YJt5dRgrdnjhD/8gIo1AAAAJFWwpuumffLJJ5KXl2eCtUTx1ltvyV133WXaVpv68ssv5fHHH5fzzz9fbrrpJklJSWl2n19//VV+/vnnoKalAgAQUATDC+xBrLFmBVhRn6YZUitok4o1d5SHFwAAAACJGqzl5ORIdna234Aqnm3evFnS09Pl0EMPNW2q2qJaUFAga9euNaGbBmevvPKKCdU0XAvkoosukiOPPNLvbT179ozhMwAAJINIhxe4WmkFDbA3n7PW7xrtVtAw11hr4TEAAACAhF1jbeTIkTJp0iQz/bN3796SCLRFUwcp6EAFf7f98Y9/NFNONVy75JJLpLi42O9++vfvL/vtt18bHDEAoCPzH6y5xO1KCWo7m79W0CAft9nwAncQ00hb2D7oB/ezjd9WUAAAAKCN2GOx08svv1xSU1PlmWeekUShAxX8hWpKBy5cf/315rLT6ZSZM2e28dEBADoKd8StoK1UrLW0/6AzqubDB4KuEouw4s1nV4HWdQt2vTcAAAAgHoM1rVj717/+JZ9++qncdtttUlJSIolOK9FsNptnyAEAADHhL2CyBRcqhTu8INSpoM0fObJWUOv6cPjdV3i7AgAAAOJjKuiNN95oLg8ZMkTefPNNeeedd2Tw4MHSo0cPycjI8LtdVlaW/POf/5R4tXz5cnE3/l9/S+2t33//vcyYMUO2bt1qJooOHTpUjj32WDMdFQCAmE4FDXN4QShTQf0JfY215tuHk4YFfEySNQAAACRqsKZDC3SKpjdtn1y4cKE5BaJTROPZCy+8YM6Liopk7733Dni/r7/+2udnfS0ee+wxOffcc+Vvf/tbwHZTAACMUIYXNEmW7OIKoWItTIECvaATQdM4Gv72TY7FX9MnuRoAAAASNljT4OiYY44JeTutWItXEyZMkPfee89cvvbaa8300KZ0WqhOFNXBBX369DHPR6vcdLvffvtNXn31VVPNd++998bsONPSUqSoKL4DyrbC6xAfeB/iB+9F4rwX6fbgtt2S2zCsoGlAlZmZIUVF/qvDVUv/vNOlc64UFLV4eGJP8TfVUyQtNTXoz5m/MCwrq+Xj9sf8r4OfRK4gP1uKWnke/E7ED96L+MF7ER94HwCggwdrGig9+uijkiwWL15sBhdoG+jo0aPltNNO83u/J554QvLz832u06Dt/PPPlzvvvNO0xL777rty+umnt1jxBgDo2IKt3NLKtOYBl1tczlb273Vf7+28bwtqB00fN6SpoFEaXhDi9QAAAEDcB2vJZNWqVXLJJZdIeXm5qURraQ24pqGadyXbrbfeaqreNm7caAY6xCpYczicUlZWJR2Z9S98JSXl7X0oHRrvQ/zgvUi892J7qdZz5fq9zXvb7dv1r3DfCmq72y1VlXVSUlIbeP9bAu+/dEuF1LcSS9XXZ/sZGOAWR51TSkpa/zvI5cptlh7qEbV23P5UV2X4rX4r21olWSX+E0Z+J+IH70X84L2ID4n+PlBpB6CjislU0GSwbt06ueiii2TTpk2y5557ypNPPhlw8EJrtHX0wAMP9FTAAQAQKR1S0Lzyq/U11lqqDAu30ssWyuwBf+uiud1hPXbDc6E+DQAAAAlesVZWVuZZtL9bt25y2GGHtbrNZ599JpWVlebyySef7HfdsvailWUXXnihrF27VnbddVd59tlnJScnJ6J9WtvX1ob2r/EAgA4m6OEFNv8tla22ZNoCtoIG89j6uJFPBW1esRbWVNAA67WRtQEAACChgrX77rtPPvjgAzMx8+WXXw5qGw3gxo4dK1VVVSbIuuqqqyQebN682YRq2ga60047yfPPPx+ViaXLli0z54WFhVE4SqBj0C/gS2anypLZaZKR5ZZd9ndI9/6tLCAFJLigAyo/FWv2INY6a3n/zSd2Nt9BgDXSQlhjzd9Ow50K6u94qWEDAABAwrSCLl261IRqNptNHnjgARk4cGBQ240cOVJuueUWc/m5556Tbdu2SXsrLS017Z86zbN///7y4osvSufOnSPe79y5c2X69Onm8r777huFIwU6htWLUmXqR5mSs3SN1P26Vb56JUvWLE5p78MCYsxvDZb/yi+3v4AruO2br5MWnIBbRVSxFmawFuGxAAAAAO0erOm0S3X88cfL/vvvH9K2Z5xxhowYMUKqq6vl448/lva0fft2+cMf/mDWQOvTp4+pvAu2uuzzzz+X119/3QRz3nSSqLbIXn755eJyuaRLly7mOQMIztI5qdLDuVFG1s6RQ6unSbFjk/wyNX7axoFYiKil0h3hGmvBPHakwViAVtCwpoL62VfjQwAAAACJ0QpqVWKFGxjpdnPmzJGpU6fK+eefL+3lsccek19//dVcTktLk+uvvz7gfU866SQ588wzPT8vWbJEHn/8cbnzzjtNeFZcXGzWjFuxYoVZf05pO6neJzfX/yQ2AM1tWp0ig5xbPT/3qN8gs1YWN3yZDq4oB0hagVpBg23J9GbtJ5hwy99dQltjzd8vbyjTD1o5mJauBwAAAOItWLPWDhs+fHhY21vbaUtpe/IeKqDPyXpe/uy1114+P48ePdq0so4fP17Wr18vW7Zs8dymQdrRRx9t1pDr1atXjI4eSD6OOpGaSrvku7Z7rstzVZjz2mqbZGbzzRnJyV9A5e/T7vIzXVMDrlYr1hpv924j9VwOtmLN7S9Yaz3tth4mahVrQTfOAgAAAHEYrGkLp8PhkKysrLCnZurAA6sVsz1dfPHFpp01GD179vT5efDgwXLrrbea09atW80wBn1tCgoKpF+/fmK3R9xxC3Q45VvtPmGaynU1TBLevoVgDUksopbK1ivWrNu1um3Hdo23BTUV1P9BBr+tv/bNMIPyCI4FAAAAaPdgLSMjw4RGGiLV19dLamrouysvLzfn2dnZ0p4GDBhgTpHSYQfRGHgAdHQ1FQ1f9TNdNZ7rst3VZg2pyu0auoXR7wYkU67msvlpBXWJo5XhBVrppny3DaEV1O/abkG2oAbYf0PFWui1Zw3HAgAAALSfiEqpNFSzQiRdZywc1nbBDgoA0DFUV9rE7nZKutR7rtMv0OnuOqmp5Ks0klhEwwtCaAX1qVhrDNZc4a3tFuwaa56KNX/TTMOqMgvwZwEVawAAAGgjEfco6lRP9dVXX4W1/ZdffumzHwBQGp5luHesfWjJIFhDkgs2YNIAzW9A1epUUFvgYM0dbrAWXJjVUitouFNBaQUFAABAQgdrRxxxhDl/+eWXzcL9oZg3b558+umn5vLhhx8e6aEASCLVFXbJ9Bus1UpNFRVrgL+KNV03LaKKtSDCLZfL5rM+m7W91WIa6mOH1ErabIf+a9bcNIgCAAAgUYK1U045Rbp162bWShs7dqysXr06qO3mz58v48aNE6fTKbvttpsceOCBkR4KgCSrWPMfrNVJLcEaklmwraCBWjJbWWPNXzvmjuEFQUz29FMpZw46qFBux3FGayooFWsAAABI6GAtPT1d7rrrLrPe2uLFi+Xkk0+W+++/X3777TcTmnmrq6uT2bNny2233SZnn322lJSUSGZmptkeALzVVtvMemrKJTapkzRzWcM2XX8NSFbB5kumFbRZQOWK/RprgaaRBtUK2rwN1Rq64PL9X4bg6LE0y/jCrH4DAAAA2noqqHc7qIZl99xzj1RWVsrzzz9vTllZWdK1a1cz8VOv1yBNwzWLXv/AAw/IrrvuGo3DAJBE6mpEOrkd5rLDlmaCtXS3w4RtVKwhmQVbuaWVaRpIebMHESr5XefMHUoraPgVZ4FaQfV5OFuptAtFeIMQAAAAgHaoWLOcc8458tJLL8nQoUM911VXV8uaNWtk0aJFsnbtWp9QbeTIkfLmm2/KqFGjonUIAJJIXa1WrDUEaxqq1dnSzWUN1upqqFhDEov18III11gLtwXVe//NjlsDwTAq1iKpngMAAADipmLNss8++8iHH34o06ZNk0mTJpnhBFu2bJGqqirJy8uTwsJC2WOPPUyFG1NAAbREw7M074o1W0MrqIZt2iZqvlCTr6ED8xdwmZbKVoI1a8iAb7DWuM9WAim9Xds5m1bKBTvV0zo2exjHHeh4mgomXAQAAADiMlizHHDAAeYEAOGqq24arFkVaw5xOW3irBdJbcjagKQSbLWVBlH2sIYX2MJeY81vG6mpOAuu0i5wK6g7vDXW/DD7pmINAAAAidYKCgDRol+w6x02SZfGVlCvirW0xoEGWrUGJCW/kzltwbVkut3BDS9wu3326NmPO8hgrFkLanAVZwGDObN9GL/TAVpBrao8AAAAINYI1gDE5fpqKs1d76lY05PyhG2sswbp6FNBbSaQalr5FWzVmTcrnGotkLLCs6gPLwgiEAz+uTQMdgAAAADaAsEagLicCKo8raA+wwsarqNiDUkr6KmgDYFUqJVj/irddtzYShtpwGAtuHXNrKo0v2vDhdkK2vy50AoKAACAtkOwBiDuWNVonqmgPsML6kyZChVrSFahrLEWTsDlb5KmtVZbMMMLvO+/Y3uXWbst2O39TTONWsWaVr/RCgoAAIA2QrAGIO5YoZm1npr3VFD9Qp8iTqmtbtdDBOJ0KqgGVLbwKtbcrU/2DFRx5hl+EOwabf6COWd02jeZCgoAAIC2RLAGID6DNbdb0qR+R8WaNLSCWlVrOjUUSEahTQV1NavWaq1irWFttuYPElS1W6DhBe7IpopGssaa35CPijUAAAC0kdS2eiAACCVY01DNis68hxdYLaK11Zm8oOjQdIH+pi2ZgdZYK91ol81rU6Sol9OEUU238+wz6IozV1Qr1mzhrrEWYHgBraAAAABoKwRrAOJyeIE1uGBHK6h3xZpD6mqy2unogPioWDMtnU3u7G8q6JI5qfL9h5mS4naK05YhRb1cYmucuNusYi3IqaDNA73GqaKtVry1MLwgnIo18RcuasUaFa0AAABoG7SCAojPijWvYK1OUj1rrHlaQRvXYQOSTkitoC0PAdCgbM7EDBlUt1xOrfhUBtatkJK1KYFbQYMdPhBojbXW2lADtYLqcYezxprfijUNF/nzAQAAAG2DYA1A3NHQzJoIalWsuWw6sqDhjywN3WpZYw1JKtjlwRqGEPhrydwxnVPPK8rsslftPNMiuXftXK/7hbPGWqCKMytYa31wgv812lxRXWMt2Ko/AAAAIFIEawDiv2KtsQ3UOk8XhhcgiYVQseZvCIB1mzkPsG6Z/2BNg6qWgzFrv5FOBbX7rViTKCFYAwAAQNshWAMQnxVrUmcua5Waq/GPKqsd1AwvoBUU0tHXWPM/vKDhNus+wQdrwQRSrU0FbXWNtYCtoK5Wq+X877B5EBnMZFQAAAAgWgjWAMRpxVrD4upmGqjN5luxpsMLqtv1EJOOBiIrfkmVRT+nSS2vbULQ9cqaBVxNhgi4QgjWtNqt9VbQxvuGucZa4KmgGurtaGGNrBU0+HASAAAAiBRTQQHEnbraHVNBHV5/TO2oWGsYXmC+VLNGeVRe7/GvZsvmNVoB5ZKZ4zPkqHOrpKg3ZT/twh3+Gms71jpr+DnQQICmgVzDtq5WBwgEGj4Q7FTQgMMLGg9Y20FTUiN7qVhjDQAAAG2JijUAcT28wHsaaJ3saAXV6hZHbbsdYlKZPSFDylc75JjKb+Wkii+ksHyDTHovS5xRW/MKoQi22CrQVNCG22yhV6wFEaztqDhruGANFAm+Ys13+IF1FNbzCHmAgalYC30IAwAAABAtBGsA4o7Da3iBaQXV8to0lzgaW0HT3HWeAA6R0SBj6Zw02bluseS5KyVVnLJn7Vyp3CqyeiFFze2htcmaO+7np3LMHf4aa1o11lqYagV2ngCv8X8jNJSTIIYfmEI5t9sThjklpXF/OyrWQsdUUAAAALQfgjUAcUW/2Nc7bJIm3sGaW7KynD7DC1RtNcFapDavSTEBZY/6jZ7rst010t25SRbN3FEtiLZjwq0gFgnTkMsKtCRA5VcoU0EbKtZCG17gtNlDG17QJAx02lKaHLctjDXWfOn+Q90PAAAAEC6CNQBxxapC824FTUt3S3q6u1mwVkewFrGSdXbJcNWYajVvA+tWyPrlqVK+lYCirWm41TQwCxhSBRhe4FljLUDA5G//wQRrVnDmCcJCbQVtMmzAaiX1XmMtFC5/68y5XaG3lAIAAABhIlgDEFesaZ/eraBpaS5zsqaCmmo2t1tqaQWNWNmmFMl3bff8vDBtsDnv6dwoWa4qee+xXCndwF8VbclZ37wSzZ96h0iKOMOaCpridgZoBW05SHU6bOZ3z3pcq1XbOt5ggjnvYM3VpBU01LXR3E59rdzNA8KGocIAAABAzPFtCUBcsdo7M9wNkwlqbemSnu4yJ6tizdYYrlGxFrmtG+1S0Bis6XCIBRlDPLcdX/m1dHWWyifPZcuaRQ0BSHta8WuqfP9RhsydnC7OJA5OzGTMIBImbZluGpB51jprrFTzk58ZKWFWrGmYp8GYFWbp76f3/jQUbIkel3ewVt/YCrpjewmJ01Tt+T4X3VdrASEAAAAQLQRrAOJKTVVDRcyOVlAN1tymHdSqjlF6O2usRW57qV1yXRUNl+25ZkDE3PRh5meNJo6smiz9a1bJlA8zpa4dp7D+/G26THo7Q6p+2iK/fCPyxX+zkzZc89cKWuTcYs7LSuyelkqnQ8ywCW9N1zoLqWItqGDNJqmy44W3qkit/Wnw1hJ9z7wfe8f2Ls/+Q6G7alaxppV3SfrZAAAAQPwhWAMQVzQs0zYzq4KlThoq1rxbQVW6u07qatrxQJNAbbWIo9YmOa4q83OlPcecL0wfIovTBkqNLcP8PLz2F3FWOWXJ7PYZZrBts03mTUmXkTWz5aCaH2VU1WQpW+OSRT8n53AFrbby1wqqAeivPzQ8Zw3AdAJn04As0jXWWqv0Mu2n/oIxDfjc7uC293rsHRVv9TtaTUOgwWHzAQ6tB4QAAABAtKRGbU8AEAW1VTbJcNft+NmWLvkarGkrqGT6VqyxxlpEKrY2/NtKridYy5ac3HopLKyT2St2lyWuATK68htJF4f0rN8gq37rIbvs10pJUgys+DVNMl010r9+dcPxuiulj2OdLJ/fW4bt2/bH0yYVa35aQfNcFbJ8XrEceEKtpzKsacVasFNBrXDMJTvWKLO73a2uTVZf51uxZgVjtsZQT6voWqLBWYq7ecVbauPxOEJ8O11+QshgAkK0Hy2q1FC/vNQuJStEtpaIbFidaX4uL7OJzSbStYdLuvVxmlPXnk5J2/FvKgAAAHGHYA1A3FWsaTWa9xdvDdXS05q2gtaxxlqEysvs5ltutrshWKuw50h2tlNG7LVd+g2okknfFcoWe2fp6tpqgrUfVvUWR520+ZfcdctSpHv9Bp/rujs3yvS1fU3VYvqOvLXDTAW1Wia9Qyp/QwACLdVmDR+ol1SzXqGt8TpnEGus+atYs/bZWgtm4Iq1xoq3UCvWnM0no2pbaR2toDG3dZNd1i1NkeoKu/k91D+7daqz90k/f/r2uBrPGz6PO95ju9tpKmZz3RXS2VUpfVyV4habbCnrIr8t6iKz7Nlis7ulS/eGoK24r1N6D6mXFP7vFQAAxBH+1wRA3FespWfUmHDNbbOLwwQB9WZqaFXjoAOEX7GW7a72VCxV2rIlJ6chNMkvqJesLKdsrC2SrnVbpatzq2k9LF2fIsX92q7PTr+Mb92QIn1cW32u72Idz8YU6d6GxxMtK39LlR8/zzCtuDvt7ZA9j6yVlJTgp4LW1/lWrNVLirm8YyqoreU11qxhAbYUE5Tpz/qYjlZbOW2S6lNxluYTaLU2vEBvt6rTvIM1/Qzqsbe2RltwraCtB4QIT+V2myyfnyrL56dJ6YYUyXDVSI67wvx5rFXE2W6HFDReThWHqYLU97XpKc1db1qb9c8ff5+YIY7l5rzKlilbUrrI5hVdZP3qrvLb9E6SnpMhO+3pkJ1HOiQn3zdUBQAAaA8EawDitmJNvzJplVp6erVZY836Iq9fyvQ+ZbSCRkTbrqz11axW0G45DaGJtmN17lInW7cXmJ/1C7B+id683t6mwZp+kXfU2aSTs9wTxGjwmu2ukTT9DGyyJ1ywVlMlMundTOlXvUoKXGUyZ+pu0qV7qgzcvX5HWOQOtmKtsYXSlmoCKw0yfNdY87+9Vgopp4ml7A3BmraCtvJSaqunVe2mahvX4dtRsWYPomLNf8WbtpiGOrzA5W94ga6x1krAh+Dp0JJVC1Jl2bw0Wb88xYRovR2rZXj9Gs9QjWjQ3+0KW475HOS7Gn7f9fc8u36d9KlfZ37WdR+X1vWXxZP7y/ypOdJ3aL1pB+/W12n+zAIAAGgPBGsA4q5iLa8xWHNImqlS0+EFOhnU+iKe465mKmiUKtZy3JWetbaqbVmSnVPmub2gs0OWrW4I1lRn1zbZsq6LeWfaigZnWraW79pufl6d2ksGN1az5Du3y9ZNndrsWKJl0cx0kXqXjKyd7Rka8fO3A3cEayG0gloVaxpAZ7lrvSrWGu7n1sq1Jq2STSvWtF5Nw2q91Fqw1VCx5j8Y00qxekdj2V0LFWveraQ+wZy79cdvSivzmlWsaeVcYmWtcUU/O1vW2WXdslTThl2yJkVsTpf0qN8oB9SvMefe7bzetE5N//HD0XjS0NanVk0/jmIzFZb6uS+355oWdD3pRGKLhuZaJVvo3GLOtUJVP+uZ7lrZtW6hDKtbJGtSe8ri+QPli187S+fuLhm6j0N6Daqnig0AALQ5gjUAcaWm2iZdG4M1q03MTAVN31GxZq4TB2usRaiizC59rMEFtmxTpqZrrFkKOtdLjS1Pqm0ZJrTp7CyTteuLpC2VldglS6vTGhfMX5daLP0dq8yXbA3btm7sLImkdKPIrO/09az2XFfg3CaLtu2o9NJ1yloP1sS3Yq3xr/NmU0F1DbImFV3e22nwodtmSq2pRNLqpGArznSvdV7/G9HQChr89k1bSfU9ra9LC2ONNd/XSkMfpoKG8Bq6GgLsTWtSZP2yFFm/PNW0KGe5qqXYuUH2rS+R7vUbJd1raIW+95tSCmVlWh/ZnNLFBKy6Xp/+Q0gw7Ha3ZOc4paDALd07uSQ1rVpycytMK7rDYZPSLelSuqWTLN1SKPOrU8x73MVVJgPqVkrf+jXmPe5bv9acSu0FsmTNAPlhQy9x2TIlO88lRb2d5qTrsun6bKzJBgAAYolgDUDcVaxZraB13sFamn6Vc++4zl1nWgRNdU8LRTK62L4uor1xVYps3WiX/EKXDNiVxa/1y3SFVyuotoEq/bJryS9wmLBtq71AspwbpYuzTBZssZvwJX1HoVFMlZWkSCev9dW22TvJdnue+ZKtwdqqErspyEqENrDSTSL//YdW2m3zacF1Ngkj/E26bMpUdrndngmd9Y0BlbWdZyqoK0Cw5lWxptWKehddF6uulXULdSpoRuMaa1rp5rSlNmkFbWWNNYdN0mRHG6qGMb4Va2lRaQVlKqh/+rui7dWb1zZUom1eZzfrJjZUIjqkyLlZdq0vkWJniXRyVTTbfqs9X1al9ZZVqb2kxp4lBZ3rpHf3WsnIqJa0dLdp2Tenxst2e8Pvps3mbjxv2I91nt8py5xv274jaDbXF9TLgEENl6ur7FJami4b1mfJzDV7yFznLjLQsVIGOZabVlH9s2Dfmlmyl8yVrSkFsqW2s2ze2kXm/tJF6uw5Yk9xmymjfXaql75DHZJfyLpsAAAgugjWAMTVlz5dYy2jacVahst8EdNwzVHT8MVbq2uUhmaZOc2/KK1dkiIzv8mQrRsbUjeteMhzVZiWowU/pstR51ZLlp/tOorqcptpo8txNbSCaluWfhG2Wm6Vvt45OfVSVpsvPZ0bJd+1zVxftjHFrGnUVhVrXRrbQOskTWpsmbItpVNDsObcbiprNCjITYBFzOdMEcmt3SZHV03wud4tTYO1huqv5tw+a51pgGTFWLrGmm/F2o7hBf5COm3bNPvRWKpxW1Ox1sq6hVpxlm0NTDBtpHafYKzVirV6kczGajnTfGpL8alYC2UqqJky6fbfCupiKqiP8q02s0basrlpsr3Ubv487OQqN22WfV0NrZb6c9NXXz9NpfbOsjG1yLRhb0/pJNk59dK3T4307lMiuXmx/3MgK9slvbJrpFfvGtl193JZuTxLli8bJAtrBptpxUMcy8xab/r50XPvdd90zbYtKZ2lZFmh/LKqWH7+Nlfyi5zSb6iGbPWmmi0RQnkAABDfCNYAxA2tLtNAwLtiLSXF5ZmWqMGPpxW0MVirrRHJzGk+cXHCW5nS17FGdnWslTRxSCfndtPKpFPmJqw9WKZ+lCmjzvGtkuhIyrc2BCI57h2toN5toJZO+fVSVpbfeN9qE76UbrK3SbCmwcm2ErsMaBxcsD0lz5S6aNWaOTatqHG7zX1y8+N/Ua2Z34nsU7e02fVNI0FdH8xvGOa1VppWjnm3VOp6hMpkBG63pxXU7bT5rViz1klzas1bY7ilv1NacabhWKDWOe811pxNgjGtfVs4I91Mi9Q2vL100mlq8+2t49ZtNVzzbO/W4QUSNKsqz/t1sY6DNdYahg6s/DVNls5JlY2rUs3wkb71y2Wv+vWmrdtan6+pcluOCdI2phRJSWqRWSstM8sp3XvUyPC+W6RzZ0e7hVGZmS7ZeVilDNm5UtatzZTlSwtlQmlP8+d7N2eJCQq7OkvNn1Uq110pufWV0q9+jbhrRTandJW1a3vIko09ZO7kHMnJd5mQbdAIhwnZAAAAwkGwBiBu1FQ2fFvzrljzqaBKd0ud7GgFVQ2tazvu46gVmf5phlmLZ2TtnGaPoa1DI2tmy8TFB8nmtXYp7OXqsMGaBhm6dprSSj7vNlBLp3yHrE3ZMSBA2xi3bmybgQHaqqpBTKfGirVt9jxzvt2e61lnTxczL9tsl16D4z9YUxryNruusbVy4cw0GbKnQ2oq7ZLf+Pn2pmGblSHVVO2o7DQ/232nc1oBlZ57DxtQWq1k/f7U6gLzjeFYmjT+TtXYJCvXHVSrtoZrllR3vWS4aiVv2XpZsqqH1FZnysEn1/hsr5V23sFcvVcrqb4OrVXM+eyr3rf6zqdyLsQhCMlCJ86uW5oqqxelyuqFqeJ2uExV10GO1dLdualZ26xWHJbZO0lpSmdz0vXSquw5kpLqksLCOtm5uFqKupVJbm58Td3UFtPepmquRraWpsraNVmyeUsfWVo20FQxZrpqTMBmBW1dXaUmdLYq2vaonS+l9nxZW9tTVm3tIb/+kCc9B9XLbgfWSff+8fVcAQBA/CNYAxA3qrY3VFFZC7tX2zNNpYRF11qzKtZMqOB2m4DB25I5aeKocMnw2l89IYwusq0thLoI/iDHCunm3GwCosWzs6SwVyurtSepbZvtpjXWotP5inPr/VasLbQVmMqith4YYE0E1RY1tb2xUk3XWPMcn6tctpU0VNTFM6u6yqq09KZBVYGzTH76JFc2rEiRqnKbdHf7BlLKu/JM2191cXmL92uiAZWuP6j0XNfO8qbrsnmHY67GNd6soE4Dbg3WtOpLW4YzstySltFQQdgQ6NV6gm9drF4HGGg1qG5/cPU06eLaJgMcK+WbOYfJQSfV+IQUtTW2HW3cjaGetvhqSKq/n9srgk80tA3Yer5Nw0sNZFtbfzEZ6Huia0euWZxqTvqPBeJym7XS9qhfJ70da32GDmjz8IaUYlORpi2SWv2p74EOh9FKtN6dHVLUbYt07uIw4VUi6NylXjp3afgzQj+zZVvTzJpsW7d0kYWlxVJbmyLprlrp6dwgvRzrzfpxWtWon9Muddtk97oFsiGlSH5bOES+WlJo/rFlt4PqTKsoARsAAAgGwRqAuKFhgd3tlMzGL/jVtizJzNpRUZaZ6ZRqe8Ni1/rFSMOBSjNN0ekTrPV1rDZf1NXUzH2lXFsITcuYU/qYL5oO6V2/TpYt3Fn2P662Q3552rZlR7DmbqxYG5TX8OW0abBmtV92dW2VAtd2WbmpbQYGlG5MMa2q1mL3WrGWoZ+B6ixxSIq5Ps9VLmUlXSTeVW23QqDmwVoP5ybpUbXJBMATfznQRGhZrubBmnd7qIbQ2Y0BtIYlGox6B2eO2jRP+GQNOPDc7nZ6Aj4NtxyNaxlaj2l+D1NsMuHtLDM8QoO1g0+uluL+DcMJdlSUZnjO07VazV1rwgqla+Cppp8TrTBtOpxEA/R0l1Yf1sjG8uDTHG2HtZ6vN09wV2uTzOz4X3sv1Hb5LetTTICmAwh0kmd1ud1UaHWvXyODnRuluH6T53fGopMzdYKnDh2os6dLQWeHCc8GdC6Xgi4OM40zGf4c1GUDuhY6zMkzrKEiRTasz5D163rIitJ+JmjuUb9RetWvN+f6DwbdnSXSvbrEvE6/rRwiE9b2kE6FLtntQIcM3N3BVFEAANAigjUAcUPDAq1a8fysU+e8KtZ0EetttoZgTWmwULkty6d1UCfc7Vq/zvysQYWGaocftVmyspzy5afdZH1qsVlvR79U/VIxTErW2KVbn47XDrp9s136NFaC6fpqWrXibyFyXXctNdVlBgZosKYDAzTQ0Nc6r3NsQwutxClwNgQ1qiwlX/r0rpFlS3KkvHEyqFasrd4c/5NBK8oaAiNtXQ1EKykbV0nz+T2w6FpiViuohl/FjRVrWo2p62B5B0tWsNbQCuobPOnKZhle4VZV40TYNKmXTFe1GRihC93LhnIZVTNbFjsGyfTPe8nRFzSsx7ejYs0K1tIlz13p05pqeeWePDOJ8YATaiQ1Tds3bV6hXrrn+POl3IRDWq0X7Hupbd+q6fPz7F/XX2x4aglLqwfXL08xlYwla1NMFWdDq2O1dHGWyGDXVimuL5HOjYGmtypblqxM6y0rU3tLeUonyc2rl0F9q6V3n23mz9KOQD9H+ufa4LwqGbxTlVRX22XDukxZv65IftjcS+wup/RzrJGd65aY9dj0z5QDa34y68z95hgi0z/qLTPHp5tW816D66XnIGfShbUAACByBGtRVlpaKh9//LHMnz9fqqqqpEePHjJq1Cg54IADov1QQNJp2t6mFWvds3ZU+GRlOz0hgMp2VUvl9h2TC3RNIQ0VGgIKkTWpPcwaYZ06NXzxLiyqk7W1PUywlu8ql1xXhaxemCbd+jQPBMJq9XMnRuuZtkvplECrYs2qdsr10wqqX0zzOtVLWVVDG6a2gmryodVkeZ1jO3pRJ7r2blxfTYdOaGVVj57lsmJZtml9NJNBXeVmXS6tCMuJ48mg+nrrmnYtBWvevH8PLLbGijVThbNNK9ZqPAGKw+uvcw2atForUMVapqvWhGhWOLbVvqOVtouzTDavLZSVC9LkxOrp5nj3q/lZ3t7WRzYsb3iMzCZTe+saAzYrcLP0cqyTPvVrZfavu8kvXdNl6D4Nv8s7KtYawr/qxu01TNTgTQOz9MzWXyOr3dVqBdX96JqBVsWaThhuPhoivmkQunFViqxflmoCNR0EoWvi6dTObs5SGdo4wdN6773pM91i72L+8UBPWmWanuGWXr2rZY++m6WgM62NWVkuGTCoypxqa21mAMLSxb1lWUU/U8U8tG6xCSk1KN6ndrbsVvebrKrtJRtmFcuUuV3FbbOZVtHeg+tN0Na1J1NFAQAAwVpUTZkyRa677jopK2togbG88sorctRRR8lDDz0kGRk7FpgG4Gv7Frvkuio9i2rX2DIkO3vHF0itOqu3pXnWZMp2V8m2xumWatXCVOlev8GzQPe61B7Sp8eOL/s9etbI/A3dzL61lVTbgNYs7i97HxV+sKYVb9M/zzSVcja7WwYNr5d9jq4JKhhoL+WlDVUv3sFaRobTDIfwR9tBt21qCF+0bSrXXSFb1qVLv6GxbXnbXmqTgsZKnLLG8EePRcPSrTX50r9+tZluqMFDyZoUydG21TgeFpHjaqj4apXb7b9irfFzrW2AGiZqwKIq7dlNKtbqPW2S+jo2XYPMhKONtAW41p4plbYsM0mxi2urLF1TbG5rGgJuLbGb11pbNq0WTs+5syHo9qaVP9bxTP7+AOk3rN4nmNvRCtpQdWq9Pvr51MCiNWbQgdvtCQ61ijHLWWteOz1ODR+L4ng4iYbxutbhlnV22bwuxZy0StNV37B2oE65HFZfYv6hINAET33fdNLlhtRusiG12LymOTn1Uty9VoZ132r+MSFR1kpraxkZbhkwsFr6D6g2AduSRd1k/Nae5nXXgK3Yudl8lnZ2LDUnbT/flFok65cXy8LVxTJ7Yo6kZ7qloMgp+YUun5OG/LzuAAB0HFSsRcnixYvl6quvNlVqAwcOlDFjxkjXrl1l5syZ8uqrr8r48ePllltukQceeCBaDwkk5bpfgxvDHv3Crwuj6zQ6S07j5XJ7jnQ1bYAVsrKxDbC2WmTjyhTZr369uY+ulaNf2Hv0bKheU/plc44t37SImnWt6jfI4pJBpq0xtyD0ypbN6+zy5cvZUlSzSQ6pWyY1tnSZ//MuUrYpW469uMqs9xOPtNVPgwfvYM1fG6iloMAha1LyzVpeGu4U1ZfKxpW9Yj+4QGxS4GycCJrSSbKz6yUtzW3WhtqyuWFdNWugwqY12dJ/1/p2C0hW/JIqy+enmddWP495BS4p6OaSzt1ckpHtlnlTMqSnqzSo/Wk1mb8gxe52mbKkRT+nSyfndk/739rUHuIyK7A1vD+6eL9WrGkrpFZkDmwSrOni7RYN5ZROhMyp1/bCrTI/wDpnpevtZs07K+CzKh2tc+9hGN50GqXSEEnXOcxxV3p+x70HL2grnt6uAV4wwZpWpOlrZR2PLsavIZT+rG3i5aXx8b84+vnQP2P0+es/Huifc9tK7KYaTYcsaCWjrl3Y2blVBjnLzHPwF6zqkAhreqc52QtMKGqzuaVrYZ0M6V4jxd23tfi7DPFbldurd4307FUjm0vSZfHCfJlUcpB5PwY6Vkr3+o2mQlDXretVv8GcpLZhzcdNNYVSvj1Pypbnymp7rtToUgU2XaPQLfldXZJf1PBnQEE3pznXv2fiuWUdAACEJz7+rzMJ/POf/zShWv/+/eXtt9+W3NyGLxpHH320DB8+XK699lrTInrOOefI3nvv3d6HC8Qdbf/SNdZ82xPdkuPVnqjrfaWkuMx0yIZgbbtpddN1mXQinq6Xo1Voam1qdxPEmMX3G+kghPwCh6yvKzbBWpFzi1nIevWiVBm2b/NF5VvirBeZ8kGmFNaUyMHV0xtXxhLp6twq49ceJrMnpMveoyJvMY0Fre7Sihit2lNbUwqkoFPg59+1qE6ctnwptXeWQlepqej4cW3fhmqohqKjqNN2uAxXjQlyzDHaCzzvZZcuDllhz/dMKu3qLJWS1TumYrYVnTq56rdUmT0xXbaX2Mxnb6BTwzO3VJTkSsmyTrLEnmdislRxeKrv1LqUYilJLZQRtb8E1QZqtYK6XDZZMitV9q1b7AlbtO1Pv61rO2iG/tddL1VVNpnyYaZku6pk79q5PvsxwYBZ2yzDVIAqDWr61K8zraAabumaez6P7XbJptWp0qsx6FQVjYGaTt5V1sCQQDT41opU63fFCuS0ZbHh+TVU021emyODR9QHtf5YuldV3ZaUHUMsdC3AzesKpS1puO8JzrxCtPItdvO+WZWIedpq6CqXPjqV0tmwTqAVDnrTytqSlK5mguemlKKGqk2bzfy5pgMHBneuk85dKs2fafEa4icSDbyKutWZU9nWVFmyOEdmrx9u2pP1c9m9fpP0cG40f97o+6Vt6Hrypr+D+vug1ZPlVbmybV0nWWjPkypbtnmA1HStcNOwzWmCd0+FWycCNwAAEhnBWhRs2LBBJk+ebC5fc801nlDNcvzxx8vLL78ss2fPlrfeeotgDQlPKzCWz0uVlb+lmoqLwh5O2WlvR1hVXxadcKdfPDu7tnqqWLJzNEhrvhD19uo80e4v/fKs2+iUvKVz0qRX/TpPpc+atF7SvWfziZ9atbamtLtI7Tzz5UiHGCyZ0yvkYG3OpHSpLHHJwTWzTSBgtZfq2jwalvw8dYT03bleinq7/H4BXzwrzRy3tl/2HOiUPkPr2+zLsYZWhSYAajhu/cI+oOuOwKQpnRiobbibagulsE6Dtc3idjYEJb2HxKY6RteY6uZsCICUtrsNKmwIUTp3rTPVjBoIajiqz2X5hgFSp2tzRbnbXqvPNLitLLNLeZkObbBL5baGc33/6qpF+tavkQNrF5qKK3+sSjKLVhp9n72/qYjx94BWi2dT1j6G1S0y6wSqZWn9PSGYhmQZbocZIqDhqY4pOLJ6asDntiGlmznv3bdaNi1vCKG0AmzXut9kXsauzQYC6HpsGqoqDQ+07bB7jxrZvtb377xA1i5Jlc5eVW0arBUW1cqWTXkmkNDH7lm/QRbOHSrDD6mT7Dx3EJNtK31e1wpbtuS6q6TYuUlm/dZDtm2uM8FFJO+/rntWW2UzFXJ6XlNlM22muiak97lpTdVX3V1vAkT9R4Kergpz3qlxTcem0zq9aVCsn2mtvNuY0k02p3Qx721ensOEPTsVlZlqzYzM+G1vTRa6Ht3IfbeZ9Si3bE6XTRsyZM3GAbKwYkjDOp71JSZk0+Eq+v5af+/oZ1j/0UdP3rSNVP9BaFtNJ9lWkSdlKzvJKnsnqW2scEtNc5sppKbKrTFs06pXrXbVqbyp6fE9nAUAgI6OYC0KJk2aZM7T0tLkyCOP9HufY445xgRrEydOjMZDAu1GQ5kfv8iQrett5kuwrpe0dEk3mT8tR3Y9oE5GHBpeldaGlSmmOkkXH7eCFP0S6a8tccuWhsqUdNEWqm2y4IdcEyQc4ljdsK29s6mm2bvvjjZQi7b7LPqt0FSCaCgzwLFSJqzvK1vW26Vrj+C+sG5abZf536fLnrVzTcuZfv2fmH2Q9HaslZ0cy0z70JrUnvL9R13lhLFVZhqi9SVdA7Wfv8kQd2WdFDlLpNKWJkt+7io5nTNk32NqpM/OsW3j0hBgy7oUGdpY2adVPhpSaRVYIPqFTtdq2lReKLvIIrP2VmdXmaz4NTcmwVp1RcMkxH0bK6vK9AuoPUMKC8s9C5BnZjqlpLbhPdRKEnG6ZOWvqTJkz3q/QbBWRGp1o4Yf2ibpqGlY/F5/brheGq6v3fFzdWVDiOZyNnyj1Uoufb+1CkzXAxvmKjfVX1ZVnaqw5Ui9hiGuCk9FYNNqJB2qobzXRbMcWj1VUjS19EObPfs6VpsF1dXGlCKZnzFM+g2okpXLsz1rje1at1AWpO9kfjeafsH3Ni9jF+nZq1oGDa6Uiau6yvK0vjLAsUqG1i0x1WzedOBArWiw1vA7pe3UOi1WQ7kZ6wo8wVggWvFWVW6XwY2hoQ5c0CBw4OCtsmVzgWln1TXzdqpbKitq+sjEdzPlmDHVLa5TpWvNdW9cL04HKWjwty61u/kd1EmPS9IGyAf/6SS5BQ2BRafG08auDRWn28rSzLm+v/V1Da2lnvCs8Vx/tt5/7+einwOtLNTz4sbznMYwTdeqa42GaPqPB6UpBaZaUCsyTUuszSYZmU4pKqqTEd3KpbBbnfm8o33oP3Z0K64zp92kXCorUmTTxgzZuKGLzNrcQ5xOqxKx2ixNoJWIDUFqQ5hqrVOogapOVdaTN10vVN/38pRc2V6ZK+Wr82STPddUuOmfyxZtLdWAzQra9JSWISaQS0ltONe/Z1L0PLXxXH9ObTg392s8974+EYbtAACQCAjWomDRokXmfMiQIZKZ6X/F8t12282cb926VUpKSqSoqEjinf5LrVYY6GLKbTFYLRoPkdM4MLKyqoX+tMQaEtcqDWvailbo6GeiuH6THF07z3x5UK5amyxL6yfzpuwiK37NkWF7a9AsUlmZ7jk+c+5ufPn13G3zXK9LRy34MV12ql/tqfDRqo1dujZf7F0DnlXLCzxtgFpx9suKYVLgLJPujdU0K9P6SF4nh08bqEWvK+hcJ8sc/UwoU+QsNZVDk97Ll/67OFr8Iq8Bjbaf6Vpa3R0bZZBjhbl+SdpAE1DpdEVdT0pfl5E1s+SrkiPksxeypc9O9aadaO3SFNm20SZDHEtll9rfPNUrOvFyXv0u8u0bvaXHQKd06+Ns6Ivzfu2kyWWv17Lhepvf27N0XXi3SHV1hvl506oUyXTVeNbZ0iBCK2K0OrAlRcW1MmtlV7OOnIapg+uWy09z9pSMzAxJy7QOYscx+jtOn2O0rvfctuN+Oggiw1lj3lu1OrWnqZjLL6j3BH3de9TK6speskvdItOC2NexRqZ+3E8W/+w0f3ZplZEGZ44am6mq9EeDMm2b1DXJdIF9rUQxJ6mXTm6HFLnrTICmwZmGaWZR/ACvz2Z7F5mXMUw2pxZ6whetWmpouXWaEE3DH/3CXGPPNAMYqsua/zmlC6YHUlxfIju5lprLur7T1Kx9pGffWtl9xHYTrGmlmkVb13QB9kB+ytjDHMeIvTaadesGDamSOYt2M7/bup7UHk1aVPU16uIs9fzOa2ui/i7qSb//6+fIqqLzR8MGrVDrXb/W/LwhtUhSUtxS1K1W+vStlvnLh5n3W1/7fWpmy6SVB8h7j+ZIYS+n2b++53rSz4n+Dm5anSJOh8hejQGx1Sa5JH2A+b3U/RxZNUU2pRZKeW2ulJfkykZ7riy154rTrMvmkhSd4mtWp9PPgdOEh/oa5rjrpHNj5Z9eZ11vXdZAP1gaIOrzbmgPtE55Um3LNMebluaS/M4OKS5wyE6dy6RzZ4dpWac6KT7pOp8DchumiurfB1VVKVJRnioV5XqeK1vKC2Rleao4HA1/kaS7as3vYsOp3FRZ6xIG1p/9+meXttfryZtWEutnRKtC9aR/dtRVpu24bP4s0Sg9RZw2/ROm4dz7Z/1kt/ZB0oE7VhCXkanBm/7BmC2pqW4Tulm/e3a7e8fvYZNzc5v1s9fvql5v/YFpzqzLTc6Duk/jbQ33cbe4vb/9+G4f6D4Nxxt2YWAEFYXem25qXNWgvDyIr2hxWMVYNKq9jwAA2gfBWhSsWdPwZaJnz54B7+N9m94/3oM1/fIy4e0sWbOwofKiLdKo6Pz/ge8Uu47A1oZJof6r/MF1y836ZBZ3Y0XOYMcKU73zq2MnWfxl54b/qW9yfNbl5ucixe56+f/27gNMqvL64/hZyi4dRUSQIiLSixQpGhRRscZeogYTa/wnGruxRY0aWzSxlygW1GAXFSuKFSwIioiIojRBpPdl6/yf37u8493ZO7Ozs7PsLvP9PM8yw957Z+7efs8973m75M9x/9dNemFWPddsM1bLVnlmdbJcRpgyXBTgURH07nklAe7crBwXWOu+88a49xQdOuba1yt3tD5ZM12G3ODcqfbFL31s7tLEXXnq79QtS8eiVdY77xv3O90wK6DSpdt611xoSnE/G7HxQxecGL5xks1Y1N3mLW7kplOB8sH5c6LBCU/jDt40zTrVnW+zv+9sc+dsjhDHLLew/8d//2uU7dfFoPmP2ID8H91tl27eFtRrazu1LX+/UY+qM7Kb2Y/5HV0wa6fChbYmv5ktmry9RWL23tj1G/a7svPnPyXimh3ukT8nOo/z6u9k7dvnllqf7XfKtXlzt3OZU8qi6pc3wzXFzJub4zK+tLxd0GxzoKwkaFb6/z6jLBWaLxX+X1a3pQv86XW77QtsSNeVLgiom6K1a+rbunUtlEznMkV2aFLHGjUutibNllvdOhGbOGF7W1y3te0YaPLq/Vy3lWsWqGXttdicfaYb6w8bDrHm20dst/5rosHgmdldXbaa7Jn7mcuikm+yu9jCem1dDbg++d+4JoYL6rez3QascUE16dZjnS1etL1NLh5k+20sycIO0rQ7bc4IVSBYdd36tFlv2dkRa7VDni34qV3CwFqP/Nnupt9nc82r18FNp4ygHr3W2cQlLW16YS8bmPelC3gfsOFdm12wi+WvyA5sGSXRZgV2u0TyXEaexhX9ferZNj/SyCYVD3J/v4IW7TYHZ6uKlp4CIAqYbqxTEkQL/hRllVxmZWcXW5OmhdakSaG1bFpgTZpssibNCl0za4JotZP2O3Wu4zrYKUlCNX9Yy8+rU3IMWFvP1q1pbCvWNrd5a+tZYaEeVJZ0rqGguzLcSjLdSjLcfHBcxyY1abZANmwq22ZYwM2/KrO25EgZPo7O4drnirN01C750UOvkvdmkcDvy/wEhpWep+DZKHaYbN7XA5GvmLNa6fEC72PHDZ+uJJIWPl74fG15PnBfNqPZaty8ljWEwBqADEVgLQ02bCip8dK4cUkPZ2GCw/z46Va/fl3bfvv0FPBetsjsp+/M9s6dFG36AwQpoPRFTu+Sm/+82bZrwY8uoye2UHpF6SJXzdjatiu01q3DC2Z13LnAvv2+s7UvXORungdt+iI6bFpOX2vQpI716a0mMkrXKqtXT7Mfv8+yaYV9bc9Nn7m6aHvlflzheVWA4+MGu9u2rcx2H1Rka9Zsstde2da+yunp6qwpkDYs99PQadW5wvScXi57TOOqiZDLoMstCRRsCbOzO1th/Rzr01fNzcKXVVDPXvn29bTO1qFgkQtihRXeTzc1dSzMzrG+/UrPY/NmZj+0LbBpC3q7zCRtB90TZGhVhG4egxlmCqBtyGpoG+v4941cs0NXG6lexHZsW2j7ddtgrVsrSFJS26yEslLCMgFLbph26Zxvn37f3zUdDi5LNX2d3HDQrz2BZplrnul93GCg1W3WwPbdf73l5JQskx498+zbr3eNBtZ8UE3z+m32ri7AoyZnKoSvzgI67FxoPXsqOPDrMt1nRK69Nn5b+7RBfxfo9XUD3edvDvBp/5zaYDdr1EzfqaZyDa17zyJ77+dW9kvdlnEz7tQxQrAprDI8D+i7wZo3K/n+IUM32QfvdrAWxSutU8ECt30leyxRcHV+/XY2eGCebduiyN5+c3t7J2svFwhUDTYFL4K9mZZHHUKUyhSKea9X9TisbDQ1mfVN9tQ7Z9OmxdasebG1ba7XfGvWfJM1b1ZsOT6rM0rTVFHPH7WQ3w62Jq1cCUOtdzWzL3ABtw0bsmz1qrq2alVdW7tmW1u5Zjubt6auFWzOqlWGm4Js+tH59NeMyQLXUUfJq7Imy68Jqk+stzkbM2crzNZHTXV4dc8AAFQLAmtpUFhY8nSpboLK46q/5hUUVKxIenVQEwdRMxkgSDWkvsvu5Iqmb9MiYu1bFNmMub1sQUE765U3yzUxTPUZqurNTG3Q19blNLe9di+d1RW0W/9N9upPTW1yZHcbsKmkzpkCCNNzetqSnNa2/14bXN2ZeDRsz2EbbcKbre1jG+iCGj4QkSwVfp/WoLdlNW9oew3f4LIXtt222Pb4Ta5N+nAXl8Wi5eGyDjaLbK4dp8DhL/VaWbv2BVaQn2MTlwyzjoULrFve9y7IV9UUOFL9qW+yu9qAAZusYcPk7rh69MqzhQsb23uRPWzApumu2WtVPS9XEXoF/rSd7Tk0N3Qeh+6Ra2+sbmLv2m9cRpR6WFS+RTAbQwGygqx6Ja8WeK/XUv/X8JL3sU2olJXVuEmxNWlSbDs0Kd78fqN7bd68OOVOJ4bskWsNGuTYzK8727z6Hazfpq9sU1YD+zqnm3XoVOSCNDO+6ukyXJoVrXO9EX7VoKetzGlpB45QUO3XZdJ/4Cb7ZmZztw9oe9YQ1S1TkLfZdll20CFr7Jdf6tmSxQ2t57abbOdOBWUypbZrWWTDR2yw9ya2dxlgslve19a5YK57r8+cltPHluW0spHaxzb/3e3aFVrHXQrs4x8G2ZDcKdEm2Qo6fZXTw9VtU1NaZeOoCeuUBv2sSzfVrfo16LhTx0Lr2z/Ppn7RzxbUa+8yQmPrUXnKrNFnK7i5rN52NjO7m+20S5Ht0rnkbzrwkA325bRGNmdZD9u4cXPQK1JsjSMKsm2wrEjEirJKGoGqcwAFELWtlATQ6peqbSVq0qZlHf1pELHtGxW77MPGjTdZY/dabA0aRhI2JUdmc53vNIm4rMV27X9tTqyAW25ulq1ZXcfWrqlra9Y0sbVrm9ma3DqWl5flflwtt6BIxNVULMnOVbNm9T1cZHUjxSU5aNGs3c05aYHXkvGiuWpxx1FQv2wuGpDYxuX51qglDw0AZJ6sSGRLVmjaOp166qk2adIkO/TQQ+22224LHWf58uW25557uvePPfaYDRkyJO3zUVBQZKtXp95sIEhbxdv/a2g/z1Gva+u3WHNDLtsqs+yqnoqNK1NDTd3U9FFNKnWzsG5tXZvxVTNbvjTH6kUKrFFxrttmfm3CJWoesvnVSr/6cRRUyG4QsQGD1rjaTYmsWlnfpn7W3DZuUK21QtNtheqEqXlby+2TC14vW5ptX05tbrkbS7bz8gLJus1QE5mSQExJj4h9+60t00vf0l+y7asvmrl5U8BOTRJ1I5+fleOmbbFdvnXvuc62a1mSxfDz4hybOaOZK5bfwGUpFJRafsFlGP1/maYyybwv4YJHdepatx7rrXOXDRVqipafn2VfTmtuSxY3sPqRfJdx5zOBYtd3cD7D5ive71yzJPVw2aDIevdZazu2i99UNTe3js2Y3syWLFZORvw/RIX21RxTNa2UZaYmkPXqF1t9V8S72P2/9O8iVj+72Bo1LHI9t6YzK2fN2tJBXD2bee+dlrZxQ0k0WPtW1+7ro+vli8+b28IFJdNq/gbuvtpatS67f8yf29CmT2vmmmwrQ01BorbtNlnv3da64GCyVJtOBdq/ntHU8jbVdbXolD2jfaygXrYNGLTa2uxYep2ort0XU5vb4p8CmUfauEM2Lu03+oywgKT266+/amqrVmYHmgqXfg0GPuvWLXb14YLLq/SyzSqpf7VedbDquZpY9evVdd9dWFRSU1HNclUcXstITTa13vWanaPXiKsFR3PNqhFvn0Dsdqxjbx0ryK/jXvWjup0KuGnfK3mN+YkOL/kpDhnP171MWiRO4894v/91Ql8aLfRKL5nyBmWHlS5AmuisF6/haPyGqltSaueXX5dnzbLvGY1shwHNq3s2AGCLI7CWBueff7699tprNnToUHv00UdDx5k9e7Yddthh7v1LL71k3bp1s5ocWBP1lrbwu3q2fIFZwdqq7akwXRrklNyYbspLvrh0VVLToK2JbjC32bbAWmwXXuR//fq6tmJ5tkWKSgrl5+UVRpdB8Mb018LEm5fP5oLBDRsVud7XFPhINrNy+bJs25Rb1wXVFLCqaMaIPkMBNvUQWFyc4LJaMQJ3A64M1GLbrmW+NW1aVO68rV1TUldHyy46XbOi0PEVVFi9qn5J4WstglLLLHCTELIsS/4TcyOSZdawQUm27KY8BetKKHDQsmW+NWyUekaqghVabrrBi705i13nofMYLBIdU1RaGUKqR6VeYZNdnwoGafvTOtSydj8+gFavZgRGkgkihMWi9LuS7SLLLRNfFy3MyhX13Y/+fu1LKrZeGVqmq1fWt9xcVTIv6VVXdcHiydtUx02j7ULBKc2vfrdpU0kwoEEDNZHUcSH+d+rv1X6zYUPdkm1rc8cWvtMLBbsUdM3JURPL4grv8wRzag7WRfXSeccH4Ro1auAC0WvW5FlRUR03rKQjGgXgAp3ORLKsOPr/X4cFxwv+P6h0RzyxB7qYDm0sbNqYTnrKm9Z3mBMYFn0p3efO5jepnSjSeaWXXb/kiUN+QVGVfWlVXpmO2DefwBqAjERT0DTo1KmTe50/f37ccfywOnXqWMeOHa02UHO5jj0Krc2Ohbbxp9rRKQAX6dWrpJBzrqt/tSWyEHRDreBBZT9jh9b57qcq5i3Z+dP4ymDTT23YJ5o0LbImTWtOlomyyrZtUTMC6pURFnDS7xSgSoaC3vpJl2hx9iQp0BWbwakAbkWCuPp71QOs7wUWQNXQeUcPMhSsb9q0JHJWpy77XXWq/dexW1+9RABIBtVA0qBPnz7udfHixbZwYUmvabE+++wz99q1a1dr0CBxz4MAAAAAAACo+QispYGagDZtWtIb5//+978yw9etW2evvPKKez9y5Mh0fCUAAAAAAACqGYG1NMjOzrZTTjnFvR8zZoy9/vrr0WHr16+3iy66yFavXm3NmjWzE088MR1fCQAAAAAAgGpGjbU0OeOMM1zPoFOnTrXzzjvP7rjjDmvZsqXNmjXLBddUW+2GG26wbbbZJl1fCQAAAAAAgGpExloas9YeeughGzVqlDVs2NDmzp1rU6ZMcUG1XXfd1UaPHm37779/ur4OAAAAAAAA1YyMtTRq1KiRXXnllXbBBRfYnDlzLDc319q0aWMdOnRI59cAAAAAAACgBiCwVkUBNt9TKAAAAAAAALZONAUFAAAAAAAAUkBgDQAAAAAAAEgBgTUAAAAAAAAgBQTWAAAAAAAAgBQQWAMAAAAAAABSQGANAAAAAAAASAGBNQAAAAAAACAFBNYAAAAAAACAFNRLZSJklnqN6lijttlWGzRt0ci9Fqwsqu5ZyWish5qDdVFzsC5qBtZDzcG6qDlYFzVDbV8Pfv4BINMQWEO56mbXcT+1QaOW9d3rhkjJK1gPmY59ouZgXdQMrIeag3VRc7Auaobavh78/ANApqkd0RIAAAAAAACghiGwBgAAAAAAAKSAwBoAAAAAAACQAgJrAAAAAAAAQAoIrAEAAAAAAAApILAGAAAAAAAApIDAGgAAAAAAAJACAmsAAAAAAABACgisAQAAAAAAACkgsAYAAAAAAACkgMAaAAAAAAAAkAICawAAAAAAAEAKCKwBAAAAAAAAKSCwBgAAAAAAAKSAwBoAAAAAAACQAgJrAAAAAAAAQAoIrAEAAAAAAAApyIpEIpFUJkTNo1VZWFhsmax+/brutaCgqLpnJaOxHmoO1kXNwbqoGVgPNQfrouZgXdQMtX09+PkHgExDYA0AAAAAAABIAU1BAQAAAAAAgBQQWAMAAAAAAABSQGANAAAAAAAASAGBNQAAAAAAACAFBNYAAAAAAACAFBBYAwAAAAAAAFJAYA0AAAAAAABIAYE1AAAAAAAAIAUE1gAAAAAAAIAUEFgDAAAAAAAAUkBgDQAAAAAAAEgBgTUAAAAAAAAgBQTWAAAAAAAAgBQQWAMAAAAAAABSQGANAAAAAAAASAGBNQAAAAAAACAFBNYAAAAAAACAFBBYAwAAAAAAAFJQL5WJgKqwcuVKmzZtmi1ZssT9f/jw4dauXbukps3Ly7MpU6bYzz//bI0bN7ZevXpZhw4dqnzardXcuXPtyy+/tA0bNlhWVpaddNJJ5U5TXFxs33//vS1evNiWLVtmOTk51qlTJ+vRo4fVrVs3qe/VdF988YWtWbPGtttuOxs0aJA1adLEMlV+fr7NnDnTvv32WysqKnLb5V577ZXSvvXaa6+59w0bNrSjjz663Gn0nd99950VFBS47+3fv3/S63FrpG1Sx6dFixa5/++555628847Jz291t/06dNt4cKFFolE3DLt3bu31a9fP+F0Wv6ff/65+16tu27dutkuu+ximUzLcOrUqbZ+/Xr3/+OPP77c5ejpGDV79mw3rY4tXbt2tV133TXp/Ujfu2rVKmvRooUNHDjQttlmG8tEmzZtcstR5+vVq1db06ZNrWfPnrbTTjsl/Rl+XeizdK7X8qxXr16VT7u1Wb58uf3www9uXeic0apVK+vbt29S22ZlppXCwkK3T/z000+WnZ1tXbp0cftUpvrxxx/dsvjll1/cMal9+/buOK9lU1ETJkxwnyO/+c1vrGPHjgnHX7t2rbuW1XFK60/nbF1HAQC2jMy8CkGNoYu5e++919046uIuqG3btkkF1p599lm75ZZb3EVF0D777GPXX3+9tWzZskqm3dp89NFH9swzz7h1sWLFiujvFUxJFFjTheTdd99tkyZNcjdYsXbccUc7//zz7bDDDov7GQrg3XDDDfbiiy+6AISn4NwZZ5xhf/7znzMqqPPQQw/Z+++/b1999ZW7cfQOOOCAlAJrWravvPKKe69tOlFgbc6cOXbppZfajBkzSv2+devWdt1116X0/bWVblDuuusut0/oRl4BMU/HjWQCawo4/+9//7N77rnHfV6QbnpOPfVUO/3000OnHT9+vDsOKZATNHToULvxxhutTZs2lim0Dp588kn3unTp0lLDjjzyyHIDa3pQcPXVV7uAcazu3bvbtddea3369In78OVf//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+7duyecbvbs2ZHx48dHVq1aVWbY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jvfggw/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" = [