Skip to content

Commit 76bc2e3

Browse files
lukeshinglesclaude
andauthored
Split __init__.py into submodules and remove the nahar handler (#72)
Splits the 1400-line `__init__.py` into focused submodules, removes the retired nahar handler, and applies a round of cleanups. No behaviour change: the generated ARTIS files are byte-identical to `main` for every test set. ## Module split `__init__.py` becomes a re-export facade (with `__all__`), so `artisatomic.name` access from the readers, the tests, and the `makeartisatomicfiles` entry point is unchanged. The implementation now lives in: | module | responsibility | | --- | --- | | `base.py` | element data, physical constants, shared helpers (`log_and_print`, `xopen_check_extension`, `path_for_log`, `parallel_map`, `ion_log_path`, …) | | `levelnames.py` | level-name parsing: `interpret_configuration`, `get_parity_from_config` | | `ionhandlers.py` | which ions to process and which source reads each | | `phixs.py` | cross-section downsampling and the hydrogenic estimate | | `iondata.py` | `IonData` and the per-ion reading logic | | `output.py` | the four ARTIS output-file writers | | `cli.py` | argument parsing, `main`, the per-element loop | ## Nahar handler removed Deletes `readnahardata.py`, the `"nahar"` branch of `read_ion_data`, the `nahar_core_states` / `nahar_configurations` fields of `IonData`, and the Nahar target-fraction path, plus the `atomic-data-nahar/` and unused `recombination_rates/` folders. `makerecombratefile` keeps its optional `.rrc` glob (now finding nothing and falling through to its other sources). ## Cleanups - Physical constants are defined once in `base.py`; the duplicate copies in five readers are gone, as is a second import-time parse of `atomic_properties.txt` in `readhillierdata`. - `IonData` is a `dataclass(slots=True)` and the photoionisation target fractions are filled in place, replacing `NamedTuple._replace()`. - The Hillier-specific target fallback moved out of `write_output_files` into `resolve_photoion_targetfractions`, so `output.py` no longer imports any reader. - Dead code removed: an always-true guard and a redundant `else` in the cmfgen branch, the unused `chunks()` helper, ~30 lines of commented-out alternatives in the downsampler. ## Verification - 18 unit tests pass; ruff, ty, and the pre-commit hooks are clean (mypy unchanged at its 3 pre-existing errors). - All five `tests/*/` sets were regenerated after every commit. `cmfgen`, `floers25`, and `jplt` match their committed checksums; `kurucz` and `qub` produce byte-identical output to `main` when both are run against the same local data (their committed checksums differ from local runs both before and after this branch). - `tests/` is unchanged from `main`, including checksums. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 848b610 commit 76bc2e3

43 files changed

Lines changed: 1635 additions & 92023 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ output.txt
1414

1515
*.nosync
1616

17-
recombination_rates/phixs.pdf
1817
.DS_Store
1918
.idea
2019
.tags*

.sourcery.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ rule_settings:
2828
- extract-duplicate-method
2929
- code_clarification
3030
- low-code-quality
31+
- no-loop-in-tests
32+
- no-conditionals-in-tests
3133
rule_types:
3234
- refactoring
3335
- suggestion

artisatomic/__init__.py

Lines changed: 59 additions & 1387 deletions
Large diffs are not rendered by default.

artisatomic/base.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Element data, physical constants, and small utilities shared by the data-source readers."""
2+
3+
import contextlib
4+
import multiprocessing as mp
5+
import sys
6+
import typing as t
7+
from collections.abc import Callable
8+
from collections.abc import Iterable
9+
from functools import lru_cache
10+
from pathlib import Path
11+
12+
import pandas as pd
13+
import polars as pl
14+
15+
PYDIR = Path(__file__).parent.resolve()
16+
atomicdata = pd.read_csv(PYDIR / "atomic_properties.txt", sep=r"\s+", comment="#")
17+
atomicdata = atomicdata.apply(lambda x: x.fillna(x.number / 0.45), axis=1) # estimate unknown atomic mass as Z / 0.45
18+
elsymbols = ["n", *list(atomicdata["symbol"].values)]
19+
atomic_weights = ["n", *list(atomicdata["mass"].values)]
20+
21+
roman_numerals = (
22+
"",
23+
"I",
24+
"II",
25+
"III",
26+
"IV",
27+
"V",
28+
"VI",
29+
"VII",
30+
"VIII",
31+
"IX",
32+
"X",
33+
"XI",
34+
"XII",
35+
"XIII",
36+
"XIV",
37+
"XV",
38+
"XVI",
39+
"XVII",
40+
"XVIII",
41+
"XIX",
42+
"XX",
43+
)
44+
45+
# the single copy of each constant for the whole package
46+
ryd_to_ev = 13.605693122994232
47+
hc_in_ev_cm = 0.0001239841984332003
48+
hc_in_ev_angstrom = 12398.419843320025
49+
h_in_ev_seconds = 4.135667696923859e-15
50+
51+
52+
def split_element_ionstage_str(ionstr: str) -> tuple[int, int]:
53+
"""Split a string like 'FeII' into (atomic_number, ion_stage).
54+
55+
Splitting on `ionstr.rstrip("IVX")` destroys the symbols of the elements whose symbols are
56+
made only of those letters: V (vanadium) and I (iodine). Instead find the split point where
57+
the prefix is an element symbol and the suffix is a Roman numeral. Element symbols have a
58+
lowercase second letter and Roman numerals are uppercase, so the match is unambiguous.
59+
"""
60+
for splitpos in range(1, len(ionstr)):
61+
elsym, ion_stage_roman = ionstr[:splitpos], ionstr[splitpos:]
62+
if elsym in elsymbols and ion_stage_roman in roman_numerals[1:]:
63+
return elsymbols.index(elsym), roman_numerals.index(ion_stage_roman)
64+
65+
msg = f"Could not split '{ionstr}' into an element symbol and a Roman numeral ion stage"
66+
raise ValueError(msg)
67+
68+
69+
# The id-keyed transition columns write_transition_data() needs, so an empty frame still carries
70+
# them. Name-keyed frames get lowerlevel/upperlevel from add_level_ids_forbidden() instead.
71+
empty_transitions_schema = pl.Schema({"lowerlevel": pl.Int64, "upperlevel": pl.Int64, "A": pl.Float64})
72+
73+
74+
def leveltuples_to_pldataframe(energy_levels) -> pl.DataFrame:
75+
"""Convert a list of level tuples (or a DataFrame) into a DataFrame with a zero-based levelid column.
76+
77+
Level ids are zero-based everywhere in memory; the 1-based numbering of the output files is
78+
applied by the write_*() functions.
79+
"""
80+
dflevels = energy_levels if isinstance(energy_levels, pl.DataFrame) else pl.DataFrame(energy_levels)
81+
82+
if "levelid" not in dflevels.columns:
83+
dflevels = dflevels.with_row_index(name="levelid")
84+
85+
dflevels = dflevels.with_columns(pl.col("levelid").cast(pl.Int64))
86+
87+
# the frame is indexed by level id elsewhere, so a reader-supplied levelid must be contiguous
88+
# and zero-based. Not an assert: input validation must survive python -O.
89+
if not dflevels["levelid"].equals(pl.int_range(dflevels.height, dtype=pl.Int64, eager=True)):
90+
msg = "level ids must be contiguous and start at zero"
91+
raise ValueError(msg)
92+
93+
return dflevels
94+
95+
96+
def ion_log_path(log_folder: str | Path, atomic_number: int, ion_stage: int) -> Path:
97+
"""Path of the per-ion log file, written by the reading pass and appended to by the writing pass."""
98+
return Path(log_folder, f"{elsymbols[atomic_number].lower()}{ion_stage:d}.txt")
99+
100+
101+
def log_and_print(flog, strout):
102+
"""Write a line to both stdout and this ion's log file."""
103+
print(strout)
104+
flog.write(strout + "\n")
105+
106+
107+
def path_for_log(filepath: str | Path) -> str:
108+
"""Render an input data path relative to the repository root where possible.
109+
110+
The log files are compared by checksum in CI, so an absolute path would make them depend on
111+
where the repository happens to be checked out. Paths outside the repository (some readers
112+
load data from elsewhere) are returned unchanged.
113+
"""
114+
try:
115+
return str(Path(filepath).resolve().relative_to(PYDIR.parent))
116+
except ValueError:
117+
return str(filepath)
118+
119+
120+
def isfloat(value: t.Any) -> bool:
121+
"""Whether a string parses as a float, accepting Fortran's D exponent (1.5D-3)."""
122+
try:
123+
float(value.replace("D", "E"))
124+
except ValueError:
125+
return False
126+
127+
return True
128+
129+
130+
compression_extensions = ("", ".zst", ".gz", ".xz")
131+
132+
133+
def find_file_check_extension(filename: str | Path) -> Path | None:
134+
"""Find a data file by its plain name, accepting any of the compressed variants of that name.
135+
136+
Returns None if neither the plain name nor any compressed form exists, so that callers which
137+
treat a missing file as "no data for this ion" can say so without opening it.
138+
"""
139+
return next((path for ext in compression_extensions if (path := Path(f"{filename}{ext}")).is_file()), None)
140+
141+
142+
def xopen_check_extension(filename: str | Path, **kwargs: t.Any) -> t.IO[t.Any]:
143+
"""Open a data file, trying the compressed variants of the name if it does not exist.
144+
145+
The data sets ship some files compressed and some not, and which ones varies between
146+
downloads, so callers name the plain file and this finds whichever form is present.
147+
"""
148+
from xopen import xopen
149+
150+
filepath = find_file_check_extension(filename)
151+
if filepath is None:
152+
filepaths = [f"{filename}{ext}" for ext in compression_extensions]
153+
msg = f"Could not find any of the following files:\n {'\n '.join(filepaths)}."
154+
raise FileNotFoundError(msg)
155+
156+
return xopen(filepath, **kwargs)
157+
158+
159+
@lru_cache(maxsize=1)
160+
def get_nist_ionization_energies_ev() -> dict[tuple[int, int], float]:
161+
"""Get a dictionary where dictioniz[(atomic_number, ion_sage)] = ionization_energy_ev."""
162+
dfnist = pd.read_csv(
163+
PYDIR / "nist_ionization.txt",
164+
sep="\t",
165+
usecols=["At. num", "Ion Charge", "Ionization Energy (a) (eV)"],
166+
)
167+
168+
dictioniz = {}
169+
for atomic_number, ion_charge, ioniz_ev in dfnist[
170+
["At. num", "Ion Charge", "Ionization Energy (a) (eV)"]
171+
].itertuples(index=False):
172+
with contextlib.suppress(ValueError):
173+
ion_stage = int(ion_charge) + 1
174+
dictioniz[int(atomic_number), ion_stage] = ioniz_ev
175+
return dictioniz
176+
177+
178+
def parallel_map[ResultType](
179+
fn: Callable[..., ResultType],
180+
*iterables: Iterable[t.Any],
181+
**kwargs: t.Any,
182+
) -> list[ResultType]:
183+
"""Execute a parallel map with a progress bar using either multithreading (for free-threading python) or multiprocessing."""
184+
# use a thread pool if we have no GIL (free threading)
185+
use_multiprocessing = sys._is_gil_enabled() # ruff: ignore[private-member-access]
186+
187+
if use_multiprocessing:
188+
mp.set_start_method("spawn", force=True)
189+
from tqdm.contrib.concurrent import process_map
190+
191+
results = process_map(fn, *iterables, **kwargs) # type: ignore[arg-type] # zuban: ignore[no-untyped-call]
192+
else:
193+
from tqdm.contrib.concurrent import thread_map
194+
195+
results = thread_map(fn, *iterables, **kwargs) # type: ignore[arg-type] # zuban: ignore[no-untyped-call]
196+
197+
assert isinstance(results, list)
198+
return results

artisatomic/cli.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
# PYTHON_ARGCOMPLETE_OK
3+
"""Command-line entry point: build an ARTIS atomic database from the configured ions and handlers."""
4+
5+
import argparse
6+
import glob
7+
import json
8+
import os
9+
import typing as t
10+
from collections.abc import Sequence
11+
from pathlib import Path
12+
13+
import argcomplete
14+
15+
from artisatomic import readhillierdata
16+
from artisatomic.iondata import read_ion_data
17+
from artisatomic.iondata import resolve_photoion_targetfractions
18+
from artisatomic.ionhandlers import get_ion_handlers
19+
from artisatomic.output import clear_files
20+
from artisatomic.output import write_compositionfile
21+
from artisatomic.output import write_output_files
22+
23+
24+
def main(args: argparse.Namespace | None = None, argsraw: Sequence[str] | None = None, **kwargs: t.Any) -> None:
25+
"""Write an ARTIS atomic database from the configured ions and handlers."""
26+
if args is None:
27+
parser = argparse.ArgumentParser(
28+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
29+
description="Produce an ARTIS atomic database from published atomic data sets.",
30+
)
31+
parser.add_argument("-output_folder", action="store", default="artis_files", help="Folder for output files")
32+
parser.add_argument(
33+
"-output_folder_logs", action="store", default="atomic_data_logs", help="Folder for log files"
34+
)
35+
parser.add_argument(
36+
"-nphixspoints", type=int, default=100, help="Number of cross section points to save in output"
37+
)
38+
parser.add_argument(
39+
"-phixsnuincrement",
40+
type=float,
41+
default=0.03,
42+
help="Fraction of nu_edge incremented for each cross section point",
43+
)
44+
parser.add_argument(
45+
"-optimaltemperature",
46+
type=int,
47+
default=6000,
48+
help=(
49+
"(Electron and excitation) temperature at which recombination rate "
50+
"should be constant when downsampling cross sections"
51+
),
52+
)
53+
parser.add_argument(
54+
"-electrontemperature",
55+
type=int,
56+
default=6000,
57+
help="Temperature for choosing effective collision strengths",
58+
)
59+
parser.add_argument(
60+
"--nophixs", action="store_true", help="Don't generate cross sections and write to phixsdata_v2.txt file"
61+
)
62+
63+
parser.add_argument(
64+
"-nlevels_hydrogenic_for_unknown_phixs",
65+
type=int,
66+
default=100,
67+
help=(
68+
"Consider this many of the lowest levels of any ion whose handler supplied no"
69+
" cross sections at all, and estimate a hydrogenic one for each, or 0 to disable."
70+
" Negative values are rejected. Fewer tables than this can result, because a level"
71+
" at or above the ionization energy is skipped but still counts towards the limit."
72+
" An ion with even one cross section from its data source is left untouched, so"
73+
" this never replaces or extends measured data. Excludes the top ion, which has no"
74+
" upper ion to photoionise to."
75+
),
76+
)
77+
78+
parser.set_defaults(**kwargs)
79+
argcomplete.autocomplete(parser)
80+
args = parser.parse_args(argsraw)
81+
82+
# 0 is the way to switch the estimate off, so a negative value is a typo rather than a
83+
# quieter way of saying the same thing
84+
if args.nlevels_hydrogenic_for_unknown_phixs < 0:
85+
msg = f"-nlevels_hydrogenic_for_unknown_phixs must not be negative, got {args.nlevels_hydrogenic_for_unknown_phixs}"
86+
raise ValueError(msg)
87+
88+
ion_handlers = get_ion_handlers()
89+
90+
assert len(ion_handlers) > 0
91+
readhillierdata.read_hyd_phixsdata()
92+
93+
os.makedirs(args.output_folder, exist_ok=True)
94+
95+
log_folder = Path(args.output_folder) / args.output_folder_logs
96+
if log_folder.exists():
97+
# delete any existing log files
98+
logfiles = glob.glob(os.path.join(log_folder, "*.txt"))
99+
for logfile in logfiles:
100+
Path(logfile).unlink(missing_ok=True)
101+
print("deleting", logfile)
102+
else:
103+
os.makedirs(log_folder, exist_ok=True)
104+
105+
with Path(log_folder, "artisatomicionhandlers.json").open("w", encoding="utf-8") as f:
106+
json.dump(obj=ion_handlers, fp=f)
107+
write_compositionfile(ion_handlers, args)
108+
clear_files(args)
109+
process_files(ion_handlers, args)
110+
111+
112+
def process_files(ion_handlers: list[tuple[int, list[tuple[int, str]]]], args: argparse.Namespace) -> None:
113+
"""Read every configured ion and append it to the output files, one element at a time.
114+
115+
Ion stages are processed in ascending order so that each ion's photoionisation targets, which
116+
are levels of the next ion up, are already known, and so the top ion can be identified and
117+
given no cross sections.
118+
"""
119+
for atomic_number, listions in ion_handlers:
120+
if not listions:
121+
continue
122+
123+
iondatalist = [
124+
read_ion_data(atomic_number, ion_stage_entry, is_top_ion=(i == len(listions) - 1), args=args)
125+
for i, ion_stage_entry in enumerate(listions)
126+
]
127+
128+
if not args.nophixs:
129+
resolve_photoion_targetfractions(iondatalist)
130+
131+
write_output_files(atomic_number, iondatalist, args)
132+
133+
134+
if __name__ == "__main__":
135+
main()

artisatomic/groundstatesonlynist.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99

1010
import artisatomic
1111

12-
hc_in_ev_cm = 0.0001239841984332003
13-
1412

1513
class EnergyLevel(t.NamedTuple):
1614
"""A ground state read from the NIST table."""

0 commit comments

Comments
 (0)