diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f57ecd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +.venv/ +__pycache__/ +*.pyc +*.pt +checkpoints/ +wandb/ diff --git a/boa_ft/README.md b/boa_ft/README.md new file mode 100644 index 0000000..79fe920 --- /dev/null +++ b/boa_ft/README.md @@ -0,0 +1,97 @@ +# boa_ft: BOA on LeMat-Rho + +Trains BOA (Basis Overlap Architecture, github.com/sciai-lab/boa) from scratch +on LeMat-Rho r2SCAN charge densities (15x15x15 grids, periodic solids). + +BOA learns per atom-pair Gaussian-orbital coefficients and is supervised +directly on grid density values (no grid-to-basis projection step). We add: + +- `preprocess.py`: LeMat-Rho parquet -> sharded LMDB of scdp `AtomicData`. +- `transforms.py`: `PBCRadiusEdgeIndex`, a periodic (minimum-image) neighbor + graph that replaces BOA's open-boundary `radius_graph` and removes the + `torch_cluster` dependency from the training path. +- `configs/`: Hydra overlays wiring the datamodule to our LMDB and transform, + and turning the periodic decoder on (`model.pbc=true`, `model.orb_cutoff=3.0`). + +## Setup + +BOA and its vendored `scdp` and `sciai-dft` (`mldft`) packages are not on PyPI. +Clone BOA as a sibling of this repo and editable-install the three packages into +the same environment that has this repo's dependencies: + +```bash +git clone https://github.com/sciai-lab/boa ../boa +uv pip install -e ../boa/scdp -e ../boa/sciai-dft -e ../boa +``` + +`preprocess.py` also imports the shared parquet decoder from `charge3net_ft`, +which requires the charge3net repo cloned as a sibling (`../charge3net`), exactly +as the deepdft and charge3net arms already need. + +## Preprocess + +```bash +python -m boa_ft.preprocess \ + --parquet-dir /path/to/lemat_rho_chunks \ + --out-dir $BOA_DATA/lematrho \ + --num-shards 16 +``` + +This writes `$BOA_DATA/lematrho/data/*.lmdb`, `$BOA_DATA/lematrho/datasplits.json` +(charge3net split convention: seed 42, 5% val, 5% test), and +`$BOA_DATA/lematrho/atomic_numbers.json`. The element list is the union over the +*train* split only, because BOA builds one orbital per training element and +aligns the basis positionally against the train split. This means the train +split must contain every element you want a basis for (a 90% split of a large +dataset does; a tiny subset may not). Use `--limit N` for a quick smoke subset. + +## Train + +BOA reads three environment variables for its paths (`PROJECT_ROOT` = the boa +clone, `BOA_DATA` = the dataset root that holds `lematrho/`, `BOA_MODELS` = the +run/checkpoint output root). BOA's Hydra root lives in the boa clone, so add our +config directory to the search path and select the experiment: + +```bash +export PROJECT_ROOT=/path/to/boa +export BOA_DATA=/path/to/data_root +export BOA_MODELS=/path/to/model_root + +ATOMIC_NUMBERS=$(python -c "import json; print(json.load(open('$BOA_DATA/lematrho/atomic_numbers.json')))") + +python "$PROJECT_ROOT/boa/train.py" \ + hydra.searchpath="[file://$(pwd)/boa_ft/configs]" \ + experiment=lematrho \ + data.basis_info.atomic_numbers="$ATOMIC_NUMBERS" +``` + +The `experiment=lematrho` overlay sets `model.pbc=true`, `model.orb_cutoff=3.0`, +`model.linear_basis=true` (the periodic-safe density decoder), and +`initial_guess_pre_training_steps=0`. BOA's separate initial-guess pre-training +opens a second datamodule on the same LMDB, which py-lmdb forbids in one +process, so we disable it; the initial-guess module still trains jointly in the +main loop. + +### Smoke run (CPU) + +`+trainer.devices=1` uses a leading `+` because the trainer config has no +`devices` key by default. A short `hydra.run.dir` avoids a run-directory name +built from the (long) override string. + +```bash +python "$PROJECT_ROOT/boa/train.py" \ + hydra.searchpath="[file://$(pwd)/boa_ft/configs]" \ + hydra.run.dir=/tmp/boa_smoke \ + experiment=lematrho \ + data.basis_info.atomic_numbers="$ATOMIC_NUMBERS" \ + trainer.accelerator=cpu +trainer.devices=1 \ + trainer.max_steps=3 trainer.max_epochs=1 trainer.num_sanity_val_steps=0 \ + data.datamodule.num_workers.train=0 data.datamodule.num_workers.val=0 \ + data.datamodule.batch_size.train=2 data.datamodule.n_probe.train=512 +``` + +## Adastra + +Use `submit_boa_adastra.sh` at the repo root (MI250, account `c1816212`, single +GCD first). It sets the proxy, activates `venv311`, exports the three BOA path +variables, reads `atomic_numbers.json`, and launches the Hydra command above. diff --git a/boa_ft/__init__.py b/boa_ft/__init__.py new file mode 100644 index 0000000..62071df --- /dev/null +++ b/boa_ft/__init__.py @@ -0,0 +1,7 @@ +"""LeMat-Rho -> BOA (Basis Overlap Architecture) training arm. + +This package adapts the LeMat-Rho parquet charge-density dataset to the BOA +codebase (github.com/sciai-lab/boa), which is editable-installed from a sibling +clone at ``../boa`` together with its vendored ``scdp`` and ``sciai-dft`` +(``mldft``) packages. See ``boa_ft/README.md`` for setup and usage. +""" diff --git a/boa_ft/configs/data/datamodule/dataset/lematrho.yaml b/boa_ft/configs/data/datamodule/dataset/lematrho.yaml new file mode 100644 index 0000000..eae857e --- /dev/null +++ b/boa_ft/configs/data/datamodule/dataset/lematrho.yaml @@ -0,0 +1,5 @@ +defaults: + - transform: lematrho + +_target_: boa.data.dataset.LmdbDataset +path: ${paths.data_dir}/${data.dataset_name}/data diff --git a/boa_ft/configs/data/datamodule/dataset/transform/lematrho.yaml b/boa_ft/configs/data/datamodule/dataset/transform/lematrho.yaml new file mode 100644 index 0000000..67b6646 --- /dev/null +++ b/boa_ft/configs/data/datamodule/dataset/transform/lematrho.yaml @@ -0,0 +1,39 @@ +# Mirrors boa's default transform pipeline, but swaps the two open-boundary +# AddRadiusEdgeIndex steps for boa_ft.transforms.PBCRadiusEdgeIndex so the graph +# respects periodic boundary conditions (and drops the torch_cluster dependency). +# The list order is kept identical so the ${..N.name} interpolations below still +# point at the matching edge-index step. +_target_: boa.data.transforms.MasterTransform +transforms: + - _target_: boa.data.transforms.SampleProbe + n_probe: ${data.datamodule.n_probe.train} + + - _target_: boa.data.transforms.ConvertToOFData + basis_info: ${data.basis_info} + + - _target_: boa.data.transforms.ToTorch + + - _target_: boa.data.transforms.AddMessagePassingMatrix + basis_info: ${data.basis_info} + type: overlap + remove_diagonal: False + + - _target_: boa_ft.transforms.PBCRadiusEdgeIndex + radius: 6.0 + name: message_edge_index + + - _target_: boa.data.transforms.AddEdgeMatrices + basis_info: ${data.basis_info} + edge_name: ${..4.name} + name: message_edge_matrices + + - _target_: boa_ft.transforms.PBCRadiusEdgeIndex + radius: 3.0 + name: edge_index + + - _target_: boa.data.transforms.AddEdgeMatrices + basis_info: ${data.basis_info} + edge_name: ${..6.name} + name: edge_matrices + + - _target_: boa.data.transforms.ToTorch diff --git a/boa_ft/configs/data/datamodule/lematrho.yaml b/boa_ft/configs/data/datamodule/lematrho.yaml new file mode 100644 index 0000000..1cd8e19 --- /dev/null +++ b/boa_ft/configs/data/datamodule/lematrho.yaml @@ -0,0 +1,15 @@ +defaults: + - default + - dataset: lematrho + +_target_: boa.data.datamodule.ProbeDataModule + +batch_size: + train: 4 + val: 4 + test: 4 + +# 15x15x15 grids hold 3375 points per material; 2000 probes subsamples each +# sample. The datamodule default sets n_probe.val/test equal to n_probe.train. +n_probe: + train: 2000 diff --git a/boa_ft/configs/data/lematrho.yaml b/boa_ft/configs/data/lematrho.yaml new file mode 100644 index 0000000..8a7bc5b --- /dev/null +++ b/boa_ft/configs/data/lematrho.yaml @@ -0,0 +1,18 @@ +defaults: + - datamodule: lematrho + +dataset_name: lematrho + +# Uncontracted def2-svp: a compact basis that keeps the per-sample pyscf +# overlap (built in the dataloader) affordable for many-element solids. +# atomic_numbers must list every element present in the LMDB. Preprocess writes +# the exact list to /atomic_numbers.json; override this at launch, e.g. +# data.basis_info.atomic_numbers='[1, 8, 26]' +# The default below is only a placeholder for smoke runs. +basis_info: + _target_: boa.data.basis_info.BasisInfo.from_atomic_numbers_with_even_tempered_basis + basis: 'def2-svp' + atomic_numbers: [1, 8] + beta: None + even_tempered: false + uncontracted: True diff --git a/boa_ft/configs/experiment/lematrho.yaml b/boa_ft/configs/experiment/lematrho.yaml new file mode 100644 index 0000000..1183471 --- /dev/null +++ b/boa_ft/configs/experiment/lematrho.yaml @@ -0,0 +1,27 @@ +# @package _global_ + +# LeMat-Rho periodic solids: enable the periodic density decoder and keep the +# orbital cutoff tight (3.0 A) so the periodic image replication inside +# GTOs.forward stays cheap for dense cells. +defaults: + - override /data: lematrho + +name: lematrho + +# BOA runs its initial-guess pre-training as a separate Trainer that builds a +# second datamodule on the same LMDB. py-lmdb refuses to open one environment +# twice in a process, so the LMDB-backed path cannot pre-train in the same run. +# We disable the separate phase; the initial-guess module still trains jointly +# inside the main loop (it is part of the BOA net's forward). +initial_guess_pre_training_steps: 0 + +model: + pbc: true + orb_cutoff: 3.0 + # Linear basis: sum independent atom-centered orbital contributions. BOA's + # non-linear (pairwise) orbital_inference matches every edge with its flip by + # unique-column bookkeeping that produces out-of-bounds indices under + # pbc=true (it was written for the non-periodic QM9 case). The linear path + # (orbital_inference_linear) has a clean periodic summation, so we use it for + # periodic solids. + linear_basis: true diff --git a/boa_ft/preprocess.py b/boa_ft/preprocess.py new file mode 100644 index 0000000..a04c1a9 --- /dev/null +++ b/boa_ft/preprocess.py @@ -0,0 +1,383 @@ +"""LeMat-Rho parquet -> sharded LMDB of scdp ``AtomicData`` for BOA. + +BOA trains from an LMDB directory of pickled ``scdp.data.data.AtomicData`` +graphs plus a ``datasplits.json`` (see ``boa.data.dataset.LmdbDataset`` and +``boa.data.datamodule.ProbeDataModule``). This script builds both from the +LeMat-Rho parquet chunks, reusing the exact schema and row decoder that the +charge3net and deepdft arms use (imported, not copied, so the input pipeline +stays a single source of truth). + +Each valid row becomes an ``AtomicData`` via ``build_graph_with_vnodes`` with +``disable_pbc=False`` (periodic neighbor graph) and ``vnode_method="none"`` +(BOA's function-centric path assigns coefficients to real atom pairs, so no +virtual nodes). Rows whose ``compressed_charge_density`` is null are already +dropped by the shared index builder; any row that still fails to convert is +skipped and counted. + +.. code-block:: bash + + python -m boa_ft.preprocess \\ + --parquet-dir /path/to/lemat_rho_chunks \\ + --out-dir $BOA_DATA/lematrho \\ + --num-shards 16 +""" + +from __future__ import annotations + +import argparse +import collections +import json +import pickle +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +import pyarrow.parquet as pq +import torch +from ase.data import atomic_numbers as ASE_ATOMIC_NUMBERS +from tqdm import tqdm + +# scdp (sibling boa clone, editable-installed; see boa_ft/README.md) and the +# shared charge3net_ft parquet pipeline (needs the ../charge3net sibling) are +# imported lazily inside the functions that need them, so the pure helpers +# (row_exceeds_max_z, write_datasplits) import cleanly without either sibling. +if TYPE_CHECKING: + from scdp.data.data import AtomicData + + +def row_to_atomic_data( + row: dict, + metadata: str, + z_table, + atom_cutoff: float, + max_neighbors: int | None, +) -> AtomicData: + """Convert one parquet row dict into an scdp ``AtomicData`` graph. + + Parameters + ---------- + row : dict + Row with the LeMat-Rho columns (species, positions, lattice, density). + metadata : str + Human-readable id stored on the graph (used for logging). + z_table : scdp.data.data.AtomicNumberTable + Atomic-number-to-index table spanning all elements. + atom_cutoff : float + Cutoff radius (Angstrom) for the periodic atom-atom neighbor graph. + max_neighbors : int or None + Optional cap on neighbors per atom. + + Returns + ------- + scdp.data.data.AtomicData + Graph carrying atoms, cell, full probe grid, and flattened density. + """ + from scdp.data.data import AtomicData + + from charge3net_ft.data import _row_to_atoms_and_density + + atoms, density, _origin = _row_to_atoms_and_density(row) + + atom_types = torch.from_numpy(atoms.numbers).long() + atom_coords = torch.from_numpy(atoms.positions).float() + cell = torch.from_numpy(np.asarray(atoms.cell.array)).float() + chg_density = torch.from_numpy(np.ascontiguousarray(density)).float() + + # origin=None: LeMat-Rho grids start at fractional (0, 0, 0), so the grid + # positions computed inside build_graph_with_vnodes need no offset. + return AtomicData.build_graph_with_vnodes( + atom_types, + atom_coords, + cell, + chg_density, + None, + metadata=metadata, + z_table=z_table, + atom_cutoff=atom_cutoff, + disable_pbc=False, + vnode_method="none", + device="cpu", + max_neighbors=max_neighbors, + struct=None, + ) + + +# Same bound as the per-worker LRU table caches in deepdft_ft.data and +# charge3net_ft.data: each cached entry is a fully decompressed pyarrow +# table, so an unbounded cache eventually holds the whole dataset in RAM. +_TABLE_CACHE_MAX_CHUNKS = 5 + + +def read_row_cached( + file_paths: list[Path], + fi: int, + ri: int, + cache: collections.OrderedDict, + max_chunks: int = _TABLE_CACHE_MAX_CHUNKS, +) -> dict: + """Read one parquet row, keeping at most ``max_chunks`` tables cached (LRU). + + Parameters + ---------- + file_paths : list of Path + Parquet chunk files, indexed by ``fi``. + fi, ri : int + File index and row index within that file. + cache : collections.OrderedDict + Caller-owned LRU cache mapping file index to pyarrow table. + max_chunks : int + Cache capacity; least recently used tables are evicted beyond it. + + Returns + ------- + dict + Column-name-to-value mapping for the requested row. + """ + if fi in cache: + cache.move_to_end(fi) + else: + cache[fi] = pq.read_table(file_paths[fi]) + while len(cache) > max_chunks: + cache.popitem(last=False) + table = cache[fi] + return {col: table.column(col)[ri].as_py() for col in table.column_names} + + +def row_exceeds_max_z(row: dict, max_z: int) -> bool: + """Return True when a row contains an element heavier than ``max_z``. + + Used to drop materials the GTO basis cannot represent (def2-svp covers + H through Rn, Z <= 86, so actinide-bearing rows must be excluded). + + Parameters + ---------- + row : dict + Row with the LeMat-Rho columns (needs ``species_at_sites``). + max_z : int + Highest allowed atomic number. + + Returns + ------- + bool + True if any site's element has Z above ``max_z``. + + .. code-block:: python + + row_exceeds_max_z({"species_at_sites": ["U", "O", "O"]}, 86) # True + """ + return any(ASE_ATOMIC_NUMBERS[s] > max_z for s in row["species_at_sites"]) + + +def write_datasplits( + n_samples: int, + out_dir: Path, + val_frac: float, + test_frac: float, + seed: int, +) -> dict: + """Write ``datasplits.json`` with the charge3net split convention. + + Uses the same ``torch.Generator`` seed and (val, test) fractions as + ``charge3net_ft.data.build_dataloaders`` so splits stay comparable across + arms. Indices are sorted global dataset indices (matching how + ``LmdbDataset`` concatenates shards in sorted filename order). + + Parameters + ---------- + n_samples : int + Total number of samples written to the LMDB. + out_dir : Path + Dataset directory where ``datasplits.json`` is written. + val_frac, test_frac : float + Validation and test fractions. + seed : int + Split RNG seed. + + Returns + ------- + dict + The split dict with keys ``train``, ``validation``, ``test``. + """ + n_val = int(n_samples * val_frac) + n_test = int(n_samples * test_frac) + n_train = n_samples - n_val - n_test + generator = torch.Generator().manual_seed(seed) + train_idx, val_idx, test_idx = torch.utils.data.random_split( + range(n_samples), [n_train, n_val, n_test], generator=generator + ) + splits = { + "train": sorted(int(i) for i in train_idx.indices), + "validation": sorted(int(i) for i in val_idx.indices), + "test": sorted(int(i) for i in test_idx.indices), + } + with open(out_dir / "datasplits.json", "w") as f: + json.dump(splits, f) + return splits + + +def main(args: argparse.Namespace) -> None: + """Run the parquet -> LMDB conversion and write splits + basis metadata.""" + from scdp.scripts.preprocess import get_atomic_number_table_from_zs + + from charge3net_ft.data import _build_parquet_index + + out_dir = Path(args.out_dir) + data_dir = out_dir / "data" + data_dir.mkdir(parents=True, exist_ok=True) + + file_paths, index = _build_parquet_index(Path(args.parquet_dir)) + if args.limit is not None: + index = index[: args.limit] + + # Atomic-number table spanning all elements (matches scdp's own preprocess). + z_table = get_atomic_number_table_from_zs(np.arange(100).tolist()) + + # Contiguous blocks keep the LMDB global-index order equal to processing + # order, so datasplits.json indices line up with LmdbDataset. + num_shards = min(args.num_shards, len(index)) + shard_blocks = np.array_split(np.arange(len(index)), num_shards) + map_size = args.map_size_gb * (1024**3) + + import lmdb # local import: lmdb is only needed when actually writing. + + n_written = 0 + n_failed = 0 + n_heavy = 0 + # Per-sample element sets, indexed by global write order. BOA's + # construct_orbitals aligns the basis positionally against the *training* + # split's unique elements, so the basis element list must be exactly the + # union over the train split (see write below), not the whole dataset. + sample_elements: list[set[int]] = [] + + # Bounded per-chunk LRU table cache (see read_row_cached): rows are + # processed in contiguous blocks, so a small cache still gives one + # read per file without holding every decompressed table in RAM. + table_cache: collections.OrderedDict = collections.OrderedDict() + + for shard_id, block in enumerate(shard_blocks): + if len(block) == 0: + continue + shard_path = str(data_dir / f"data.{shard_id:04d}.lmdb") + db = lmdb.open( + shard_path, + map_size=map_size, + subdir=False, + meminit=False, + map_async=True, + ) + local_idx = 0 + for global_pos in tqdm(block, desc=f"shard {shard_id}", position=0): + fi, ri = index[int(global_pos)] + chunk_stem = file_paths[fi].stem + metadata = f"{chunk_stem}_row{ri:06d}" + row = read_row_cached(file_paths, fi, ri, table_cache) + if args.max_z is not None and row_exceeds_max_z(row, args.max_z): + n_heavy += 1 + continue + try: + data = row_to_atomic_data( + row, metadata, z_table, args.atom_cutoff, args.max_neighbors + ) + except Exception as exc: # noqa: BLE001 - skip and count bad rows. + n_failed += 1 + print(f"skip {metadata}: {type(exc).__name__}: {exc}") + continue + sample_elements.append({int(z) for z in data.atom_types.tolist() if z > 0}) + txn = db.begin(write=True) + txn.put(f"{local_idx}".encode("ascii"), pickle.dumps(data, protocol=-1)) + txn.commit() + local_idx += 1 + n_written += 1 + # LmdbDataset reads this "length" key to size the shard. + txn = db.begin(write=True) + txn.put("length".encode("ascii"), pickle.dumps(local_idx, protocol=-1)) + txn.commit() + db.sync() + db.close() + + splits = write_datasplits( + n_written, out_dir, args.val_frac, args.test_frac, args.seed + ) + + # Basis element list = union of elements over the train split, matching the + # metadata BOA computes over the same split. Elements that appear only in + # val/test are excluded here; BOA has no orbital for an unseen element, so + # for real runs the train split must cover every element (a 90% split of a + # large dataset does). + train_elements: set[int] = set() + for i in splits["train"]: + train_elements.update(sample_elements[i]) + atomic_numbers = sorted(train_elements) + with open(out_dir / "atomic_numbers.json", "w") as f: + json.dump(atomic_numbers, f) + + print( + f"wrote {n_written} samples to {data_dir} across {num_shards} shard(s); " + f"skipped {n_failed} unconvertible row(s) and {n_heavy} row(s) above max-z" + ) + print(f"atomic_numbers ({len(atomic_numbers)}): {atomic_numbers}") + print( + f"pass these to training via data.basis_info.atomic_numbers='{atomic_numbers}'" + ) + + +def get_parser() -> argparse.ArgumentParser: + """Build the CLI parser.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--parquet-dir", + required=True, + help="Directory of LeMat-Rho chunk_*.parquet files.", + ) + parser.add_argument( + "--out-dir", + required=True, + help="Dataset directory to create (holds data/ LMDB shards + datasplits.json).", + ) + parser.add_argument( + "--num-shards", + type=int, + default=16, + help="Number of LMDB shards to split the data into.", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Process only the first N valid rows (for smoke tests).", + ) + parser.add_argument( + "--atom-cutoff", + type=float, + default=4.0, + help="Cutoff radius (Angstrom) for the periodic atom-atom graph.", + ) + parser.add_argument( + "--max-neighbors", + type=int, + default=None, + help="Optional cap on neighbors per atom in the stored graph.", + ) + parser.add_argument( + "--max-z", + type=int, + default=None, + help=( + "Skip rows containing any element with Z above this " + "(86 keeps def2-svp basis coverage; actinides have no basis)." + ), + ) + parser.add_argument( + "--map-size-gb", + type=int, + default=64, + help="LMDB max map size in GiB (virtual; file grows sparsely).", + ) + parser.add_argument("--val-frac", type=float, default=0.05) + parser.add_argument("--test-frac", type=float, default=0.05) + parser.add_argument("--seed", type=int, default=42) + return parser + + +if __name__ == "__main__": + main(get_parser().parse_args()) diff --git a/boa_ft/transforms.py b/boa_ft/transforms.py new file mode 100644 index 0000000..7fadb39 --- /dev/null +++ b/boa_ft/transforms.py @@ -0,0 +1,165 @@ +"""PBC-aware neighbor graph for the BOA transform pipeline. + +BOA's stock edge builder (``boa.data.transforms.AddRadiusEdgeIndex``, which +wraps ``mldft``'s ``AddRadiusEdgeIndex``) calls +``torch_geometric.nn.radius_graph``. That is open-boundary (it ignores the +cell) and it drags in the compiled ``torch_cluster`` extension, which has no +ROCm/AMD wheel. LeMat-Rho holds periodic solids, so we replace it with a +minimum-image radius graph built from the lattice directly. + +We keep exactly one edge per ordered atom pair (plus self loops), matching the +output structure of ``radius_graph(..., loop=True)``. BOA's density decoder +(``ChgLightningModule.orbital_inference``) matches every edge ``(i, j)`` with +its flip ``(j, i)`` by finding unique columns, so duplicating a pair across +several periodic images would break that bookkeeping. The periodic summation of +the density itself is still handled downstream inside ``GTOs.forward`` when +``pbc=True``; this graph only decides which atom pairs carry coefficients and +exchange messages. + +.. code-block:: python + + import torch + from boa_ft.transforms import pbc_radius_edge_index + + pos = torch.tensor([[0.0, 0.0, 0.0], [0.0, 0.0, 3.9]]) + cell = torch.eye(3) * 4.0 + edge_index = pbc_radius_edge_index(pos, cell, radius=1.0) +""" + +from __future__ import annotations + +import torch + + +def _cell_heights(cell: torch.Tensor) -> torch.Tensor: + """Perpendicular distance between opposite faces for each lattice vector. + + Used to decide how many periodic images we must generate so that no + neighbor within ``radius`` is missed. + + Parameters + ---------- + cell : torch.Tensor + Lattice matrix of shape ``(3, 3)`` with lattice vectors as rows. + + Returns + ------- + torch.Tensor + Shape ``(3,)`` interplanar spacings. + """ + volume = torch.det(cell).abs() + heights = [] + for i in range(3): + # The two lattice vectors spanning the face opposite to vector i. + j, k = [d for d in range(3) if d != i] + face_area = torch.linalg.cross(cell[j], cell[k]).norm() + heights.append(volume / (face_area + 1e-12)) + return torch.stack(heights) + + +def pbc_radius_edge_index( + pos: torch.Tensor, + cell: torch.Tensor, + radius: float, + loop: bool = True, +) -> torch.Tensor: + """Minimum-image radius graph under periodic boundary conditions. + + An ordered pair ``(i, j)`` is connected when the closest periodic image of + atom ``j`` lies within ``radius`` of atom ``i``. Self loops ``(i, i)`` are + included when ``loop`` is True (their minimum-image distance is 0). + + Parameters + ---------- + pos : torch.Tensor + Cartesian atom coordinates, shape ``(N, 3)`` (Angstrom). + cell : torch.Tensor + Lattice matrix, shape ``(3, 3)`` with lattice vectors as rows (Angstrom). + radius : float + Neighbor cutoff (Angstrom). + loop : bool + Whether to include self loops. Defaults to True to match + ``radius_graph(loop=True)``. + + Returns + ------- + torch.Tensor + Edge index of shape ``(2, E)`` and dtype long. Row 0 is the source, + row 1 the destination. The graph is symmetric. + """ + # float64 keeps distance comparisons stable for near-cutoff neighbors. + pos = pos.to(torch.float64) + cell = cell.to(torch.float64) + + # Enough images along each axis to cover the cutoff even for skewed cells. + heights = _cell_heights(cell) + n_rep = torch.ceil(torch.as_tensor(radius) / heights).long() + axis_ranges = [torch.arange(-int(n_rep[d]), int(n_rep[d]) + 1) for d in range(3)] + # Integer image offsets (S, 3) mapped to Cartesian shift vectors (S, 3). + offsets = torch.cartesian_prod(*axis_ranges).to(torch.float64) + shifts = offsets @ cell + + # dvec[i, j, s] = (pos[j] + shift[s]) - pos[i]; take the closest image per pair. + dvec = pos[None, :, None, :] + shifts[None, None, :, :] - pos[:, None, None, :] + dist_min = dvec.norm(dim=-1).min(dim=-1).values # (N, N) + + mask = dist_min <= radius + mask.fill_diagonal_(bool(loop)) + + src, dst = torch.nonzero(mask, as_tuple=True) + return torch.stack([src, dst], dim=0).long() + + +class PBCRadiusEdgeIndex: + """Drop-in periodic replacement for ``boa.data.transforms.AddRadiusEdgeIndex``. + + Mirrors that class's interface (``radius`` and ``name``) so it slots into the + BOA transform config unchanged, but builds a minimum-image graph from + ``sample.cell`` instead of an open-boundary ``radius_graph``. + + .. code-block:: yaml + + - _target_: boa_ft.transforms.PBCRadiusEdgeIndex + radius: 3.0 + name: edge_index + """ + + def __init__(self, radius: float, name: str = "edge_index"): + """ + Parameters + ---------- + radius : float + Neighbor cutoff (Angstrom). + name : str + Attribute name to store the edge index under. Defaults to + ``"edge_index"``. + """ + self.radius = radius + self.name = name + + def __call__(self, sample): + """Add the periodic edge index to ``sample`` under ``self.name``. + + Parameters + ---------- + sample : mldft.ml.data.components.of_data.OFData + Sample carrying ``pos`` (N, 3) and ``cell`` ((1, 3, 3) or (3, 3)). + + Returns + ------- + mldft.ml.data.components.of_data.OFData + The same sample with the edge index attribute added. + """ + # Imported here so the pure geometry helpers above stay importable + # (and unit-testable) without the mldft package installed. + from mldft.ml.data.components.of_data import Representation + + cell = torch.as_tensor(sample.cell) + # ConvertToOFData stores the cell as (1, 3, 3); collapse the batch dim. + if cell.dim() == 3: + cell = cell[0] + edge_index = pbc_radius_edge_index( + torch.as_tensor(sample.pos), cell, self.radius + ) + sample.add_item(self.name, edge_index, Representation.NONE) + return sample diff --git a/charge3net_ft/__init__.py b/charge3net_ft/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/charge3net_ft/data.py b/charge3net_ft/data.py new file mode 100644 index 0000000..5a14627 --- /dev/null +++ b/charge3net_ft/data.py @@ -0,0 +1,366 @@ +""" +Dataset and DataLoader for LeMatRho charge density data. + +Reads Parquet files produced by lematerial-fetcher, converts to charge3net's +expected input format (padded dict batches), and builds PBC-aware atom-atom +and atom-probe graphs using charge3net's KdTreeGraphConstructor. + +Uses lazy loading: only an index of valid rows is stored in memory (~2 MB). +Parquet rows are read on-the-fly in __getitem__. Each DataLoader worker caches +opened tables per chunk file so each file is read from disk only once per worker. +""" + +import collections +import json +import sys +from functools import partial +from pathlib import Path + +import ase +import ase.data +import numpy as np +import pyarrow.parquet as pq +import torch +from torch.utils.data import DataLoader, Dataset, random_split + +# --------------------------------------------------------------------------- +# charge3net imports — add the cloned repo to sys.path so that its internal +# `from src.charge3net.data...` imports resolve correctly. +# Expects: /charge3net/ (cloned from AIforGreatGood/charge3net) +# --------------------------------------------------------------------------- +_CHARGE3NET_ROOT = Path(__file__).resolve().parent.parent.parent / "charge3net" +if not _CHARGE3NET_ROOT.exists(): + raise RuntimeError( + f"charge3net repo not found at {_CHARGE3NET_ROOT}.\n" + "Clone it with: git clone https://github.com/AIforGreatGood/charge3net " + f"{_CHARGE3NET_ROOT}" + ) +if str(_CHARGE3NET_ROOT) not in sys.path: + sys.path.insert(0, str(_CHARGE3NET_ROOT)) + +from src.charge3net.data.collate import collate_list_of_dicts +from src.charge3net.data.graph_construction import KdTreeGraphConstructor +from src.utils.data import calculate_grid_pos + +# Columns we actually need from Parquet +_COLUMNS = [ + "species_at_sites", + "cartesian_site_positions", + "lattice_vectors", + "compressed_charge_density", +] + +# --------------------------------------------------------------------------- +# Element symbol -> atomic number lookup +# --------------------------------------------------------------------------- +_SYMBOL_TO_Z = {s: z for z, s in enumerate(ase.data.chemical_symbols)} + +# Process-local LRU table cache: keyed by file index, populated on first access. +# Each DataLoader worker has its own cache (workers fork the parent), so each +# chunk file is read from disk at most once per worker per cache cycle. +# +# Bounded LRU because the previous unbounded version OOM-killed jobs 4971293 +# and 4971343 at MaxRSS=35 GB/rank. Per-chunk decompressed pyarrow tables +# weigh ~2 GB (the compressed_charge_density JSON strings inflate 6x from +# disk). With 8 workers x 4 DDP ranks = 32 workers, an unbounded cache grew +# to ~140 GB total in 6 h. +# +# Cap of 5 chunks per worker keeps each worker's cache around 10 GB worst +# case, well under any per-rank memory budget. OrderedDict gives O(1) LRU. +_TABLE_CACHE_MAX_CHUNKS = 5 +_TABLE_CACHE: "collections.OrderedDict[int, object]" = collections.OrderedDict() + + +def _parse_grid_json(json_str: str) -> np.ndarray: + """Parse a JSON-serialised 3D grid string into a float32 numpy array.""" + grid = json.loads(json_str) + return np.array(grid, dtype=np.float32) + + +def _row_to_atoms_and_density(row: dict) -> tuple: + """ + Convert a single Parquet row dict into an ase.Atoms object, a 3D density + grid, and the grid origin. + + Returns + ------- + atoms : ase.Atoms + The periodic structure. + density : np.ndarray + Shape (Nx, Ny, Nz) charge density grid. + origin : np.ndarray + Grid origin, [0, 0, 0] (charge3net convention). + """ + species = row["species_at_sites"] + atomic_numbers = [_SYMBOL_TO_Z[s] for s in species] + positions = np.array(row["cartesian_site_positions"], dtype=np.float64) + cell = np.array(row["lattice_vectors"], dtype=np.float64) + + atoms = ase.Atoms( + numbers=atomic_numbers, + positions=positions, + cell=cell, + pbc=True, + ) + + density = _parse_grid_json(row["compressed_charge_density"]) + origin = np.zeros(3) + + return atoms, density, origin + + +def _build_parquet_index(parquet_dir: Path) -> tuple: + """ + Scan all Parquet chunks and build a lightweight index of valid rows + (those with non-null compressed_charge_density). + + Returns + ------- + file_paths : list[Path] + Sorted list of Parquet file paths. + index : list[tuple[int, int]] + Each entry is (file_index, row_index_within_file). + """ + file_paths = sorted(parquet_dir.glob("chunk_*.parquet")) + if not file_paths: + raise FileNotFoundError(f"No chunk_*.parquet files in {parquet_dir}") + + index = [] + n_total = 0 + for fi, fp in enumerate(file_paths): + # Read only the charge density column to check for nulls. + # Use .is_valid (Arrow scalar) rather than .as_py() is not None to + # avoid creating Python objects for every row. + col = pq.read_table(fp, columns=["compressed_charge_density"]).column( + "compressed_charge_density" + ) + n_rows = len(col) + n_total += n_rows + for ri in range(n_rows): + if col[ri].is_valid: + index.append((fi, ri)) + + n_valid = len(index) + print( + f"LeMatRhoDataset: {n_valid}/{n_total} valid rows indexed from {len(file_paths)} files" + ) + return file_paths, index + + +class LeMatRhoDataset(Dataset): + """ + PyTorch Dataset that lazily reads LeMatRho Parquet chunks and yields + charge3net-compatible graph dicts. + + Only a lightweight index (~2 MB for 65k rows) is stored in memory. + Each __getitem__ call retrieves a single row. Chunk files are cached + per-worker so each file is loaded from disk only once per worker process. + + Parameters + ---------- + parquet_dir : str or Path + Directory containing chunk_*.parquet files. + cutoff : float + Cutoff radius for neighbor finding (Angstrom). Must match model. + num_probes : int or None + If set, randomly subsample this many probe points per sample + (used during training to save memory). If None, use all grid points. + _shared_index : tuple or None + Internal: pre-computed (file_paths, index) to share between + train/val datasets without scanning files twice. + """ + + def __init__( + self, + parquet_dir: str | None = None, + cutoff: float = 4.0, + num_probes: int | None = None, + _shared_index: tuple | None = None, + ): + self.cutoff = cutoff + self.num_probes = num_probes + + self.graph_constructor = KdTreeGraphConstructor( + cutoff=cutoff, + num_probes=num_probes, + disable_pbc=False, + ) + + if _shared_index is not None: + self._file_paths, self._index = _shared_index + else: + self._file_paths, self._index = _build_parquet_index(Path(parquet_dir)) + + def __len__(self): + return len(self._index) + + def _read_row(self, idx: int) -> dict: + """ + Read a single row from disk via its index entry. + + Uses a process-local LRU cache (_TABLE_CACHE) so each chunk file is + loaded from disk at most once per worker per cache cycle. Cache is + capped at _TABLE_CACHE_MAX_CHUNKS entries; on a miss past capacity + the least-recently-used chunk is evicted. Re-access of a present + entry promotes it to most-recent so the running shuffled-access + pattern from RandomSampler doesn't constantly thrash. + """ + fi, ri = self._index[idx] + if fi in _TABLE_CACHE: + # Hit: bump to most-recent and return. + _TABLE_CACHE.move_to_end(fi) + else: + # Miss: evict LRU if at capacity, then read. + if len(_TABLE_CACHE) >= _TABLE_CACHE_MAX_CHUNKS: + _TABLE_CACHE.popitem(last=False) + _TABLE_CACHE[fi] = pq.read_table(self._file_paths[fi], columns=_COLUMNS) + table = _TABLE_CACHE[fi] + row = {} + for col in _COLUMNS: + row[col] = table.column(col)[ri].as_py() + return row + + def __getitem__(self, idx: int) -> dict: + row = self._read_row(idx) + atoms, density, origin = _row_to_atoms_and_density(row) + + # Generate grid positions — same function charge3net uses internally. + # For a (Nx, Ny, Nz) density grid, this creates fractional coordinates + # [0/Nx, 1/Nx, ..., (Nx-1)/Nx] in each dimension, then maps to + # Cartesian via the cell matrix. + grid_pos = calculate_grid_pos(density, origin, atoms.get_cell()) + + # Build the graph dict using charge3net's KdTreeGraphConstructor. + # This handles: + # - Random probe subsampling (if num_probes is set) + # - Dynamic supercell expansion for PBC + # - KD-tree based atom->probe neighbor finding + # - ASE-based atom->atom neighbor finding + graph_dict = self.graph_constructor(density, atoms, grid_pos) + + return graph_dict + + +def build_dataloaders( + parquet_dir: str, + cutoff: float = 4.0, + train_probes: int = 200, + val_probes: int = 1000, + batch_size: int = 4, + val_frac: float = 0.05, + test_frac: float = 0.05, + num_workers: int = 4, + seed: int = 42, + pin_memory: bool = False, + distributed: bool = False, +) -> tuple: + """ + Build train, validation, and test DataLoaders. + + Scans Parquet files once, then creates three datasets (with different + probe sampling) that share the same lightweight index. + + The split is deterministic given (seed, val_frac, test_frac). These + values must not change between runs if reported metrics are to remain + comparable. + + Parameters + ---------- + parquet_dir : str + Path to directory with chunk_*.parquet files. + cutoff : float + Neighbor cutoff (Angstrom). + train_probes : int + Number of randomly sampled probes per sample during training. + val_probes : int + Number of probes per sample during validation and test. + batch_size : int + Batch size. + val_frac : float + Fraction of data to use for validation. + test_frac : float + Fraction of data to hold out as a test set (never used for model + selection — only evaluated once at the end of training). + num_workers : int + DataLoader workers. + seed : int + Random seed for split reproducibility. + pin_memory : bool + Pin memory for GPU transfer. + + Returns + ------- + train_loader, val_loader, test_loader : DataLoader, DataLoader, DataLoader + """ + # Build the index once, share between all three datasets + shared_index = _build_parquet_index(Path(parquet_dir)) + + train_dataset = LeMatRhoDataset( + cutoff=cutoff, num_probes=train_probes, _shared_index=shared_index + ) + val_dataset = LeMatRhoDataset( + cutoff=cutoff, num_probes=val_probes, _shared_index=shared_index + ) + test_dataset = LeMatRhoDataset( + cutoff=cutoff, num_probes=val_probes, _shared_index=shared_index + ) + + # Split indices (same for all three datasets, different probe sampling) + n = len(train_dataset) + n_val = int(n * val_frac) + n_test = int(n * test_frac) + n_train = n - n_val - n_test + generator = torch.Generator().manual_seed(seed) + train_indices, val_indices, test_indices = random_split( + range(n), [n_train, n_val, n_test], generator=generator + ) + + train_subset = torch.utils.data.Subset(train_dataset, train_indices.indices) + val_subset = torch.utils.data.Subset(val_dataset, val_indices.indices) + test_subset = torch.utils.data.Subset(test_dataset, test_indices.indices) + + collate_fn = partial(collate_list_of_dicts, pin_memory=pin_memory) + + # DDP path: shard the training set across ranks via DistributedSampler. + # Val/test stay non-distributed (each rank evaluates the whole set; only + # rank 0 reports). This wastes V+T compute but keeps eval simple and + # rank-agnostic. The data is tiny (5%+5% of 65k) so it's fine. + train_sampler = None + if distributed: + from torch.utils.data.distributed import DistributedSampler + + train_sampler = DistributedSampler( + train_subset, + shuffle=True, + seed=seed, + drop_last=True, + ) + + train_loader = DataLoader( + train_subset, + batch_size=batch_size, + # shuffle and sampler are mutually exclusive in DataLoader. + shuffle=(train_sampler is None), + sampler=train_sampler, + num_workers=num_workers, + collate_fn=collate_fn, + pin_memory=pin_memory, + ) + val_loader = DataLoader( + val_subset, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + collate_fn=collate_fn, + pin_memory=pin_memory, + ) + test_loader = DataLoader( + test_subset, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + collate_fn=collate_fn, + pin_memory=pin_memory, + ) + + return train_loader, val_loader, test_loader diff --git a/charge3net_ft/model.py b/charge3net_ft/model.py new file mode 100644 index 0000000..9f9429c --- /dev/null +++ b/charge3net_ft/model.py @@ -0,0 +1,143 @@ +""" +ChargE3Net model wrapper for fine-tuning on LeMatRho data. + +Thin wrapper around the official E3DensityModel that handles: +1. Instantiation with the correct MP hyperparameters. +2. Loading pre-trained checkpoint weights (both legacy PL and new format). +3. Forward pass returning predicted density at probe points. +""" + +import sys +from pathlib import Path + +import torch +from torch import nn + +# --------------------------------------------------------------------------- +# charge3net imports +# Expects: /charge3net/ (cloned from AIforGreatGood/charge3net) +# --------------------------------------------------------------------------- +_CHARGE3NET_ROOT = Path(__file__).resolve().parent.parent.parent / "charge3net" +if not _CHARGE3NET_ROOT.exists(): + raise RuntimeError( + f"charge3net repo not found at {_CHARGE3NET_ROOT}.\n" + "Clone it with: git clone https://github.com/AIforGreatGood/charge3net " + f"{_CHARGE3NET_ROOT}" + ) +if str(_CHARGE3NET_ROOT) not in sys.path: + sys.path.insert(0, str(_CHARGE3NET_ROOT)) + +from src.charge3net.models.e3 import E3DensityModel + +# --------------------------------------------------------------------------- +# Default hyperparameters matching the Materials Project checkpoint +# (from configs/charge3net/model/e3_density.yaml) +# --------------------------------------------------------------------------- +MP_MODEL_DEFAULTS = { + "num_interactions": 3, + "num_neighbors": 20, + "mul": 500, + "lmax": 4, + "cutoff": 4.0, + "basis": "gaussian", + "num_basis": 20, +} + + +class ChargE3NetWrapper(nn.Module): + """ + Wrapper around the official E3DensityModel. + + Instantiates the model with hyperparameters matching the pre-trained + Materials Project checkpoint, and provides utilities for loading weights + and running the forward pass. + + Parameters + ---------- + ckpt_path : str or None + Path to a pre-trained checkpoint (.pt file). If provided, weights + are loaded immediately. + model_kwargs : dict + Override any of the default MP hyperparameters. + """ + + def __init__(self, ckpt_path: str | None = None, **model_kwargs): + super().__init__() + + # Merge user overrides with MP defaults + params = {**MP_MODEL_DEFAULTS, **model_kwargs} + self.model = E3DensityModel(**params) + self.cutoff = params["cutoff"] + + if ckpt_path is not None: + self.load_pretrained(ckpt_path) + + def load_pretrained(self, ckpt_path: str): + """ + Load pre-trained weights from a checkpoint file. + + Handles three formats: + 1. Legacy PyTorch Lightning: state_dict keys prefixed with "network." + 2. New charge3net format: checkpoint["model"] contains the state_dict. + 3. Raw state_dict: the file IS the state_dict. + + Note: weights_only=False is required for the legacy PL format which + stores non-tensor objects. The checkpoint is our own internal file + (AIforGreatGood/charge3net repo), not untrusted user input. + """ + ckpt_path = Path(ckpt_path) + if not ckpt_path.exists(): + raise FileNotFoundError(f"Checkpoint not found: {ckpt_path}") + + checkpoint = torch.load(ckpt_path, map_location="cpu", weights_only=False) + + if isinstance(checkpoint, dict): + if "pytorch-lightning_version" in checkpoint: + # Legacy PL format: keys like "network.atom_model.interactions.0...." + state_dict = { + k.replace("network.", ""): v + for k, v in checkpoint["state_dict"].items() + } + print(f"Loaded legacy PL checkpoint from {ckpt_path}") + elif "model" in checkpoint: + # New charge3net trainer format + state_dict = checkpoint["model"] + print(f"Loaded charge3net checkpoint from {ckpt_path}") + elif "state_dict" in checkpoint: + # Generic PL format without version key + state_dict = { + k.replace("network.", ""): v + for k, v in checkpoint["state_dict"].items() + } + print(f"Loaded state_dict checkpoint from {ckpt_path}") + else: + # Assume the dict IS the state_dict + state_dict = checkpoint + print(f"Loaded raw state_dict from {ckpt_path}") + else: + raise TypeError(f"Unexpected checkpoint format: {type(checkpoint)}") + + missing, unexpected = self.model.load_state_dict(state_dict, strict=False) + if missing: + print(f" Warning: {len(missing)} missing keys: {missing[:5]}...") + if unexpected: + print(f" Warning: {len(unexpected)} unexpected keys: {unexpected[:5]}...") + + def forward(self, input_dict: dict) -> torch.Tensor: + """ + Run the E3DensityModel forward pass. + + Parameters + ---------- + input_dict : dict + Batch dict with keys matching charge3net's expected format: + nodes, atom_xyz, cell, atom_edges, atom_edges_displacement, + num_nodes, num_atom_edges, probe_xyz, probe_edges, + probe_edges_displacement, num_probes, num_probe_edges. + + Returns + ------- + torch.Tensor + Predicted density at probe points, shape [B, max_probes]. + """ + return self.model(input_dict) diff --git a/charge3net_ft/train.py b/charge3net_ft/train.py new file mode 100644 index 0000000..95035b5 --- /dev/null +++ b/charge3net_ft/train.py @@ -0,0 +1,644 @@ +""" +Training script for fine-tuning ChargE3Net on LeMatRho charge density data. + +Usage: + python -m charge3net_ft.train \ + --parquet-dir /path/to/lematrho_full_10x10x10 \ + --ckpt-path /path/to/charge3net/models/charge3net_mp.pt \ + --epochs 50 + + # Or set LEMATRHO_DATA_DIR env var and omit --parquet-dir. + +To do a quick smoke test (1 batch, no checkpoint): + python -m charge3net_ft.train \ + --parquet-dir /path/to/lematrho_full_10x10x10 \ + --smoke-test +""" + +import argparse +import os +import random +import sys +import time +from pathlib import Path + +import numpy as np +import torch +import torch.nn.functional as F +import wandb +from dotenv import load_dotenv + +# --------------------------------------------------------------------------- +# charge3net imports (for the LR scheduler) +# --------------------------------------------------------------------------- +_CHARGE3NET_ROOT = Path(__file__).resolve().parent.parent.parent / "charge3net" +if not _CHARGE3NET_ROOT.exists(): + raise RuntimeError( + f"charge3net repo not found at {_CHARGE3NET_ROOT}.\n" + "Clone it with: git clone https://github.com/AIforGreatGood/charge3net " + f"{_CHARGE3NET_ROOT}" + ) +if str(_CHARGE3NET_ROOT) not in sys.path: + sys.path.insert(0, str(_CHARGE3NET_ROOT)) + +from src.charge3net.models.scheduler import PowerDecayScheduler + +from .data import build_dataloaders +from .model import ChargE3NetWrapper + + +# --------------------------------------------------------------------------- +# Distributed training helpers +# --------------------------------------------------------------------------- +def _is_ddp() -> bool: + """True if SLURM/torchrun has set up multi-process training.""" + return int(os.environ.get("WORLD_SIZE", "1")) > 1 + + +def _setup_ddp() -> tuple[int, int, int]: + """Initialize the process group and return (rank, local_rank, world_size). + + No-op (returns 0, 0, 1) if we're not in a distributed environment. + + The submit script is expected to export the standard torch env vars from + SLURM: + WORLD_SIZE = $SLURM_NTASKS + RANK = $SLURM_PROCID + LOCAL_RANK = $SLURM_LOCALID + MASTER_ADDR = $(scontrol show hostname $SLURM_NODELIST | head -1) + MASTER_PORT = some unused port (e.g. 29500) + """ + if not _is_ddp(): + return 0, 0, 1 + rank = int(os.environ["RANK"]) + local_rank = int(os.environ["LOCAL_RANK"]) + world_size = int(os.environ["WORLD_SIZE"]) + # nccl works on AMD ROCm because PyTorch routes it through RCCL. + torch.distributed.init_process_group(backend="nccl") + torch.cuda.set_device(local_rank) + return rank, local_rank, world_size + + +def _is_main(rank: int) -> bool: + """True on rank 0; used to gate prints, wandb, and checkpoint saves.""" + return rank == 0 + + +def _probe_mask(targets: torch.Tensor, num_probes: torch.Tensor) -> torch.Tensor: + """Boolean mask [B, max_probes], True for real probe points (not padding).""" + return ( + torch.arange(targets.shape[1], device=targets.device)[None] + < num_probes[:, None] + ) + + +def compute_nmape( + preds: torch.Tensor, targets: torch.Tensor, num_probes: torch.Tensor = None +) -> torch.Tensor: + """ + Integral-Normalized Mean Absolute Percentage Error (%). + + NMAPE = sum(|target - pred|) / sum(|target|) * 100 + + Computed per-sample in the batch, then averaged. + This is charge3net's primary validation metric. + + Parameters + ---------- + num_probes : torch.Tensor, optional + Shape [B]. If provided, masks out zero-padding before computing + metrics (required when samples have variable probe counts). + """ + if num_probes is not None: + mask = _probe_mask(targets, num_probes) + diff = (torch.abs(targets - preds) * mask).sum(dim=1) + denom = (torch.abs(targets) * mask).sum(dim=1) + 1e-10 + else: + diff = torch.abs(targets - preds).sum(dim=1) + denom = torch.abs(targets).sum(dim=1) + 1e-10 + return (diff / denom * 100.0).mean() + + +def compute_rmse( + preds: torch.Tensor, targets: torch.Tensor, num_probes: torch.Tensor = None +) -> torch.Tensor: + """Root Mean Squared Error (e/ų), averaged over the batch.""" + if num_probes is not None: + mask = _probe_mask(targets, num_probes) + n = mask.sum(dim=1).float() + mse = ((targets - preds) ** 2 * mask).sum(dim=1) / (n + 1e-10) + else: + mse = ((targets - preds) ** 2).mean(dim=1) + return mse.sqrt().mean() + + +def compute_nrmse( + preds: torch.Tensor, targets: torch.Tensor, num_probes: torch.Tensor = None +) -> torch.Tensor: + """Normalized RMSE (%) — RMSE / mean(|target|) * 100, per-sample then averaged.""" + if num_probes is not None: + mask = _probe_mask(targets, num_probes) + n = mask.sum(dim=1).float() + mse = ((targets - preds) ** 2 * mask).sum(dim=1) / (n + 1e-10) + mean_abs = (torch.abs(targets) * mask).sum(dim=1) / (n + 1e-10) + else: + mse = ((targets - preds) ** 2).mean(dim=1) + mean_abs = torch.abs(targets).mean(dim=1) + rmse = mse.sqrt() + return (rmse / (mean_abs + 1e-10) * 100.0).mean() + + +def train_one_epoch( + model, + train_loader, + optimizer, + scheduler, + device, + global_step, + log_every=50, + use_wandb=False, +): + """Run one training epoch, return (average loss, updated global_step).""" + model.train() + total_loss = 0.0 + n_batches = 0 + + for i, batch in enumerate(train_loader): + batch = { + k: v.to(device) if isinstance(v, torch.Tensor) else v + for k, v in batch.items() + } + + preds = model(batch) + loss = F.l1_loss(preds, batch["probe_target"]) + + optimizer.zero_grad() + loss.backward() + optimizer.step() + scheduler.step() + + total_loss += loss.item() + n_batches += 1 + global_step += 1 + + if (i + 1) % log_every == 0: + lr = optimizer.param_groups[0]["lr"] + print(f" step {i + 1}: loss={loss.item():.6f} lr={lr:.2e}") + if use_wandb: + wandb.log({"train/loss_step": loss.item(), "lr": lr}, step=global_step) + + return total_loss / max(n_batches, 1), global_step + + +@torch.no_grad() +def validate(model, loader, device): + """Run evaluation, return average L1, NMAPE, RMSE, and NRMSE.""" + model.eval() + total_loss = 0.0 + total_nmape = 0.0 + total_rmse = 0.0 + total_nrmse = 0.0 + n_batches = 0 + + for batch in loader: + batch = { + k: v.to(device) if isinstance(v, torch.Tensor) else v + for k, v in batch.items() + } + + preds = model(batch) + targets = batch["probe_target"] + num_probes = batch.get("num_probes") + + total_loss += F.l1_loss(preds, targets).item() + total_nmape += compute_nmape(preds, targets, num_probes).item() + total_rmse += compute_rmse(preds, targets, num_probes).item() + total_nrmse += compute_nrmse(preds, targets, num_probes).item() + n_batches += 1 + + denom = max(n_batches, 1) + return { + "L1": total_loss / denom, + "NMAPE": total_nmape / denom, + "RMSE": total_rmse / denom, + "NRMSE": total_nrmse / denom, + } + + +def _unwrap(model): + """Return the underlying ChargE3NetWrapper regardless of DDP wrapping. + + DistributedDataParallel wraps the user model in a ``.module`` attribute; + state_dict() and load_state_dict() should always target the inner model + so checkpoints are interchangeable between single-GPU and DDP runs. + """ + return model.module if hasattr(model, "module") else model + + +def save_checkpoint(model, optimizer, scheduler, epoch, best_nmape, global_step, path): + """Save training checkpoint (rank 0 should be the only caller in DDP).""" + torch.save( + { + "epoch": epoch, + "model": _unwrap(model).model.state_dict(), + "optimizer": optimizer.state_dict(), + "scheduler": scheduler.state_dict(), + "best_nmape": best_nmape, + "global_step": global_step, + }, + path, + ) + + +def load_checkpoint(path, model, optimizer, scheduler, device): + """Load training checkpoint, return (start_epoch, best_nmape, global_step).""" + ckpt = torch.load(path, map_location=device, weights_only=False) + _unwrap(model).model.load_state_dict(ckpt["model"]) + optimizer.load_state_dict(ckpt["optimizer"]) + scheduler.load_state_dict(ckpt["scheduler"]) + start_epoch = ckpt["epoch"] + 1 + best_nmape = ckpt["best_nmape"] + global_step = ckpt.get("global_step", 0) + lr = optimizer.param_groups[0]["lr"] + print( + f"Resumed from checkpoint: epoch {start_epoch}, " + f"best_nmape={best_nmape:.2f}%, step={global_step}, lr={lr:.2e}" + ) + return start_epoch, best_nmape, global_step + + +def main(): + load_dotenv() # load .env (WANDB_API_KEY, etc.) + + parser = argparse.ArgumentParser(description="Fine-tune ChargE3Net on LeMatRho") + parser.add_argument( + "--parquet-dir", + type=str, + default=os.environ.get("LEMATRHO_DATA_DIR"), + help=( + "Directory with chunk_*.parquet files. " + "Defaults to $LEMATRHO_DATA_DIR env var." + ), + ) + parser.add_argument( + "--ckpt-path", type=str, default=None, help="Pre-trained checkpoint (.pt)" + ) + parser.add_argument( + "--save-dir", type=str, default="./checkpoints", help="Save directory" + ) + parser.add_argument("--cutoff", type=float, default=4.0, help="Neighbor cutoff (A)") + parser.add_argument( + "--train-probes", type=int, default=200, help="Probes per sample (train)" + ) + parser.add_argument( + "--val-probes", type=int, default=1000, help="Probes per sample (val/test)" + ) + parser.add_argument("--batch-size", type=int, default=4, help="Batch size") + parser.add_argument("--lr", type=float, default=5e-4, help="Learning rate") + parser.add_argument("--epochs", type=int, default=50, help="Number of epochs") + parser.add_argument( + "--val-frac", + type=float, + default=0.05, + help="Validation fraction. Do not change after first run.", + ) + parser.add_argument( + "--test-frac", + type=float, + default=0.05, + help="Test fraction (held out, evaluated once at end). " + "Do not change after first run.", + ) + parser.add_argument("--num-workers", type=int, default=4, help="DataLoader workers") + parser.add_argument("--seed", type=int, default=42, help="Random seed") + parser.add_argument("--log-every", type=int, default=50, help="Log every N steps") + parser.add_argument( + "--smoke-test", action="store_true", help="Run 1 forward pass and exit" + ) + parser.add_argument( + "--overfit-single-batch", + action="store_true", + help="Overfit on a single batch to verify the model can learn", + ) + parser.add_argument( + "--device", + type=str, + default=None, + help="Force device (cpu, cuda, mps). Auto-detect if not set.", + ) + parser.add_argument( + "--resume-from", + type=str, + default=None, + help="Path to training checkpoint (latest.pt) to resume from", + ) + parser.add_argument("--wandb-project", type=str, default="lemat-rho-charge3net") + parser.add_argument("--wandb-entity", type=str, default="dtts") + parser.add_argument("--no-wandb", action="store_true", help="Disable W&B logging") + parser.add_argument( + "--wandb-mode", + type=str, + default="online", + choices=["online", "offline", "disabled"], + help="W&B mode (use 'offline' on air-gapped clusters)", + ) + args = parser.parse_args() + + if args.parquet_dir is None: + parser.error( + "--parquet-dir is required (or set the LEMATRHO_DATA_DIR environment variable)" + ) + + # Seed everything for reproducibility + torch.manual_seed(args.seed) + np.random.seed(args.seed) + random.seed(args.seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(args.seed) + + # DDP setup (no-op when WORLD_SIZE=1). Must happen before device + # selection because each rank pins itself to its own GPU via local_rank. + rank, local_rank, world_size = _setup_ddp() + is_main = _is_main(rank) + + # Device + if args.device: + device = torch.device(args.device) + elif _is_ddp(): + device = torch.device(f"cuda:{local_rank}") + elif torch.cuda.is_available(): + device = torch.device("cuda") + else: + device = torch.device("cpu") + if is_main: + print(f"Using device: {device}; world_size={world_size}") + + # W&B (rank 0 only). Soft-fail: if init times out (e.g. compute node + # can't reach api.wandb.ai through the cluster proxy), degrade to + # disabled mode and keep training. Used to be fatal — caused the + # 1h47m job 4969727 timeout-then-crash on Adastra. + use_wandb = (not args.no_wandb and not args.smoke_test) and is_main + if use_wandb: + try: + wandb.init( + project=args.wandb_project, + entity=args.wandb_entity, + config=vars(args), + settings=wandb.Settings(init_timeout=300), + mode=args.wandb_mode, + ) + except Exception as e: # noqa: BLE001 — really do want broad here + print( + f"WARNING: wandb.init failed ({type(e).__name__}: {e}); " + "continuing with wandb disabled. Training output is still " + "saved to checkpoints + stdout." + ) + use_wandb = False + + # Data + if is_main: + print("Building dataloaders...") + train_loader, val_loader, test_loader = build_dataloaders( + parquet_dir=args.parquet_dir, + cutoff=args.cutoff, + train_probes=args.train_probes, + val_probes=args.val_probes, + batch_size=args.batch_size, + val_frac=args.val_frac, + test_frac=args.test_frac, + num_workers=args.num_workers, + seed=args.seed, + distributed=_is_ddp(), + ) + if is_main: + print( + f"Train: {len(train_loader.dataset)} samples, " + f"Val: {len(val_loader.dataset)} samples, " + f"Test: {len(test_loader.dataset)} samples" + ) + + # Model. Loaded on every rank (each gets its own copy of the weights); + # DDP will sync gradients across ranks at backward. + if is_main: + print("Initializing ChargE3Net...") + model = ChargE3NetWrapper(ckpt_path=args.ckpt_path, cutoff=args.cutoff) + model = model.to(device) + if _is_ddp(): + model = torch.nn.parallel.DistributedDataParallel( + model, device_ids=[local_rank], output_device=local_rank + ) + n_params = sum( + p.numel() for p in (model.module if _is_ddp() else model).parameters() + ) + if is_main: + print(f"Model parameters: {n_params:,}") + + # Smoke test: just run one forward pass + if args.smoke_test: + print("\n--- Smoke test ---") + model.eval() + batch = next(iter(train_loader)) + batch = { + k: v.to(device) if isinstance(v, torch.Tensor) else v + for k, v in batch.items() + } + print(f"Batch keys: {list(batch.keys())}") + for k, v in batch.items(): + if isinstance(v, torch.Tensor): + print(f" {k}: shape={v.shape} dtype={v.dtype}") + with torch.no_grad(): + preds = model(batch) + print(f"Predictions shape: {preds.shape}") + loss = F.l1_loss(preds, batch["probe_target"]) + print(f"L1 loss: {loss.item():.6f}") + print("Smoke test passed.") + return + + # Optimizer + optimizer = torch.optim.Adam(model.parameters(), lr=args.lr) + + # ----------------------------------------------------------------------- + # Single-batch overfit test + # ----------------------------------------------------------------------- + if args.overfit_single_batch: + print("\n--- Single-batch overfit test ---") + print(f"lr={args.lr} epochs={args.epochs} probes={args.train_probes}") + + # Fetch exactly one batch and pin it to device + fixed_batch = next(iter(train_loader)) + fixed_batch = { + k: v.to(device) if isinstance(v, torch.Tensor) else v + for k, v in fixed_batch.items() + } + n_atoms = fixed_batch["num_nodes"].tolist() + n_probes = fixed_batch["num_probes"].tolist() + print(f"Batch: {len(n_atoms)} samples, atoms={n_atoms}, probes={n_probes}") + + model.train() + for epoch in range(1, args.epochs + 1): + preds = model(fixed_batch) + targets = fixed_batch["probe_target"] + num_probes = fixed_batch.get("num_probes") + loss = F.l1_loss(preds, targets) + optimizer.zero_grad() + loss.backward() + optimizer.step() + + nmape = compute_nmape(preds, targets, num_probes) + rmse = compute_rmse(preds, targets, num_probes) + nrmse = compute_nrmse(preds, targets, num_probes) + print( + f"Epoch {epoch:>4d}/{args.epochs} L1={loss.item():.6f} " + f"NMAPE={nmape.item():.2f}% RMSE={rmse.item():.4f} NRMSE={nrmse.item():.2f}%" + ) + if use_wandb: + wandb.log( + { + "overfit/L1": loss.item(), + "overfit/NMAPE": nmape.item(), + "overfit/RMSE": rmse.item(), + "overfit/NRMSE": nrmse.item(), + "epoch": epoch, + } + ) + + print("\nOverfit test complete.") + if use_wandb: + wandb.finish() + return + + # ----------------------------------------------------------------------- + # Normal training loop + # ----------------------------------------------------------------------- + # charge3net's power decay scheduler: lr = alpha^(step/beta) + # For fine-tuning we use a gentler decay (higher beta = slower decay) + scheduler = PowerDecayScheduler(optimizer, alpha=0.96, beta=5e4) + + save_dir = Path(args.save_dir) + save_dir.mkdir(parents=True, exist_ok=True) + best_nmape = float("inf") + + global_step = 0 + start_epoch = 0 + + if args.resume_from: + start_epoch, best_nmape, global_step = load_checkpoint( + args.resume_from, + model, + optimizer, + scheduler, + device, + ) + + if is_main: + print(f"\nStarting training from epoch {start_epoch + 1} to {args.epochs}...") + for epoch in range(start_epoch, args.epochs): + # DDP requires set_epoch on the sampler each epoch for proper shuffling. + if _is_ddp() and hasattr(train_loader.sampler, "set_epoch"): + train_loader.sampler.set_epoch(epoch) + t0 = time.time() + train_loss, global_step = train_one_epoch( + model, + train_loader, + optimizer, + scheduler, + device, + global_step, + log_every=args.log_every, + use_wandb=use_wandb, + ) + val = validate(model, val_loader, device) + elapsed = time.time() - t0 + + if is_main: + print( + f"Epoch {epoch + 1}/{args.epochs} " + f"train_L1={train_loss:.6f} " + f"val_L1={val['L1']:.6f} " + f"val_NMAPE={val['NMAPE']:.2f}% " + f"val_RMSE={val['RMSE']:.4f} " + f"val_NRMSE={val['NRMSE']:.2f}% " + f"time={elapsed:.0f}s" + ) + + if use_wandb: + wandb.log( + { + "train/L1": train_loss, + "val/L1": val["L1"], + "val/NMAPE": val["NMAPE"], + "val/RMSE": val["RMSE"], + "val/NRMSE": val["NRMSE"], + "epoch": epoch + 1, + }, + step=global_step, + ) + + # Save best checkpoint (selected on val NMAPE). Only rank 0 writes. + if is_main and val["NMAPE"] < best_nmape: + best_nmape = val["NMAPE"] + save_checkpoint( + model, + optimizer, + scheduler, + epoch, + best_nmape, + global_step, + save_dir / "best.pt", + ) + print(f" -> New best val NMAPE: {best_nmape:.2f}%") + + # Save latest checkpoint every epoch (for SLURM resumption). + if is_main: + save_checkpoint( + model, + optimizer, + scheduler, + epoch, + best_nmape, + global_step, + save_dir / "latest.pt", + ) + + # Keep ranks in lockstep so a slow saver doesn't get lapped. + if _is_ddp(): + torch.distributed.barrier() + + # ----------------------------------------------------------------------- + # Test set evaluation — run once at the end using the best checkpoint. + # These numbers are the uncontaminated held-out performance estimate. + # ----------------------------------------------------------------------- + print("\nLoading best checkpoint for test evaluation...") + best_ckpt_path = save_dir / "best.pt" + if best_ckpt_path.exists(): + load_checkpoint(best_ckpt_path, model, optimizer, scheduler, device) + else: + print(" Warning: best.pt not found, using current model weights.") + + test = validate(model, test_loader, device) + print( + f"\nTest set results (best.pt, held-out):\n" + f" L1={test['L1']:.6f} NMAPE={test['NMAPE']:.2f}% " + f"RMSE={test['RMSE']:.4f} NRMSE={test['NRMSE']:.2f}%" + ) + if use_wandb: + wandb.log( + { + "test/L1": test["L1"], + "test/NMAPE": test["NMAPE"], + "test/RMSE": test["RMSE"], + "test/NRMSE": test["NRMSE"], + } + ) + + if is_main: + print(f"\nTraining complete. Best val NMAPE: {best_nmape:.2f}%") + print(f"Checkpoints saved to {save_dir}") + if use_wandb: + wandb.finish() + if _is_ddp(): + torch.distributed.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/deepdft_ft/__init__.py b/deepdft_ft/__init__.py new file mode 100644 index 0000000..da6fdd4 --- /dev/null +++ b/deepdft_ft/__init__.py @@ -0,0 +1,5 @@ +"""DeepDFT (peterbjorgensen/DeepDFT) fine-tuning glue for LeMat-Rho. + +Mirrors ``charge3net_ft/`` in structure: the data loader reuses ``charge3net_ft``'s +parquet helpers and adapts the per-sample shape to DeepDFT's dict contract. +""" diff --git a/deepdft_ft/data.py b/deepdft_ft/data.py new file mode 100644 index 0000000..a9d2b83 --- /dev/null +++ b/deepdft_ft/data.py @@ -0,0 +1,237 @@ +"""LeMat-Rho → DeepDFT data adapter. + +DeepDFT's ``runner.py`` expects a ``torch.utils.data.Dataset`` that yields +per-sample dicts of the form:: + + { + "density": np.ndarray (Nx, Ny, Nz), + "atoms": ase.Atoms, + "origin": np.ndarray (3,), + "grid_position": np.ndarray (Nx, Ny, Nz, 3), + "metadata": {"filename": str, ...}, + } + +That dict is fed into DeepDFT's ``CollateFuncRandomSample`` which samples +random probe points, builds the atom/probe graph via asap3, and pads the +batch. The only thing we provide is a path from a directory of LeMat-Rho +parquet chunks to that dict shape. + +The parquet schema, the index building, and the row → (atoms, density, origin) +conversion live in ``charge3net_ft.data`` and are reused verbatim. Keeping a +single source of truth for the input pipeline means a future Bader/extra-column +addition only needs one regression test. +""" + +from __future__ import annotations + +import collections +import math +from pathlib import Path + +import numpy as np +import pyarrow.parquet as pq +from torch.utils.data import Dataset + +from charge3net_ft.data import ( + _COLUMNS, + _build_parquet_index, + _row_to_atoms_and_density, +) + +# Per-worker cache, separate from charge3net_ft's so the two pipelines don't +# step on each other when running side by side in the same process. +# +# Bounded LRU, ported from charge3net_ft.data: the unbounded dict version of +# this cache held one decompressed pyarrow table (~2 GB with the inflated +# compressed_charge_density strings) per chunk file forever, the same failure +# mode that OOM-killed charge3net jobs 4971293/4971343. Cap of 5 chunks keeps +# each worker's cache around 10 GB worst case. OrderedDict gives O(1) LRU. +_DEEPDFT_TABLE_CACHE_MAX_CHUNKS = 5 +_DEEPDFT_TABLE_CACHE: collections.OrderedDict[str, object] = collections.OrderedDict() + + +def sample_probe_indices( + n_grid_points: int, + n_probes: int, + rng: np.random.Generator | None = None, +) -> np.ndarray: + """Sample flat grid indices for density probes, capped at ``n_probes``. + + Upstream DeepDFT samples probes WITH replacement + (``np.random.randint``), which on a 1000-point LeMat-Rho grid with + 5000 probes yields 5x duplicates for zero statistical value while + ``probes_to_graph`` cost grows quadratically in the probe count + (the host-RAM OOM in job 5004725). This helper samples without + replacement whenever the grid has at least ``n_probes`` points, and + always returns exactly ``n_probes`` indices (upstream padding/eval + assumes a uniform probe count per sample, so grids smaller than the + cap fall back to with-replacement sampling rather than shrinking). + + Kept here, DeepDFT-import-free, so it stays testable without the + upstream clone on sys.path (same rationale as ``_calculate_grid_pos``). + + Parameters + ---------- + n_grid_points : int + Total number of grid points (``prod(grid_shape)``). + n_probes : int + Number of probe indices to draw. + rng : np.random.Generator, optional + Source of randomness; a fresh default generator when omitted. + + Returns + ------- + indices : np.ndarray of shape (n_probes,) + Flat indices into the grid, unique when + ``n_grid_points >= n_probes``. + + .. code-block:: python + + flat = sample_probe_indices(15**3, 1000) + probe_choice = np.unravel_index(flat, grid_pos.shape[0:3]) + """ + if rng is None: + rng = np.random.default_rng() + if n_grid_points >= n_probes: + return rng.choice(n_grid_points, size=n_probes, replace=False) + return rng.integers(n_grid_points, size=n_probes) + + +def generate_datasplits(datalen: int, seed: int = 0) -> dict[str, list[int]]: + """Generate the runner's 95/5 train/validation split of ``datalen`` rows. + + Upstream DeepDFT drew ``np.random.permutation`` unseeded, so a job that + restarts with ``--load_model`` and no ``--split_file`` (exactly what + submit_deepdft_adastra.sh does) regenerated a DIFFERENT split and leaked + previous validation rows into train; DDP ranks could disagree the same + way. Seeding makes the split a pure function of (datalen, seed). + + Kept here, DeepDFT-import-free, so it stays testable without the + upstream clone on sys.path (same rationale as ``sample_probe_indices``). + + Parameters + ---------- + datalen : int + Total number of samples in the dataset. + seed : int + Split RNG seed (the runner's ``--split-seed``, default 0). + + Returns + ------- + dict + ``{"train": [...], "validation": [...]}`` index lists; validation + holds ``ceil(0.05 * datalen)`` rows, matching upstream. + + .. code-block:: python + + splits = generate_datasplits(len(dataset), seed=args.split_seed) + """ + num_validation = math.ceil(datalen * 0.05) + indices = np.random.default_rng(seed).permutation(datalen) + return { + "train": indices[num_validation:].tolist(), + "validation": indices[:num_validation].tolist(), + } + + +def _calculate_grid_pos(density: np.ndarray, origin: np.ndarray, cell) -> np.ndarray: + """Cartesian probe positions for an (Nx, Ny, Nz) density grid. + + Same formula DeepDFT uses internally (see DeepDFT/dataset.py:_calculate_grid_pos). + Kept here so we don't need DeepDFT importable at test time. + + Parameters + ---------- + density : np.ndarray of shape (Nx, Ny, Nz) + Used only for its shape. + origin : np.ndarray of shape (3,) + Cell-frame origin in Cartesian coordinates. + cell : ASE Cell or 3x3 array + Lattice vectors as rows. + + Returns + ------- + grid_pos : np.ndarray of shape (Nx, Ny, Nz, 3) + Cartesian coordinates of every grid point. + """ + ngridpts = np.array(density.shape) + grid_pos = np.meshgrid( + np.arange(ngridpts[0]) / density.shape[0], + np.arange(ngridpts[1]) / density.shape[1], + np.arange(ngridpts[2]) / density.shape[2], + indexing="ij", + ) + grid_pos = np.stack(grid_pos, 3) + grid_pos = np.dot(grid_pos, np.asarray(cell)) + grid_pos = grid_pos + origin + return grid_pos + + +class LeMatRhoDeepDFTDataset(Dataset): + """Iterate LeMat-Rho parquet chunks as DeepDFT-shaped sample dicts. + + Parameters + ---------- + parquet_dir : str or Path + Directory containing ``chunk_*.parquet`` files. + _shared_index : tuple, optional + Internal: pre-built (file_paths, index) tuple shared between + train/val splits to avoid scanning files twice. + """ + + def __init__( + self, + parquet_dir: str | Path | None = None, + _shared_index: tuple | None = None, + ): + if _shared_index is not None: + self._file_paths, self._index = _shared_index + else: + if parquet_dir is None: + raise ValueError("Must provide parquet_dir or _shared_index") + self._file_paths, self._index = _build_parquet_index(Path(parquet_dir)) + + def __len__(self) -> int: + return len(self._index) + + def _read_row(self, idx: int) -> dict: + """Lazy per-worker chunk caching, mirrors charge3net_ft.data. + + Cache is keyed by the absolute parquet path (not the integer ``fi``) + so multiple ``LeMatRhoDeepDFTDataset`` instances pointing at different + directories don't collide on ``fi=0``. Capped at + ``_DEEPDFT_TABLE_CACHE_MAX_CHUNKS`` entries; on a miss past capacity + the least recently used chunk is evicted. + """ + fi, ri = self._index[idx] + key = str(self._file_paths[fi].resolve()) + if key in _DEEPDFT_TABLE_CACHE: + # Refresh recency on hit so hot chunks survive eviction. + _DEEPDFT_TABLE_CACHE.move_to_end(key) + else: + if len(_DEEPDFT_TABLE_CACHE) >= _DEEPDFT_TABLE_CACHE_MAX_CHUNKS: + _DEEPDFT_TABLE_CACHE.popitem(last=False) + _DEEPDFT_TABLE_CACHE[key] = pq.read_table( + self._file_paths[fi], columns=_COLUMNS + ) + table = _DEEPDFT_TABLE_CACHE[key] + return {col: table.column(col)[ri].as_py() for col in _COLUMNS} + + def __getitem__(self, idx: int) -> dict: + row = self._read_row(idx) + atoms, density, origin = _row_to_atoms_and_density(row) + grid_pos = _calculate_grid_pos(density, origin, atoms.get_cell()) + + # Index-derived filename so DeepDFT logs stay distinguishable across + # samples. Format mirrors the tar member names DeepDFT normally sees. + fi, ri = self._index[idx] + chunk_stem = Path(self._file_paths[fi]).stem # e.g. "chunk_000017" + filename = f"{chunk_stem}_row{ri:06d}.parquet" + + return { + "density": density, + "atoms": atoms, + "origin": origin, + "grid_position": grid_pos, + "metadata": {"filename": filename}, + } diff --git a/deepdft_ft/runner.py b/deepdft_ft/runner.py new file mode 100644 index 0000000..20a691b --- /dev/null +++ b/deepdft_ft/runner.py @@ -0,0 +1,697 @@ +"""DeepDFT training runner — vendored from peterbjorgensen/DeepDFT@main. + +Vendored rather than monkey-patched because the DDP integration touches +many points throughout `main()` (dataset construction, model wrap, +sampler, checkpoint save, logging gates). Keeping the patched copy here +makes the delta auditable and the code testable. + +Diff vs upstream: +- Adds DDP setup via `_setup_ddp`/`_is_main` helpers (mirrors the pattern + used in `charge3net_ft/train.py`). DDP activates iff `WORLD_SIZE>1`. +- Detects parquet directories and uses `LeMatRhoDeepDFTDataset` instead + of `dataset.DensityData`. Other arg formats are passed through to + upstream unchanged so the runner still works on the original tar/dir + datasets. +- `RandomSampler` swapped for `DistributedSampler` when DDP active. +- Model wrapped in `DistributedDataParallel`; checkpoint save/load unwraps + via `_unwrap`. +- Logging + checkpoint writes gated on rank 0. +- Validation uses `ValCollateFuncRandomSample` (capped probe count via + `--val-probes`, sampled without replacement) over a deterministic + seeded subsample of the val split (`--val-max-samples`). Upstream's + 5000-probe with-replacement val collate OOM-killed job 5004725. +- Generated train/val splits are seeded (`--split-seed`, default 0) via + `generate_datasplits` instead of upstream's unseeded + `np.random.permutation`, so restarts with `--load_model` and all DDP + ranks reproduce the same split without a `--split_file`. +""" + +from __future__ import annotations + +import argparse +import itertools +import json +import logging +import math +import os +import sys +import timeit +from pathlib import Path + +import numpy as np +import torch +import torch.utils.data +from torch.utils.data.distributed import DistributedSampler + +torch.set_num_threads(1) # Try to avoid thread overload on cluster + +# --------------------------------------------------------------------------- +# Make the DeepDFT sibling repo importable. Expected layout (mirrors +# how charge3net is set up): +# / <-- LeMat-Rho +# /../DeepDFT/ <-- AIforGreatGood/DeepDFT clone +# --------------------------------------------------------------------------- +_DEEPDFT_ROOT = Path(__file__).resolve().parent.parent.parent / "DeepDFT" +if not _DEEPDFT_ROOT.exists(): + raise RuntimeError( + f"DeepDFT repo not found at {_DEEPDFT_ROOT}.\n" + "Clone it with: git clone https://github.com/peterbjorgensen/DeepDFT " + f"{_DEEPDFT_ROOT}" + ) +if str(_DEEPDFT_ROOT) not in sys.path: + sys.path.insert(0, str(_DEEPDFT_ROOT)) + +# --------------------------------------------------------------------------- +# Stub `asap3` if it isn't available. Building asap3 from source requires +# Python.h which isn't installed on Adastra (and getting it would need +# admin). Upstream DeepDFT supports an ASE-based fallback via +# `AseNeigborListWrapper`; we expose the same interface from `asap3.FullNeighborList` +# so the upstream `import asap3 ; asap3.FullNeighborList(...)` calls work. +# --------------------------------------------------------------------------- +try: + import asap3 # noqa: F401 +except ImportError: + import types + + import ase.neighborlist + import numpy as np + + _asap3_stub = types.ModuleType("asap3") + + class _AseFullNeighborList: + """Drop-in `asap3.FullNeighborList` replacement using ASE primitives. + + Behaviourally equivalent for DeepDFT's use case: ``get_neighbors(i, cutoff)`` + returns ``(indices, rel_positions, dist2)`` arrays. Much slower than real + asap3 but works without C++ headers. + """ + + def __init__(self, cutoff, atoms): + self._cutoff = cutoff + self._positions = atoms.get_positions() + self._cell = np.asarray(atoms.get_cell()) + nl = ase.neighborlist.NewPrimitiveNeighborList( + cutoff, skin=0.0, self_interaction=False, bothways=True + ) + nl.build(atoms.get_pbc(), atoms.get_cell(), atoms.get_positions()) + self._nl = nl + + def get_neighbors(self, i, cutoff): + assert cutoff == self._cutoff, ( + "cutoff must match the one used at FullNeighborList init" + ) + indices, offsets = self._nl.get_neighbors(i) + rel_positions = ( + self._positions[indices] + offsets @ self._cell - self._positions[i] + ) + dist2 = (rel_positions**2).sum(axis=1) + return indices, rel_positions, dist2 + + _asap3_stub.FullNeighborList = _AseFullNeighborList + sys.modules["asap3"] = _asap3_stub + +import dataset +import densitymodel + +from deepdft_ft.data import ( + LeMatRhoDeepDFTDataset, + generate_datasplits, + sample_probe_indices, +) + + +# --------------------------------------------------------------------------- +# Distributed-training helpers (same pattern as charge3net_ft/train.py). +# --------------------------------------------------------------------------- +def _is_ddp() -> bool: + return int(os.environ.get("WORLD_SIZE", "1")) > 1 + + +def _setup_ddp() -> tuple[int, int, int]: + """Returns (rank, local_rank, world_size). No-op when WORLD_SIZE=1.""" + if not _is_ddp(): + return 0, 0, 1 + rank = int(os.environ["RANK"]) + local_rank = int(os.environ["LOCAL_RANK"]) + world_size = int(os.environ["WORLD_SIZE"]) + # nccl routes through RCCL on AMD ROCm builds. + torch.distributed.init_process_group(backend="nccl") + torch.cuda.set_device(local_rank) + return rank, local_rank, world_size + + +def _is_main(rank: int) -> bool: + return rank == 0 + + +def _unwrap(model: torch.nn.Module) -> torch.nn.Module: + """Strip DistributedDataParallel for state_dict access.""" + return model.module if hasattr(model, "module") else model + + +def _is_parquet_dir(path: str | Path) -> bool: + """LeMat-Rho parquet dirs contain ``chunk_*.parquet``; tar/cube paths don't.""" + p = Path(path) + return p.is_dir() and any(p.glob("chunk_*.parquet")) + + +def get_arguments(arg_list=None): + parser = argparse.ArgumentParser( + description="Train graph convolution network", fromfile_prefix_chars="+" + ) + parser.add_argument( + "--load_model", + type=str, + default=None, + help="Load model parameters from previous run", + ) + parser.add_argument( + "--cutoff", + type=float, + default=5.0, + help="Atomic interaction cutoff distance [Å]", + ) + parser.add_argument( + "--split_file", + type=str, + default=None, + help="Train/test/validation split file json", + ) + parser.add_argument( + "--split-seed", + type=int, + default=0, + help="Seed for the generated train/val split (used when no " + "--split_file is given). Fixed default so restarts with " + "--load_model and all DDP ranks reproduce the same split", + ) + parser.add_argument( + "--num_interactions", + type=int, + default=3, + help="Number of interaction layers used", + ) + parser.add_argument( + "--node_size", type=int, default=64, help="Size of hidden node states" + ) + parser.add_argument( + "--output_dir", + type=str, + default="runs/model_output", + help="Path to output directory", + ) + parser.add_argument( + "--dataset", + type=str, + default="data/qm9.db", + help="Path to ASE database", + ) + parser.add_argument( + "--max_steps", + type=int, + default=int(1e6), + help="Maximum number of optimisation steps", + ) + parser.add_argument( + "--device", + type=str, + default="cuda", + help="Set which device to use for training e.g. 'cuda' or 'cpu'", + ) + + parser.add_argument( + "--use_painn_model", + action="store_true", + help="Enable equivariant message passing model (PaiNN)", + ) + + parser.add_argument( + "--ignore_pbc", + action="store_true", + help="If flag is given, disable periodic boundary conditions (force to False) in atoms data", + ) + + parser.add_argument( + "--force_pbc", + action="store_true", + help="If flag is given, force periodic boundary conditions to True in atoms data", + ) + + parser.add_argument( + "--val-probes", + type=int, + default=1000, + help="Probes per material in validation. probes_to_graph cost grows " + "quadratically in this number; 5000 OOM-killed the 64 GB job 5004725", + ) + + parser.add_argument( + "--val-max-samples", + type=int, + default=200, + help="Deterministic seeded subsample size of the validation split " + "(upstream val sets were ~100 materials; ours is ~3.3k)", + ) + + return parser.parse_args(arg_list) + + +class AverageMeter: + """Computes and stores the average and current value""" + + def __init__(self, name, fmt=":f"): + self.name = name + self.fmt = fmt + self.reset() + + def reset(self): + self.val = 0 + self.avg = 0 + self.sum = 0 + self.count = 0 + + def update(self, val, n=1): + self.val = val + self.sum += val * n + self.count += n + self.avg = self.sum / self.count + + def __str__(self): + fmtstr = "{name} {val" + self.fmt + "} ({avg" + self.fmt + "})" + return fmtstr.format(**self.__dict__) + + +class ValCollateFuncRandomSample(dataset.CollateFuncRandomSample): + """Validation collate with capped, without-replacement probe sampling. + + Upstream ``atoms_and_probe_sample_to_graph_dict`` draws probes WITH + replacement (``np.random.randint``) and ``probes_to_graph`` inserts + every probe as a dummy atom into a periodic bothways neighborlist, + so probe-probe pairs grow quadratically in the probe count. On the + tiny LeMat-Rho cells (volume p50 89 A^3) 5000 probes meant ~75M + pairs per median material, which OOM-killed the 64 GB jobs + 5003891/5004725 during the step-0 val pass. This subclass keeps the + upstream graph construction but samples probe indices via + ``sample_probe_indices`` (without replacement when the grid has at + least ``num_probes`` points). + + .. code-block:: python + + collate = ValCollateFuncRandomSample(cutoff=4.0, num_probes=1000) + batch = collate([sample_dict_a, sample_dict_b]) + """ + + def __call__(self, input_dicts: list) -> dict: + graphs = [] + for i in input_dicts: + if self.set_pbc is not None: + atoms = i["atoms"].copy() + atoms.set_pbc(self.set_pbc) + else: + atoms = i["atoms"] + graphs.append(self._sample_to_graph_dict(i, atoms)) + return dataset.collate_list_of_dicts(graphs, pin_memory=self.pin_memory) + + def _sample_to_graph_dict(self, sample: dict, atoms) -> dict: + """Upstream ``atoms_and_probe_sample_to_graph_dict`` with the probe + selection swapped for capped, without-replacement sampling.""" + grid_pos = sample["grid_position"] + density = sample["density"] + flat_choice = sample_probe_indices( + int(np.prod(grid_pos.shape[0:3])), self.num_probes + ) + probe_choice = np.unravel_index(flat_choice, grid_pos.shape[0:3]) + probe_pos = grid_pos[probe_choice] + probe_target = density[probe_choice] + + atom_edges, atom_edges_displacement, neighborlist, inv_cell_T = ( + dataset.atoms_to_graph(atoms, self.cutoff) + ) + probe_edges, probe_edges_displacement = dataset.probes_to_graph( + atoms, + probe_pos, + self.cutoff, + neighborlist=neighborlist, + inv_cell_T=inv_cell_T, + ) + + default_type = torch.get_default_dtype() + if not probe_edges: + probe_edges = [np.zeros((0, 2), dtype=int)] + probe_edges_displacement = [np.zeros((0, 3), dtype=int)] + res = { + "nodes": torch.tensor(atoms.get_atomic_numbers()), + "atom_edges": torch.tensor(np.concatenate(atom_edges, axis=0)), + "atom_edges_displacement": torch.tensor( + np.concatenate(atom_edges_displacement, axis=0), dtype=default_type + ), + "probe_edges": torch.tensor(np.concatenate(probe_edges, axis=0)), + "probe_edges_displacement": torch.tensor( + np.concatenate(probe_edges_displacement, axis=0), dtype=default_type + ), + "probe_target": torch.tensor(probe_target, dtype=default_type), + } + res["num_nodes"] = torch.tensor(res["nodes"].shape[0]) + res["num_atom_edges"] = torch.tensor(res["atom_edges"].shape[0]) + res["num_probe_edges"] = torch.tensor(res["probe_edges"].shape[0]) + res["num_probes"] = torch.tensor(res["probe_target"].shape[0]) + res["probe_xyz"] = torch.tensor(probe_pos, dtype=default_type) + res["atom_xyz"] = torch.tensor(atoms.get_positions(), dtype=default_type) + res["cell"] = torch.tensor(np.array(atoms.get_cell()), dtype=default_type) + return res + + +def split_data(dataset, args): + # Load or generate splits + if args.split_file: + with open(args.split_file, "r") as fp: + splits = json.load(fp) + else: + # Seeded and restart-stable: resumes with --load_model and no + # --split_file regenerate this split, and upstream's unseeded + # np.random.permutation leaked previous val rows into train. + splits = generate_datasplits(len(dataset), args.split_seed) + + # Save split file + with open(os.path.join(args.output_dir, "datasplits.json"), "w") as f: + json.dump(splits, f) + + # Split the dataset + datasplits = {} + for key, indices in splits.items(): + datasplits[key] = torch.utils.data.Subset(dataset, indices) + return datasplits + + +def eval_model(model, dataloader, device): + with torch.no_grad(): + running_ae = torch.tensor(0.0, device=device) + running_se = torch.tensor(0.0, device=device) + running_count = torch.tensor(0.0, device=device) + for batch in dataloader: + device_batch = { + k: v.to(device=device, non_blocking=True) for k, v in batch.items() + } + outputs = model(device_batch) + targets = device_batch["probe_target"] + + running_ae += torch.sum(torch.abs(targets - outputs)) + running_se += torch.sum(torch.square(targets - outputs)) + running_count += torch.sum(device_batch["num_probes"]) + + mae = (running_ae / running_count).item() + rmse = (torch.sqrt(running_se / running_count)).item() + + return mae, rmse + + +def get_normalization(dataset, per_atom=True): + try: + num_targets = len(dataset.transformer.targets) + except AttributeError: + num_targets = 1 + x_sum = torch.zeros(num_targets) + x_2 = torch.zeros(num_targets) + num_objects = 0 + for sample in dataset: + x = sample["targets"] + if per_atom: + x = x / sample["num_nodes"] + x_sum += x + x_2 += x**2.0 + num_objects += 1 + # Var(X) = E[X^2] - E[X]^2 + x_mean = x_sum / num_objects + x_var = x_2 / num_objects - x_mean**2.0 + + return x_mean, torch.sqrt(x_var) + + +def count_parameters(model): + return sum(p.numel() for p in model.parameters() if p.requires_grad) + + +def main(): + args = get_arguments() + + # DDP setup (no-op when WORLD_SIZE=1). Must precede device + dataset + # construction; each rank pins itself to its own GCD via local_rank. + rank, local_rank, _world_size = _setup_ddp() + is_main = _is_main(rank) + + # Override device for DDP runs. + if _is_ddp(): + args.device = f"cuda:{local_rank}" + + # Setup logging + os.makedirs(args.output_dir, exist_ok=True) + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)-5.5s] %(message)s", + handlers=[ + logging.FileHandler( + os.path.join(args.output_dir, "printlog.txt"), mode="w" + ), + logging.StreamHandler(), + ], + ) + + # Save command line args + with open(os.path.join(args.output_dir, "commandline_args.txt"), "w") as f: + f.write("\n".join(sys.argv[1:])) + # Save parsed command line arguments + with open(os.path.join(args.output_dir, "arguments.json"), "w") as f: + json.dump(vars(args), f) + + # Setup dataset and loader. If args.dataset points at a directory of + # LeMat-Rho chunk_*.parquet files, use our adapter; otherwise fall + # through to upstream's tar/cube/dir loader unchanged. + if _is_parquet_dir(args.dataset): + if is_main: + logging.info("loading LeMat-Rho parquet dir %s", args.dataset) + densitydata = LeMatRhoDeepDFTDataset(parquet_dir=args.dataset) + else: + if args.dataset.endswith(".txt"): + # Text file contains list of datafiles + with open(args.dataset, "r") as datasetfiles: + filelist = [ + os.path.join(os.path.dirname(args.dataset), line.strip("\n")) + for line in datasetfiles + ] + else: + filelist = [args.dataset] + if is_main: + logging.info("loading data %s", args.dataset) + densitydata = torch.utils.data.ConcatDataset( + [dataset.DensityData(path) for path in filelist] + ) + + # Split data into train and validation sets + datasplits = split_data(densitydata, args) + # Pool_size and num_workers downsized from the upstream 20*4 = 80. + # (An earlier comment here blamed 200-300^3 cells for the 64 GB OOMs; + # LeMat-Rho grids are actually tiny, 10-15 points per axis. The real + # host-RAM cost is probe-count quadratic neighborlist growth in + # probes_to_graph, measured on jobs 5003891/5004725; see + # ValCollateFuncRandomSample. The small pool is still kept: it bounds + # how many decoded parquet rows sit in RAM per worker.) + datasplits["train"] = dataset.RotatingPoolData(datasplits["train"], 5) + + if args.ignore_pbc and args.force_pbc: + raise ValueError( + "ignore_pbc and force_pbc are mutually exclusive and can't both be set at the same time" + ) + elif args.ignore_pbc: + set_pbc = False + elif args.force_pbc: + set_pbc = True + else: + set_pbc = None + + # Setup loaders. With DDP, the train sampler shards data across ranks + # so each rank sees a disjoint subset per epoch. Val stays + # non-distributed and only rank 0 actually uses it. + if _is_ddp(): + train_sampler = DistributedSampler( + datasplits["train"], shuffle=True, drop_last=True + ) + else: + train_sampler = torch.utils.data.RandomSampler(datasplits["train"]) + train_loader = torch.utils.data.DataLoader( + datasplits["train"], + 2, + # See RotatingPoolData(...5) above; num_workers compounds the RAM + # footprint of the rotating pool (pool_size * num_workers decoded + # rows resident per worker). The dominant transient cost per batch + # is the probe neighborlist, quadratic in the probe count (1000 + # train probes -> ~3M pairs / ~6.6 GB peak on the median material, + # measured on jobs 5003891/5004725), so keep both knobs small. + num_workers=2, + sampler=train_sampler, + collate_fn=dataset.CollateFuncRandomSample( + args.cutoff, 1000, pin_memory=False, set_pbc_to=set_pbc + ), + ) + # Deterministic seeded subsample of the val split. The 5% split is + # ~3.3k materials while upstream val sets were ~100; a full pass with + # the python-loop collate takes hours per log interval. This subsample + # is only restart-stable because the parent split is too (seeded via + # --split-seed in split_data); together they keep best_val_mae + # comparable across restarts. + val_split = datasplits["validation"] + if args.val_max_samples is not None and len(val_split) > args.val_max_samples: + val_rng = np.random.default_rng(0) + val_keep = val_rng.choice( + len(val_split), size=args.val_max_samples, replace=False + ) + val_split = torch.utils.data.Subset(val_split, sorted(val_keep.tolist())) + val_loader = torch.utils.data.DataLoader( + val_split, + 2, + # ValCollateFuncRandomSample caps probes at args.val_probes (default + # 1000, NOT prod(grid_shape): at the 15^3 = 3375-point grids the + # quadratic neighborlist cost would already flirt with the 64 GB + # ceiling) and samples without replacement. The upstream 5000-probe + # with-replacement collate OOM-killed job 5004725 at step 0. + collate_fn=ValCollateFuncRandomSample( + args.cutoff, args.val_probes, pin_memory=False, set_pbc_to=set_pbc + ), + num_workers=0, + ) + # Upstream materialised the full val_loader into a list at startup for + # speed ("Preloading validation batch"); with our larger val split we + # keep it streaming instead (eager preloading OOM-killed job 4971720). + + # Initialise model + device = torch.device(args.device) + if args.use_painn_model: + net = densitymodel.PainnDensityModel( + args.num_interactions, args.node_size, args.cutoff + ) + else: + net = densitymodel.DensityModel( + args.num_interactions, args.node_size, args.cutoff + ) + if is_main: + logging.debug("model has %d parameters", count_parameters(net)) + net = net.to(device) + if _is_ddp(): + net = torch.nn.parallel.DistributedDataParallel( + net, device_ids=[local_rank], output_device=local_rank + ) + + # Setup optimizer + optimizer = torch.optim.Adam(net.parameters(), lr=0.0001) + criterion = torch.nn.MSELoss() + scheduler_fn = lambda step: 0.96 ** (step / 100000) + scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, scheduler_fn) + + log_interval = 5000 + running_loss = torch.tensor(0.0, device=device) + running_loss_count = torch.tensor(0, device=device) + best_val_mae = np.inf + step = 0 + # Restore checkpoint + if args.load_model: + state_dict = torch.load(args.load_model, map_location=device) + _unwrap(net).load_state_dict(state_dict["model"]) + step = state_dict["step"] + best_val_mae = state_dict["best_val_mae"] + optimizer.load_state_dict(state_dict["optimizer"]) + scheduler.load_state_dict(state_dict["scheduler"]) + + if is_main: + logging.info("start training") + + data_timer = AverageMeter("data_timer") + transfer_timer = AverageMeter("transfer_timer") + train_timer = AverageMeter("train_timer") + eval_timer = AverageMeter("eval_time") + + endtime = timeit.default_timer() + for _ in itertools.count(): + for batch_host in train_loader: + data_timer.update(timeit.default_timer() - endtime) + tstart = timeit.default_timer() + # Transfer to 'device' + batch = { + k: v.to(device=device, non_blocking=True) + for (k, v) in batch_host.items() + } + transfer_timer.update(timeit.default_timer() - tstart) + + tstart = timeit.default_timer() + # Reset gradient + optimizer.zero_grad() + + # Forward, backward and optimize + outputs = net(batch) + loss = criterion(outputs, batch["probe_target"]) + loss.backward() + optimizer.step() + + with torch.no_grad(): + running_loss += ( + loss + * batch["probe_target"].shape[0] + * batch["probe_target"].shape[1] + ) + running_loss_count += torch.sum(batch["num_probes"]) + + train_timer.update(timeit.default_timer() - tstart) + + # print(step, loss_value) + # Validate and save model + if (step % log_interval == 0) or ((step + 1) == args.max_steps): + tstart = timeit.default_timer() + with torch.no_grad(): + train_loss = (running_loss / running_loss_count).item() + running_loss = running_loss_count = 0 + + val_mae, val_rmse = eval_model(net, val_loader, device) + + if is_main: + logging.info( + "step=%d, val_mae=%g, val_rmse=%g, sqrt(train_loss)=%g", + step, + val_mae, + val_rmse, + math.sqrt(train_loss), + ) + + # Save checkpoint (rank 0 only). _unwrap so the state_dict + # is interchangeable between single-GPU and DDP runs. + if is_main and val_mae < best_val_mae: + best_val_mae = val_mae + torch.save( + { + "model": _unwrap(net).state_dict(), + "optimizer": optimizer.state_dict(), + "scheduler": scheduler.state_dict(), + "step": step, + "best_val_mae": best_val_mae, + }, + os.path.join(args.output_dir, "best_model.pth"), + ) + + eval_timer.update(timeit.default_timer() - tstart) + logging.debug( + "%s %s %s %s" + % (data_timer, transfer_timer, train_timer, eval_timer) + ) + step += 1 + + scheduler.step() + + if step >= args.max_steps: + if is_main: + logging.info("Max steps reached, exiting") + if _is_ddp(): + torch.distributed.destroy_process_group() + sys.exit(0) + + endtime = timeit.default_timer() + + +if __name__ == "__main__": + main() diff --git a/graph2mat_ft/__init__.py b/graph2mat_ft/__init__.py new file mode 100644 index 0000000..481fe10 --- /dev/null +++ b/graph2mat_ft/__init__.py @@ -0,0 +1,42 @@ +"""Graph2Mat-arm infrastructure for the r2SCAN benchmark (PARKED). + +PARKED 2026-05-25. Reasoning (see +``../plan_graph2mat_parked_2026-05-25.md``): + +Graph2Mat's native target is a per-pair atom-centered density +matrix ``D_ab``. VASP outputs only a grid density (not D_ab in any +localized basis), so training Graph2Mat on VASP r2SCAN would +require inventing a CHGCAR -> D_ab projection. Standard LSQR on +that is a 10^6 x 10^6 dense linear system per structure; the +matrix-free + neighbor-cutoff variant is multi-week research-grade +engineering with its own quality ceiling to validate. + +For the LeMat-Rho 3-arm comparison (ChargE3Net, DeepDFT, SALTED), +Graph2Mat is parked. The code below is correct as scaffolding and +ships with green tests; it can be revived if (1) we switch the +training set to a code that natively outputs D_ab (SIESTA, ...) or +(2) someone invests in the matrix-free projection. + +The basis adapter (PointBasis) and IO re-export are still useful +in their own right; left in place. +""" + +from graph2mat_ft.basis import basis_table_for_species, point_basis_for_species +from graph2mat_ft.io import read_chgcar, write_chgcar +from graph2mat_ft.model import Graph2MatModel +from graph2mat_ft.projection import ( + make_basis_configuration, + pack_coeffs_to_point_labels, + unpack_point_labels_to_coeffs, +) + +__all__ = [ + "Graph2MatModel", + "basis_table_for_species", + "make_basis_configuration", + "pack_coeffs_to_point_labels", + "point_basis_for_species", + "read_chgcar", + "unpack_point_labels_to_coeffs", + "write_chgcar", +] diff --git a/graph2mat_ft/basis.py b/graph2mat_ft/basis.py new file mode 100644 index 0000000..ce665ff --- /dev/null +++ b/graph2mat_ft/basis.py @@ -0,0 +1,63 @@ +"""Adapter from our uniform ``BasisSpec`` to Graph2Mat's ``PointBasis``. + +Graph2Mat ships ``PointBasis`` as the per-species basis description. +For each species, ``PointBasis(type, R, basis, basis_convention)`` +carries the cutoff, the per-l radial count, and the spherical- +harmonic convention. Our ``salted_ft.basis.BasisSpec`` is +species-uniform in v1, so the adapter just expands the same spec +into one PointBasis per species. + +Graph2Mat's expected ``basis`` argument when given a sequence of +ints: the integer at index ``l`` is the number of radial functions +at angular momentum ``l``. So our ``n_radial=4, max_l=4`` maps to +``basis=[4, 4, 4, 4, 4]`` (4 radials at each of l=0..4). The +``basis_size`` Graph2Mat computes from that = sum_l (2l+1) * n_radial += 100, matching ``BasisSpec.n_coeffs_per_atom``. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from graph2mat import PointBasis + +from salted_ft.basis import BasisSpec + + +def point_basis_for_species(symbol: str, basis_spec: BasisSpec) -> PointBasis: + """Build a Graph2Mat ``PointBasis`` for a single species. + + Parameters + ---------- + symbol : + Atomic symbol (``"H"``, ``"Fe"``, etc.) -- becomes ``PointBasis.type``. + basis_spec : + The same BasisSpec used by salted_ft. cutoff -> ``R``, + n_radial -> uniform per-l radial count, max_l -> length of basis list. + + Returns + ------- + PointBasis with ``basis_size == basis_spec.n_coeffs_per_atom`` + and ``basis_convention == 'spherical'``. + """ + # Per-l radial counts as a list of ints. List index = angular momentum. + per_l_radials = [basis_spec.n_radial] * (basis_spec.max_l + 1) + return PointBasis( + type=symbol, + R=float(basis_spec.cutoff), + basis=per_l_radials, + basis_convention="spherical", + ) + + +def basis_table_for_species( + symbols: Iterable[str], basis_spec: BasisSpec +) -> dict[str, PointBasis]: + """Build a ``{symbol: PointBasis}`` dict for a list of species. + + Duplicates in the input are collapsed. Downstream Graph2Mat data + processors (``BasisTableWithEdges``, etc.) take this dict to know + every basis a structure can have. + """ + unique = list(dict.fromkeys(symbols)) # preserves order, deduplicates + return {s: point_basis_for_species(s, basis_spec) for s in unique} diff --git a/graph2mat_ft/io.py b/graph2mat_ft/io.py new file mode 100644 index 0000000..502929a --- /dev/null +++ b/graph2mat_ft/io.py @@ -0,0 +1,18 @@ +"""CHGCAR file I/O for the Graph2Mat arm. + +The Graph2Mat arm uses the same on-disk format as the SALTED arm +(VASP CHGCAR + pymatgen). To avoid drift between the two arms, the +canonical implementation lives in ``salted_ft.io`` and this module +re-exports it. + +Downstream code that wants the Graph2Mat namespace +(``from graph2mat_ft.io import read_chgcar, write_chgcar``) gets +the same helpers as the SALTED arm, including the +``n_electrons`` rescaling that ICHARG=1 needs. +""" + +from __future__ import annotations + +from salted_ft.io import read_chgcar, write_chgcar + +__all__ = ["read_chgcar", "write_chgcar"] diff --git a/graph2mat_ft/model.py b/graph2mat_ft/model.py new file mode 100644 index 0000000..0de4695 --- /dev/null +++ b/graph2mat_ft/model.py @@ -0,0 +1,103 @@ +"""Graph2MatModel -- wrapper around Graph2Mat coefficient prediction. + +Single-call interface ``coefficients = model(atoms)`` so the +Graph2Mat arm slots into the same evaluation pipeline as ChargE3Net +/ DeepDFT / SALTED. + +Stub mode (``ckpt_path=None``) returns deterministic +position-and-species-dependent coefficients. This is what powers +the unit tests and the end-to-end pipeline plumbing tests in D5; +PR zeta-gamma-prime (D6 train-script follow-up) wires in the real +Graph2Mat backbone. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import ase +import numpy as np + +from salted_ft.basis import BasisSpec +from salted_ft.projection import reconstruct_grid_from_basis + + +class Graph2MatModel: + """Predict atom-centered basis coefficients for a structure. + + Parameters + ---------- + basis_spec : + Basis the coefficients are defined against. Must match the + spec the trained checkpoint was trained on. + ckpt_path : + Path to a Graph2Mat checkpoint. If ``None`` (default), the + model runs in stub mode: deterministic, position-dependent + fake coefficients useful for testing the surrounding pipeline. + """ + + def __init__( + self, basis_spec: BasisSpec, ckpt_path: str | Path | None = None + ) -> None: + self.basis_spec = basis_spec + self.ckpt_path = Path(ckpt_path) if ckpt_path is not None else None + self._g2m_model = None # populated when the real forward lands in D6 + + def __call__(self, atoms: ase.Atoms) -> np.ndarray: + """Predict coefficients for ``atoms``. + + Returns + ------- + np.ndarray of shape ``(n_atoms, basis_spec.n_coeffs_per_atom)``, + float64, deterministic, finite. + """ + if self.ckpt_path is None: + return self._stub_predict(atoms) + return self._g2m_predict(atoms) + + def reconstruct_density( + self, atoms: ase.Atoms, grid_shape: tuple[int, int, int] + ) -> np.ndarray: + """Predict coefficients, then reconstruct the real-space density. + + Equivalent to:: + + c = model(atoms) + reconstruct_grid_from_basis(c, atoms, grid_shape, basis_spec) + """ + coeffs = self(atoms) + return reconstruct_grid_from_basis(coeffs, atoms, grid_shape, self.basis_spec) + + def _stub_predict(self, atoms: ase.Atoms) -> np.ndarray: + """Deterministic position-dependent coefficients without Graph2Mat. + + Seeded RNG keyed off positions + numbers + basis spec, so + same atoms in -> same coefficients out. Output magnitude is + kept small (factor 1e-3) so reconstructed densities stay in + the metric-test range. + """ + n_atoms = len(atoms) + n_coeffs = self.basis_spec.n_coeffs_per_atom + positions = atoms.get_positions() + numbers = atoms.get_atomic_numbers() + + # Hash every byte: int.from_bytes(...[:16]) would discard atoms + # past index 0 and silently collapse different structures into + # the same seed. + digest = hashlib.blake2b( + positions.astype(np.float64).tobytes() + + numbers.astype(np.int64).tobytes() + + str(self.basis_spec).encode("utf-8"), + digest_size=16, + ).digest() + seed_int = int.from_bytes(digest, byteorder="little", signed=False) + rng = np.random.default_rng(seed_int) + return rng.standard_normal((n_atoms, n_coeffs), dtype=np.float64) * 1e-3 + + def _g2m_predict(self, atoms: ase.Atoms) -> np.ndarray: + """Real Graph2Mat forward pass. Lands with D6 training driver.""" + raise NotImplementedError( + "Real Graph2Mat forward pass is deferred to D6. " + "Construct Graph2MatModel with ckpt_path=None for stub mode." + ) diff --git a/graph2mat_ft/projection.py b/graph2mat_ft/projection.py new file mode 100644 index 0000000..9d5e7da --- /dev/null +++ b/graph2mat_ft/projection.py @@ -0,0 +1,127 @@ +"""Per-atom coefficient projection for the Graph2Mat arm (PR zeta-beta). + +Path A of the Graph2Mat plan: the regression target is the same +per-atom basis-coefficient vector that SALTED predicts (see +``salted_ft.projection.project_chgcar_to_basis``). Graph2Mat then +acts as a different backbone over the same target. + +This module exposes: + +* ``pack_coeffs_to_point_labels(coeffs, basis_spec, symbols)`` -- + flatten ``(N_atoms, n_coeffs_per_atom)`` into the atom-major + concatenation Graph2Mat consumes as per-node targets. + +* ``unpack_point_labels_to_coeffs(flat, basis_spec, symbols)`` -- + inverse. + +* ``make_basis_configuration(positions, cell, symbols, basis_spec)`` + -- wrap a structure into ``graph2mat.BasisConfiguration`` so it + can be fed to Graph2Mat's data processor without us reaching + into graph2mat internals from the training driver. + +We do not lift the coefficients into a true density-matrix +representation (that was Path B). v1 has no off-site terms. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np + +from salted_ft.basis import BasisSpec + + +def pack_coeffs_to_point_labels( + coeffs: np.ndarray, + basis_spec: BasisSpec, + symbols: Sequence[str], +) -> np.ndarray: + """Flatten per-atom coefficients into Graph2Mat per-node labels. + + Parameters + ---------- + coeffs : + ``(N_atoms, n_coeffs_per_atom)`` from ``salted_ft``. + basis_spec : + Locks ``n_coeffs_per_atom``; used to validate shape. + symbols : + Per-atom species symbols. Length must match ``N_atoms``. + + Returns + ------- + 1D array of length ``N_atoms * n_coeffs_per_atom``, atom-major + (atom 0's block first, then atom 1, ...). + """ + if coeffs.shape[1] != basis_spec.n_coeffs_per_atom: + raise ValueError( + f"coeffs has {coeffs.shape[1]} channels per atom but BasisSpec " + f"declares {basis_spec.n_coeffs_per_atom}" + ) + if coeffs.shape[0] != len(symbols): + raise ValueError( + f"coeffs has {coeffs.shape[0]} atoms but got {len(symbols)} symbols" + ) + # ravel keeps the input dtype; explicit C order is the contract we test + return coeffs.reshape(-1).copy() + + +def unpack_point_labels_to_coeffs( + flat: np.ndarray, + basis_spec: BasisSpec, + symbols: Sequence[str], +) -> np.ndarray: + """Inverse of ``pack_coeffs_to_point_labels``.""" + expected = len(symbols) * basis_spec.n_coeffs_per_atom + if flat.shape[0] != expected: + raise ValueError( + f"flat has length {flat.shape[0]} but expected " + f"{len(symbols)} atoms x {basis_spec.n_coeffs_per_atom} " + f"channels = {expected}" + ) + return flat.reshape(len(symbols), basis_spec.n_coeffs_per_atom).copy() + + +def make_basis_configuration( + positions: np.ndarray, + cell: np.ndarray, + symbols: Sequence[str], + basis_spec: BasisSpec, +): + """Bundle one structure into a Graph2Mat ``BasisConfiguration``. + + The basis list is built once per call from the unique species in + ``symbols`` so the resulting config carries only the species it + actually contains (a downstream BasisTableWithEdges may union + these across the dataset). + + Parameters + ---------- + positions : + ``(N_atoms, 3)`` Cartesian atomic positions in Angstroms. + cell : + ``(3, 3)`` lattice matrix. + symbols : + Per-atom species symbols. + basis_spec : + Defines the per-species ``PointBasis`` (uniform across + species in v1). + """ + # Lazy-import keeps the module importable without graph2mat installed + # (the test class importorskips, so this only runs when present). + from graph2mat import BasisConfiguration + + from graph2mat_ft.basis import basis_table_for_species + + table = basis_table_for_species(symbols, basis_spec) + basis_list = list(table.values()) + symbol_to_idx = {pb.type: i for i, pb in enumerate(basis_list)} + point_types = np.array([symbol_to_idx[s] for s in symbols], dtype=np.int64) + + return BasisConfiguration( + point_types=point_types, + positions=np.asarray(positions, dtype=np.float64), + basis=basis_list, + cell=np.asarray(cell, dtype=np.float64), + pbc=(True, True, True), + ) diff --git a/pyproject.toml b/pyproject.toml index 0309bcf..af9b5be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,38 @@ dependencies = [ "material-hasher", "atomate2", "fireworks", + "torch>=2.0", + "numpy>=1.24", + "e3nn>=0.5.0", + "scipy>=1.10.0", + "lz4>=4.0.0", + "pyarrow>=14.0.0", + "wandb>=0.16.0", + "python-dotenv>=1.0.0", + "metatensor>=0.2.0", + "chemfiles>=0.10.4", + "graph2mat>=0.0.13", + # BOA (boa_ft) arm. BOA/scdp/mldft themselves are editable-installed from + # the sibling ../boa clone (see boa_ft/README.md), so only their runtime + # dependencies are listed here. torch-cluster is deliberately omitted: the + # boa_ft PBC edge builder replaces the only radius_graph call. + "hydra-core>=1.3.2", + "lightning>=2.5.0", + "torch_ema>=0.3", + "torch_geometric>=2.5.0", + "pyscf>=2.5.0", + "lmdb>=1.4.1", + "mp-pyrho>=0.3.0", + "basis-set-exchange>=0.9.1", + "pymatgen>=2023.11.12", + "rootutils>=1.0.7", + "loguru>=0.7.0", + "p_tqdm>=1.4.0", ] [tool.uv.sources] material-hasher = { git = "https://github.com/LeMaterial/lematerial-hasher" } + +[tool.ruff.lint.per-file-ignores] +# Vendored from peterbjorgensen/DeepDFT; keep upstream's root-logger and %-format style. +"deepdft_ft/runner.py" = ["LOG015", "UP031"] diff --git a/salted_ft/__init__.py b/salted_ft/__init__.py new file mode 100644 index 0000000..7655b6e --- /dev/null +++ b/salted_ft/__init__.py @@ -0,0 +1,17 @@ +"""SALTED-arm basis-expansion infrastructure for the r2SCAN benchmark. + +This package wraps rholearn (`lab-cosmo/rholearn`) and provides the +projection/reconstruction bridge between LeMat-Rho VASP CHGCAR data +and the rholearn training/inference pipeline. + +Layout (stacked PRs, see `plan_salted_graph2mat_basis_choice_may_20_pm.md`): + +* ``basis.py`` (PR α) — ``BasisSpec`` dataclass + shape helpers. +* ``projection.py`` (PR β) — VASP CHGCAR ↔ basis coefficients. +* ``model.py`` (PR γ) — ``SALTEDModel`` wrapper for rholearn. +* ``io.py`` (PR δ) — coefficients/grid ↔ pymatgen ``Chgcar``. +""" + +from salted_ft.basis import BasisSpec + +__all__ = ["BasisSpec"] diff --git a/salted_ft/basis.py b/salted_ft/basis.py new file mode 100644 index 0000000..939f660 --- /dev/null +++ b/salted_ft/basis.py @@ -0,0 +1,87 @@ +"""BasisSpec — the atom-centered radial × angular basis used by the SALTED arm. + +The density expansion is +:: + + rho(r) = sum_i sum_{nlm} c_{i,nlm} phi_{n}(|r - r_i|) Y_{lm}(r - r_i) + +with ``phi_n`` a Gaussian radial of width ``sigma_n`` and ``Y_lm`` a real +spherical harmonic. + +Numbers locked in Phase A4 of +``plan_salted_graph2mat_basis_choice_may_20_pm.md`` (2026-05-20): +``max_l=4``, ``n_radial=4``, ``sigma=(0.5, 1.0, 2.0, 4.0)``, ``cutoff=4.0``. +That gives 100 coefficients per atom (4 × (4+1)²), which lands the trained +model in the same parameter-count ballpark as ChargE3Net for fair comparison. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class BasisSpec: + """Configuration of the atom-centered Gaussian × Y_lm basis. + + Parameters + ---------- + max_l : + Maximum angular momentum, inclusive. Real spherical harmonics + Y_lm with ``l = 0..max_l`` and ``m = -l..l`` are used. + n_radial : + Number of radial channels. Must match ``len(sigma)``. + sigma : + Gaussian widths (Angstrom), one per radial channel. + cutoff : + Radial cutoff (Angstrom) beyond which basis functions are zero. + Should match the cutoff used by the neighbor-list / graph + constructor of the downstream ML model. + """ + + max_l: int = 4 + n_radial: int = 4 + sigma: tuple[float, ...] = field(default=(0.5, 1.0, 2.0, 4.0)) + cutoff: float = 4.0 + + def __post_init__(self) -> None: + # All validation goes here so a malformed spec raises at construction + # time, not deep inside a tensor op three PRs from now. + if self.max_l < 0: + raise ValueError( + f"max_l must be >= 0; got {self.max_l}. " + "Use max_l=0 for an s-only basis." + ) + if self.n_radial < 1: + raise ValueError( + f"n_radial must be >= 1; got {self.n_radial}. " + "A basis with zero radial channels has no expressive power." + ) + if len(self.sigma) != self.n_radial: + raise ValueError( + f"n_radial ({self.n_radial}) must equal len(sigma) " + f"({len(self.sigma)}); each radial channel needs its own width." + ) + if any(s <= 0 for s in self.sigma): + raise ValueError( + f"sigma values must be positive (Gaussian widths); got {self.sigma}." + ) + if self.cutoff <= 0: + raise ValueError( + f"cutoff must be > 0; got {self.cutoff}. " + "A nonpositive cutoff makes the basis identically zero." + ) + + @property + def n_angular_components(self) -> int: + """Number of real-Ylm components for l = 0..max_l: sum_l (2l + 1) = (max_l + 1)^2.""" + return (self.max_l + 1) ** 2 + + @property + def n_coeffs_per_atom(self) -> int: + """Coefficients per atom: n_radial channels × angular components.""" + return self.n_radial * self.n_angular_components + + def total_coeffs_shape(self, n_atoms: int) -> tuple[int, int]: + """Shape of the per-structure coefficients tensor.""" + return (n_atoms, self.n_coeffs_per_atom) diff --git a/salted_ft/io.py b/salted_ft/io.py new file mode 100644 index 0000000..d43c589 --- /dev/null +++ b/salted_ft/io.py @@ -0,0 +1,102 @@ +"""CHGCAR file I/O for the SALTED arm. + +A thin wrapper over pymatgen's ``Chgcar``. The wrapper adds two things +on top of the bare pymatgen API: + +* ``n_electrons`` rescaling. The CHGCAR convention is + ``integrated_density = sum(rho) * cell_volume / N_grid = N_electrons``. + Our predicted densities come from an L2-projected basis with no + guarantee on the integral; we have to rescale so VASP doesn't + silently fix the electron count for us at startup (which would + defeat the speedup measurement). + +* ``ase.Atoms`` input/output to match the rest of the salted_ft + pipeline. pymatgen's ``Structure`` is converted via + ``AseAtomsAdaptor`` and back. + +These two helpers are the boundary between the predicted-density +tensor world and the VASP-input file world. The actual SCF speedup +measurement lives in the entalsim ``StructureVASPSinglePoint`` maker +(separate stack). +""" + +from __future__ import annotations + +from pathlib import Path + +import ase +import numpy as np + + +def write_chgcar( + density: np.ndarray, + atoms: ase.Atoms, + path: str | Path, + n_electrons: float | None = None, +) -> None: + """Write a real-space density grid to a VASP CHGCAR file. + + Parameters + ---------- + density : + Real-space density on a regular grid, shape ``(Nx, Ny, Nz)``. + atoms : + Periodic structure; provides cell + species ordering. + path : + Output file path. + n_electrons : + If given (and > 0), rescale the density so the file's integrated + density equals this value. VASP reads this as the total electron + count when starting with ``ICHARG=1``; getting it right is + what makes the SCF-speedup comparison meaningful. + """ + if density.ndim != 3: + raise ValueError( + f"density must be a 3D grid (Nx, Ny, Nz); got shape {density.shape}" + ) + if n_electrons is not None and n_electrons <= 0: + raise ValueError( + f"n_electrons must be > 0; got {n_electrons}. Use None to skip rescaling." + ) + + from pymatgen.io.ase import AseAtomsAdaptor + from pymatgen.io.vasp.outputs import Chgcar + + structure = AseAtomsAdaptor.get_structure(atoms) + rho = np.asarray(density, dtype=np.float64).copy() + + if n_electrons is not None: + cell_volume = float(structure.lattice.volume) + n_grid = int(np.prod(rho.shape)) + current_total = rho.sum() * cell_volume / n_grid + if current_total != 0.0: + rho *= n_electrons / current_total + + # pymatgen's Chgcar stores density as the per-cell sum (not per-grid-point); + # i.e. rho_stored = rho * cell_volume in its convention. The Chgcar + # constructor expects the data dict to use the same convention as VASP's + # CHGCAR file format, which is rho * volume. We multiply here so the + # round-trip via Chgcar.from_file preserves our user-facing rho. + chgcar_data = {"total": rho * float(structure.lattice.volume)} + chgcar = Chgcar(structure, chgcar_data) + chgcar.write_file(str(path)) + + +def read_chgcar(path: str | Path) -> tuple[np.ndarray, ase.Atoms]: + """Read a CHGCAR file and return ``(density, atoms)``. + + Returns + ------- + density : np.ndarray of shape ``(Nx, Ny, Nz)``, the density per + grid point (the inverse of write_chgcar's convention). + atoms : ase.Atoms + """ + from pymatgen.io.ase import AseAtomsAdaptor + from pymatgen.io.vasp.outputs import Chgcar + + chgcar = Chgcar.from_file(str(path)) + cell_volume = float(chgcar.structure.lattice.volume) + # VASP stores density * volume; undo that for the user-facing density. + rho = np.asarray(chgcar.data["total"], dtype=np.float64) / cell_volume + atoms = AseAtomsAdaptor.get_atoms(chgcar.structure) + return rho, atoms diff --git a/salted_ft/model.py b/salted_ft/model.py new file mode 100644 index 0000000..4e5160a --- /dev/null +++ b/salted_ft/model.py @@ -0,0 +1,170 @@ +"""SALTEDModel — wrapper around rholearn's basis-coefficient prediction. + +The wrapper exposes a single-call interface +``coefficients = model(atoms)`` so the SALTED arm slots into the same +evaluation pipeline as ChargE3Net / DeepDFT: predict, reconstruct on +the VASP FFT grid, compare against the converged density via NMAPE +and friends. + +When constructed with ``ckpt_path=None`` the model is in **stub mode**: +it returns deterministic, position-dependent coefficients without +requiring a trained rholearn checkpoint. This is what powers the +unit tests and the end-to-end pipeline plumbing tests during PR +gamma; PR gamma-prime (a follow-up) will swap in real rholearn +forward calls. + +When ``ckpt_path`` points at a real rholearn checkpoint the model +delegates to rholearn. The rholearn sibling repo is expected at +``../rholearn/`` relative to the LeMat-Rho clone (same pattern as +``charge3net`` for ChargE3Net and ``DeepDFT`` for DeepDFT). +""" + +from __future__ import annotations + +import hashlib +import sys +from pathlib import Path + +import ase +import numpy as np + +from salted_ft.basis import BasisSpec +from salted_ft.projection import reconstruct_grid_from_basis + +# rholearn sibling-repo discovery follows the same pattern as +# charge3net_ft/model.py and deepdft_ft/runner.py. Resolution is lazy: +# we only insist on the sibling repo when ckpt_path is provided. +_RHOLEARN_ROOT = Path(__file__).resolve().parent.parent.parent / "rholearn" + + +def _ensure_rholearn_importable() -> None: + """Make ``rholearn`` importable; only called when ckpt_path is set.""" + if not _RHOLEARN_ROOT.exists(): + raise RuntimeError( + f"rholearn repo not found at {_RHOLEARN_ROOT}.\n" + "Clone it with: git clone https://github.com/lab-cosmo/rholearn " + f"{_RHOLEARN_ROOT}\n" + "Note: the metatensor.torch.atomistic -> metatomic.torch namespace " + "patch in rholearn/utils/system.py may also be required." + ) + if str(_RHOLEARN_ROOT) not in sys.path: + sys.path.insert(0, str(_RHOLEARN_ROOT)) + + +class SALTEDModel: + """Predict atom-centered basis coefficients for a structure. + + Parameters + ---------- + basis_spec : + The basis the coefficients are defined against. Must match the + spec the trained checkpoint was trained on. + ckpt_path : + Path to a rholearn checkpoint. If ``None`` (default), the model + runs in stub mode: deterministic, position-dependent fake + coefficients useful for testing the surrounding pipeline. + """ + + def __init__( + self, basis_spec: BasisSpec, ckpt_path: str | Path | None = None + ) -> None: + self.basis_spec = basis_spec + self.ckpt_path = Path(ckpt_path) if ckpt_path is not None else None + # Lazy: model load is deferred to the first inference call. + # Renamed _rholearn_model -> _model would be more accurate now + # that the baseline path replaced the rholearn forward, but kept + # for diff-minimality. + self._rholearn_model = None + + def __call__(self, atoms: ase.Atoms) -> np.ndarray: + """Predict coefficients for ``atoms``. + + Returns + ------- + np.ndarray of shape ``(n_atoms, basis_spec.n_coeffs_per_atom)``, + float64, deterministic, finite. + """ + if self.ckpt_path is None: + return self._stub_predict(atoms) + return self._baseline_predict(atoms) + + def reconstruct_density( + self, atoms: ase.Atoms, grid_shape: tuple[int, int, int] + ) -> np.ndarray: + """Predict coefficients, then reconstruct the real-space density. + + Equivalent to:: + + c = model(atoms) + reconstruct_grid_from_basis(c, atoms, grid_shape, basis_spec) + + Provided as a convenience for the VASP comparison pipeline, + which always wants the grid form. + """ + coeffs = self(atoms) + return reconstruct_grid_from_basis(coeffs, atoms, grid_shape, self.basis_spec) + + # ------------------------------------------------------------------ + # Implementations + # ------------------------------------------------------------------ + def _stub_predict(self, atoms: ase.Atoms) -> np.ndarray: + """Deterministic position-dependent coefficients without rholearn. + + Recipe: seed a NumPy random generator with a hash of the atomic + positions, atomic numbers, and basis spec. Same atoms in -> same + coefficients out. Different atom positions -> different coefficients. + + The numbers are small (order 1e-3) so reconstructed densities + don't blow up the metric ranges in downstream tests. + """ + n_atoms = len(atoms) + n_coeffs = self.basis_spec.n_coeffs_per_atom + positions = atoms.get_positions() + numbers = atoms.get_atomic_numbers() + + # Hash every byte: int.from_bytes(...[:16]) would discard atoms + # past index 0 and silently collapse different structures into + # the same seed. + digest = hashlib.blake2b( + positions.astype(np.float64).tobytes() + + numbers.astype(np.int64).tobytes() + + str(self.basis_spec).encode("utf-8"), + digest_size=16, + ).digest() + seed_int = int.from_bytes(digest, byteorder="little", signed=False) + rng = np.random.default_rng(seed_int) + return rng.standard_normal((n_atoms, n_coeffs), dtype=np.float64) * 1e-3 + + def _baseline_predict(self, atoms: ase.Atoms) -> np.ndarray: + """Load the D6 SchNet-style baseline ckpt and predict coefficients. + + Ckpt format (see ``salted_ft.train_baseline.train``):: + + {"basis_spec": BasisSpec, "model": state_dict} + + The baseline model is cached on first call to amortise the + load over many predictions. + """ + # Lazy import: torch is heavy and stub mode does not require it. + import torch + + from salted_ft.train_baseline import SaltedBaselineModel + + if self._rholearn_model is None: + state = torch.load( + str(self.ckpt_path), map_location="cpu", weights_only=False + ) + if "model" not in state: + raise RuntimeError( + f"Checkpoint at {self.ckpt_path} is not in the expected " + "baseline format ({'basis_spec': ..., 'model': state_dict}). " + "If this is a rholearn checkpoint, that path is deferred." + ) + model = SaltedBaselineModel(state.get("basis_spec", self.basis_spec)) + model.load_state_dict(state["model"]) + model.train(False) + self._rholearn_model = model + + with torch.no_grad(): + pred = self._rholearn_model(atoms) + return pred.detach().cpu().numpy().astype(np.float64) diff --git a/salted_ft/project_dataset.py b/salted_ft/project_dataset.py new file mode 100644 index 0000000..4442fff --- /dev/null +++ b/salted_ft/project_dataset.py @@ -0,0 +1,170 @@ +"""Phase D2: project the LeMat-Rho parquet dataset onto the SALTED basis. + +One-time job. Reads every ``chunk_*.parquet`` produced by +lematerial-fetcher (rows of densities + structures), runs +``project_chgcar_to_basis`` row by row, writes a parallel +``chunk_*.parquet`` of basis coefficients that downstream training +loops (rholearn, Graph2Mat, etc.) consume. + +Output schema per row:: + + row_index int position in the source chunk + material_id str carried from source if present, else "" + n_atoms int + atomic_numbers list[int] ASE atomic numbers, length n_atoms + lattice_vectors list[list] 3x3 cell matrix in Angstrom + n_electrons float integrated density * cell_volume / n_grid + grid_shape list[int] [Nx, Ny, Nz] + coefficients list[list] (n_atoms, n_coeffs_per_atom) + basis_set_NMAPE float per-row reconstruction error (%) + +CLI:: + + uv run python -m salted_ft.project_dataset \\ + --input-dir $SETUP/charge3net_data \\ + --output-dir $SETUP/salted_projected_coefficients +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq + +from charge3net_ft.data import _COLUMNS, _row_to_atoms_and_density +from salted_ft.basis import BasisSpec +from salted_ft.projection import ( + project_chgcar_to_basis, + reconstruct_grid_from_basis, +) + + +def _row_nmape(true: np.ndarray, pred: np.ndarray) -> float: + """Integral-normalised mean absolute percentage error (%) for one row.""" + return float(100.0 * np.sum(np.abs(true - pred)) / (np.sum(np.abs(true)) + 1e-12)) + + +def project_chunk( + in_path: str | Path, + out_path: str | Path, + basis_spec: BasisSpec, +) -> None: + """Project every valid row in ``in_path`` and write ``out_path``.""" + in_path = Path(in_path) + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + columns = list(_COLUMNS) + # material_id is optional; include it if present so downstream can match + # to the source LeMat-Rho row. + schema = pq.read_schema(in_path) + has_material_id = "material_id" in schema.names + if has_material_id: + columns.append("material_id") + + table = pq.read_table(in_path, columns=columns) + n_rows = len(table) + + out_rows: list[dict] = [] + for ri in range(n_rows): + chgd = table.column("compressed_charge_density")[ri] + if not chgd.is_valid: + continue # skip null density (failed DFT extraction in source) + + row = {col: table.column(col)[ri].as_py() for col in _COLUMNS} + atoms, density, _origin = _row_to_atoms_and_density(row) + + coeffs = project_chgcar_to_basis(density, atoms, basis_spec) + reconstructed = reconstruct_grid_from_basis( + coeffs, atoms, density.shape, basis_spec + ) + nmape = _row_nmape(density, reconstructed) + + cell = np.asarray(atoms.get_cell(), dtype=np.float64) + cell_volume = float(np.abs(np.linalg.det(cell))) + n_grid = int(np.prod(density.shape)) + n_electrons = float(density.sum() * cell_volume / n_grid) + + out_rows.append( + { + "row_index": ri, + "material_id": ( + table.column("material_id")[ri].as_py() if has_material_id else "" + ), + "n_atoms": len(atoms), + "atomic_numbers": atoms.get_atomic_numbers().tolist(), + "lattice_vectors": cell.tolist(), + "n_electrons": n_electrons, + "grid_shape": list(density.shape), + "coefficients": coeffs.tolist(), + "basis_set_NMAPE": nmape, + } + ) + + out_table = pa.Table.from_pylist(out_rows) + pq.write_table(out_table, out_path) + + +def project_directory( + input_dir: str | Path, + output_dir: str | Path, + basis_spec: BasisSpec, +) -> None: + """Run :func:`project_chunk` over every ``chunk_*.parquet`` in ``input_dir``. + + Idempotent: a chunk whose output already exists is left untouched + so partially-completed runs can resume cheaply. + """ + input_dir = Path(input_dir) + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + inputs = sorted(input_dir.glob("chunk_*.parquet")) + if not inputs: + raise FileNotFoundError(f"no chunk_*.parquet files under {input_dir}") + + for in_path in inputs: + out_path = output_dir / in_path.name + if out_path.exists() and out_path.stat().st_size > 0: + continue + project_chunk(in_path, out_path, basis_spec) + + +def _main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Project the LeMat-Rho parquet dataset onto the SALTED basis." + ) + parser.add_argument("--input-dir", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument( + "--basis-spec", + type=str, + default=None, + help="JSON-encoded BasisSpec overrides. If omitted, defaults are used.", + ) + args = parser.parse_args(argv) + + if args.basis_spec: + overrides = json.loads(args.basis_spec) + # sigma must be tuple-ified to satisfy BasisSpec's frozen dataclass + if "sigma" in overrides: + overrides["sigma"] = tuple(overrides["sigma"]) + spec = BasisSpec(**overrides) + else: + spec = BasisSpec() + print( + f"BasisSpec: lmax={spec.max_l}, n_radial={spec.n_radial}, " + f"sigma={spec.sigma}, cutoff={spec.cutoff}, " + f"n_coeffs_per_atom={spec.n_coeffs_per_atom}" + ) + + project_directory(args.input_dir, args.output_dir, spec) + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/salted_ft/projection.py b/salted_ft/projection.py new file mode 100644 index 0000000..5c2e5e8 --- /dev/null +++ b/salted_ft/projection.py @@ -0,0 +1,323 @@ +"""VASP density grid <-> atom-centered Gaussian * Y_lm basis coefficients. + +The two operations defined here are the DIY bridge between VASP plane-wave +CHGCAR data and the rholearn/SALTED localized-basis world. See the memo +``plan_salted_graph2mat_basis_choice_may_20_pm.md`` (Phase A) for why we +have to build this layer ourselves. + +Math +---- + +The basis expansion is +:: + + rho(r) ~= sum_i sum_n sum_{l,m} c_{i,n,l,m} phi_n(|r - r_i|) Y_lm(rhat) + +where ``i`` indexes atoms, ``n`` is the radial channel, ``(l, m)`` are the +real spherical harmonic indices, ``phi_n`` is a Gaussian of width +``sigma_n``, and ``Y_lm`` is a real spherical harmonic. + +Projection solves a single global least-squares system: we build the +per-structure design matrix of every basis function evaluated at every +grid point and fit all atoms' coefficients simultaneously with +``np.linalg.lstsq``. This accounts for the strong overlap between our +Gaussians (an earlier per-channel orthonormal approximation overcounted +overlapping contributions and produced ~1000% NMAPE). + +Reconstruction is the literal sum on the right-hand side. + +Both maps are linear in their input (linearity is a pinned test). + +PBC +--- + +Minimum-image convention via the cell inverse. Each grid point sees each +atom at its closest periodic image. Adequate for cells where 2*cutoff +fits inside the smallest cell vector; for very small cells we'd want +full Ewald-style supercell expansion. Not in scope for PR beta. +""" + +from __future__ import annotations + +import ase +import numpy as np + +from salted_ft.basis import BasisSpec + + +# --------------------------------------------------------------------------- +# Grid-position generation (matches charge3net's `calculate_grid_pos` plus +# `deepdft_ft.data._calculate_grid_pos` so the three pipelines agree on +# where grid point (i, j, k) lives in space). +# --------------------------------------------------------------------------- +def _grid_positions(grid_shape: tuple[int, int, int], cell: np.ndarray) -> np.ndarray: + """Cartesian coordinates of every grid point. + + Parameters + ---------- + grid_shape : (Nx, Ny, Nz) + cell : (3, 3) lattice matrix with rows as vectors + + Returns + ------- + (Nx * Ny * Nz, 3) Cartesian coordinates, ``[i, j, k]`` order matching + ``np.ravel`` of an array of that shape. + """ + # Silence harmless RuntimeWarnings from intermediate matmul reductions. + with np.errstate(divide="ignore", invalid="ignore", over="ignore"): + Nx, Ny, Nz = grid_shape + fx = np.arange(Nx, dtype=np.float64) / Nx + fy = np.arange(Ny, dtype=np.float64) / Ny + fz = np.arange(Nz, dtype=np.float64) / Nz + fX, fY, fZ = np.meshgrid(fx, fy, fz, indexing="ij") + frac = np.stack([fX.ravel(), fY.ravel(), fZ.ravel()], axis=-1) + return frac @ cell # (n_grid, 3) + + +# --------------------------------------------------------------------------- +# Real spherical harmonics. We hand-roll real Y_lm for lmax up to 4 +# (covers our default lmax=4) because the alternatives are either heavy +# (e3nn/torch in a pure-numpy module) or complex-valued (scipy.special). +# --------------------------------------------------------------------------- +_SQRT_PI = np.sqrt(np.pi) + + +def _real_sph_harm(rhat: np.ndarray, lmax: int) -> np.ndarray: + """Real spherical harmonics on unit vectors, l = 0..lmax inclusive. + + Returns an array of shape ``(..., (lmax + 1) ** 2)`` where the last + axis is ordered ``[Y_00, Y_1{-1}, Y_10, Y_11, Y_2{-2}, ..., Y_l l]`` + (the standard SOAP / SALTED ordering). + + Parameters + ---------- + rhat : (..., 3) array + Unit vectors. Zero-length inputs are handled by the caller. + lmax : + Maximum angular momentum, inclusive. + """ + if lmax > 4: + raise NotImplementedError( + f"_real_sph_harm only implements l = 0..4 (lmax={lmax} requested). " + "Extend or swap in e3nn.o3.spherical_harmonics for higher lmax." + ) + x, y, z = rhat[..., 0], rhat[..., 1], rhat[..., 2] + n_lm = (lmax + 1) ** 2 + out = np.empty(rhat.shape[:-1] + (n_lm,), dtype=np.float64) + + # l = 0 + out[..., 0] = 0.5 / _SQRT_PI + + if lmax >= 1: + # l = 1: Y_1{-1} ~ y, Y_10 ~ z, Y_11 ~ x + c1 = 0.5 * np.sqrt(3.0 / np.pi) + out[..., 1] = c1 * y + out[..., 2] = c1 * z + out[..., 3] = c1 * x + + if lmax >= 2: + # l = 2 + c2_xy = 0.5 * np.sqrt(15.0 / np.pi) # Y_2{-2}, Y_21, Y_2{-1} prefactors + c2_z2 = 0.25 * np.sqrt(5.0 / np.pi) + c2_x2y2 = 0.25 * np.sqrt(15.0 / np.pi) + out[..., 4] = c2_xy * x * y # Y_2{-2} + out[..., 5] = c2_xy * y * z # Y_2{-1} + out[..., 6] = c2_z2 * (3 * z * z - 1) # Y_20 + out[..., 7] = c2_xy * x * z # Y_21 + out[..., 8] = c2_x2y2 * (x * x - y * y) # Y_22 + + if lmax >= 3: + # l = 3 + c3a = 0.25 * np.sqrt(35.0 / (2.0 * np.pi)) + c3b = 0.5 * np.sqrt(105.0 / np.pi) + c3c = 0.25 * np.sqrt(21.0 / (2.0 * np.pi)) + c3d = 0.25 * np.sqrt(7.0 / np.pi) + out[..., 9] = c3a * y * (3 * x * x - y * y) # Y_3{-3} + out[..., 10] = c3b * x * y * z # Y_3{-2} + out[..., 11] = c3c * y * (5 * z * z - 1) # Y_3{-1} + out[..., 12] = c3d * z * (5 * z * z - 3) # Y_30 + out[..., 13] = c3c * x * (5 * z * z - 1) # Y_31 + out[..., 14] = 0.25 * np.sqrt(105.0 / np.pi) * z * (x * x - y * y) # Y_32 + out[..., 15] = c3a * x * (x * x - 3 * y * y) # Y_33 + + if lmax >= 4: + # l = 4 + c4a = 0.75 * np.sqrt(35.0 / np.pi) + c4b = 0.75 * np.sqrt(35.0 / (2.0 * np.pi)) + c4c = 0.75 * np.sqrt(5.0 / np.pi) + c4d = 0.75 * np.sqrt(5.0 / (2.0 * np.pi)) + c4e = 3.0 / 16.0 * np.sqrt(1.0 / np.pi) + out[..., 16] = c4a * x * y * (x * x - y * y) # Y_4{-4} + out[..., 17] = c4b * y * z * (3 * x * x - y * y) # Y_4{-3} + out[..., 18] = c4c * x * y * (7 * z * z - 1) # Y_4{-2} + out[..., 19] = c4d * y * z * (7 * z * z - 3) # Y_4{-1} + out[..., 20] = c4e * (35 * z**4 - 30 * z * z + 3) # Y_40 + out[..., 21] = c4d * x * z * (7 * z * z - 3) # Y_41 + out[..., 22] = ( + 0.375 * np.sqrt(5.0 / np.pi) * (x * x - y * y) * (7 * z * z - 1) + ) # Y_42 + out[..., 23] = c4b * x * z * (x * x - 3 * y * y) # Y_43 + out[..., 24] = ( + 0.1875 + * np.sqrt(35.0 / np.pi) + * (x * x * (x * x - 3 * y * y) - y * y * (3 * x * x - y * y)) + ) # Y_44 + + return out + + +# --------------------------------------------------------------------------- +# Per-atom basis-function evaluation at grid points +# --------------------------------------------------------------------------- +def _eval_basis_at_grid( + atom_position: np.ndarray, + grid_positions: np.ndarray, + cell: np.ndarray, + basis_spec: BasisSpec, +) -> np.ndarray: + """Evaluate every basis function centered on ``atom_position`` at every + grid point, using minimum-image convention. + + Returns ``(n_grid, n_coeffs_per_atom)`` array of basis-function values. + """ + # The masked points outside the cutoff intentionally produce some + # 0/0 and large-magnitude intermediates whose results we throw away + # via ``mask``. Silence the harmless RuntimeWarnings to keep test + # output readable. + with np.errstate(divide="ignore", invalid="ignore", over="ignore"): + inv_cell = np.linalg.inv(cell) + rel = grid_positions - atom_position[None, :] # (n_grid, 3) + # Minimum-image: wrap fractional displacement to [-0.5, 0.5] + frac_disp = rel @ inv_cell + frac_disp = frac_disp - np.round(frac_disp) + rel = frac_disp @ cell # (n_grid, 3) in Cartesian, wrapped + + r = np.linalg.norm(rel, axis=-1) # (n_grid,) + mask = r < basis_spec.cutoff + r_safe = np.where(r > 0, r, 1.0) + rhat = rel / r_safe[:, None] + + # Real spherical harmonics, (n_grid, (lmax+1)^2) + ylm = _real_sph_harm(rhat, basis_spec.max_l) + + n_grid = grid_positions.shape[0] + n_lm = ylm.shape[-1] + n_radial = basis_spec.n_radial + out = np.empty((n_grid, n_radial * n_lm), dtype=np.float64) + + for n_idx, sigma in enumerate(basis_spec.sigma): + radial = np.exp(-0.5 * (r / sigma) ** 2) * mask # (n_grid,) + # block layout: [n=0 lm=0..nlm-1, n=1 lm=0..nlm-1, ...] + out[:, n_idx * n_lm : (n_idx + 1) * n_lm] = radial[:, None] * ylm + + return out + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- +def project_chgcar_to_basis( + density_grid: np.ndarray, + atoms: ase.Atoms, + basis_spec: BasisSpec, +) -> np.ndarray: + """Project a real-space density grid onto the atom-centered basis. + + Solves one global least-squares system (``np.linalg.lstsq``) over + the full design matrix of all atoms' basis functions evaluated at + every grid point, so overlap between basis functions is handled + exactly rather than via a per-channel orthonormal approximation. + + Parameters + ---------- + density_grid : (Nx, Ny, Nz) array + Real-space density on the grid (CHGCAR-like). + atoms : ase.Atoms + Periodic structure. Provides positions and cell. + basis_spec : BasisSpec + Basis to project onto. + + Returns + ------- + (n_atoms, n_coeffs_per_atom) float64 array of coefficients. + """ + grid_shape = density_grid.shape + cell = np.asarray(atoms.get_cell()) + grid_pos = _grid_positions(grid_shape, cell) # (n_grid, 3) + rho_flat = density_grid.astype(np.float64).ravel() # (n_grid,) + + n_atoms = len(atoms) + coeffs = np.zeros((n_atoms, basis_spec.n_coeffs_per_atom), dtype=np.float64) + positions = atoms.get_positions() + + # Build the full per-structure design matrix B_global of shape + # (n_grid, n_atoms * n_coeffs_per_atom) and solve a single least- + # squares system for ALL atoms' coefficients simultaneously. This + # is the correct way to handle the strong overlap between our + # Gaussian basis functions (sigma ~ cutoff means heavy overlap). + # + # The previous orthonormal-approx (numer/denom per channel) + # produced ~1000% NMAPE on real LeMat-Rho rows because it + # overcounted contributions from overlapping basis functions + # (recorded in D1 sanity check, 2026-05-21). + n_per_atom = basis_spec.n_coeffs_per_atom + B_global = np.empty((grid_pos.shape[0], n_atoms * n_per_atom), dtype=np.float64) + with np.errstate(divide="ignore", invalid="ignore", over="ignore"): + for i, pos in enumerate(positions): + B_global[:, i * n_per_atom : (i + 1) * n_per_atom] = _eval_basis_at_grid( + pos, grid_pos, cell, basis_spec + ) + # lstsq is overdetermined (n_grid > n_atoms * n_per_atom for our + # 10x10x10 grids), so the solution is the unique minimum-residual + # least-squares fit. + c_flat, *_ = np.linalg.lstsq(B_global, rho_flat, rcond=None) + coeffs = c_flat.reshape(n_atoms, n_per_atom) + + return coeffs + + +def reconstruct_grid_from_basis( + coefficients: np.ndarray, + atoms: ase.Atoms, + grid_shape: tuple[int, int, int], + basis_spec: BasisSpec, +) -> np.ndarray: + """Reconstruct a density grid from per-atom basis coefficients. + + Just evaluates the basis at every grid point and contracts with the + coefficients. The reverse of ``project_chgcar_to_basis`` in the + sense that ``reconstruct(project(rho))`` is the best basis-set + approximation to ``rho``. + + Parameters + ---------- + coefficients : (n_atoms, n_coeffs_per_atom) array + atoms : ase.Atoms + grid_shape : (Nx, Ny, Nz) + basis_spec : BasisSpec + + Returns + ------- + (Nx, Ny, Nz) float64 density grid. + """ + n_atoms = len(atoms) + if coefficients.shape != (n_atoms, basis_spec.n_coeffs_per_atom): + raise ValueError( + f"coefficients shape {coefficients.shape} mismatches " + f"({n_atoms}, {basis_spec.n_coeffs_per_atom})" + ) + + cell = np.asarray(atoms.get_cell()) + grid_pos = _grid_positions(grid_shape, cell) + positions = atoms.get_positions() + + rho_flat = np.zeros(grid_pos.shape[0], dtype=np.float64) + coefficients = coefficients.astype(np.float64) + # Same harmless matmul warnings from masked-out grid points as in + # _eval_basis_at_grid; silence them at the caller too. + with np.errstate(divide="ignore", invalid="ignore", over="ignore"): + for i, pos in enumerate(positions): + B = _eval_basis_at_grid(pos, grid_pos, cell, basis_spec) + rho_flat += B @ coefficients[i] + + return rho_flat.reshape(grid_shape) diff --git a/salted_ft/rholearn_adapter.py b/salted_ft/rholearn_adapter.py new file mode 100644 index 0000000..11e1c14 --- /dev/null +++ b/salted_ft/rholearn_adapter.py @@ -0,0 +1,204 @@ +"""SALTED -> rholearn data-format adapter. + +rholearn's training loop consumes basis-coefficient vectors in +metatensor ``TensorMap`` format, with a specific flat-vector layout +that differs from our internal one: + +================== =================================================== +Our layout atom (outer) -> n (radial) -> lambda -> mu + (this is what ``project_chgcar_to_basis`` returns) +rholearn layout atom (outer) -> lambda -> n (radial) -> mu + (see ``rholearn/utils/convert.py::_get_flat_index``) +================== =================================================== + +This module provides three things: + +1. ``build_lmax_nmax(basis_spec, species)`` -- our uniform BasisSpec + expanded into rholearn's per-species ``lmax`` / ``nmax`` dicts. +2. ``dense_to_rholearn_flat`` / ``rholearn_flat_to_dense`` -- the + permutation between the two layouts, ndarray <-> ndarray. Roundtrip + is exact and pinned by tests. +3. ``dense_to_tensormap`` -- the full path that calls rholearn's + ``convert.coeff_vector_ndarray_to_tensormap`` to produce a + ``metatensor.TensorMap``. Lazy-imports rholearn / metatensor. + +The permutation is the load-bearing piece. Get it wrong and rholearn +trains on misordered data; the value at index k of the flat vector +no longer corresponds to the (lambda, n, mu) channel rholearn thinks +it does. +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterable +from pathlib import Path + +import numpy as np + +from salted_ft.basis import BasisSpec + +# Path setup for lazy rholearn import. Same pattern as +# charge3net_ft/model.py and deepdft_ft/runner.py. +_RHOLEARN_ROOT = Path(__file__).resolve().parent.parent.parent / "rholearn" + + +def _ensure_rholearn_importable() -> None: + if not _RHOLEARN_ROOT.exists(): + raise RuntimeError( + f"rholearn repo not found at {_RHOLEARN_ROOT}.\n" + "Clone it with: git clone https://github.com/lab-cosmo/rholearn " + f"{_RHOLEARN_ROOT}" + ) + if str(_RHOLEARN_ROOT) not in sys.path: + sys.path.insert(0, str(_RHOLEARN_ROOT)) + + +# --------------------------------------------------------------------------- +# Basis spec dict builder +# --------------------------------------------------------------------------- +def build_lmax_nmax( + basis_spec: BasisSpec, species: Iterable[str] +) -> tuple[dict[str, int], dict[tuple[str, int], int]]: + """Expand our uniform BasisSpec into rholearn's per-species dicts. + + Returns + ------- + lmax : ``{species: max_l}`` for every species in ``species`` + nmax : ``{(species, lambda): n_radial}`` for every (species, lambda) + """ + species = list(species) + lmax = {s: basis_spec.max_l for s in species} + nmax = { + (s, lam): basis_spec.n_radial + for s in species + for lam in range(basis_spec.max_l + 1) + } + return lmax, nmax + + +# --------------------------------------------------------------------------- +# Permutation between our layout and rholearn's +# --------------------------------------------------------------------------- +def _our_to_rholearn_permutation(basis_spec: BasisSpec) -> np.ndarray: + """Return the index permutation ``p`` such that ``rholearn_flat[k] == + our_flat[p[k]]`` for a SINGLE atom. + + Our per-atom layout (length ``n_radial * (max_l + 1) ** 2``): + for n in 0..n_radial: + for lambda in 0..max_l: + for mu in -lambda..+lambda: + yield (n, lambda, mu) + + rholearn's per-atom layout (same total length): + for lambda in 0..max_l: + for n in 0..n_radial: + for mu in -lambda..+lambda: + yield (lambda, n, mu) + + The permutation is independent of the species (uniform basis). + """ + n_radial = basis_spec.n_radial + max_l = basis_spec.max_l + + # Source flat index for (n, lambda, mu) in OUR layout: + # n * (max_l + 1) ** 2 + lambda * lambda + (mu + lambda) + # (the second-and-third pieces together index the standard Y_lm slot) + def our_idx(n: int, lam: int, mu: int) -> int: + return n * (max_l + 1) ** 2 + lam * lam + (mu + lam) + + # Build the permutation by walking rholearn's order + perm = np.empty(n_radial * (max_l + 1) ** 2, dtype=np.int64) + k = 0 + for lam in range(max_l + 1): + for n in range(n_radial): + for mu in range(-lam, lam + 1): + perm[k] = our_idx(n, lam, mu) + k += 1 + return perm + + +def dense_to_rholearn_flat( + coeffs: np.ndarray, + basis_spec: BasisSpec, + symbols: Iterable[str], +) -> np.ndarray: + """Convert our dense ``(n_atoms, n_coeffs_per_atom)`` coefficients to + rholearn's flat per-structure vector. + + Output length: ``n_atoms * n_coeffs_per_atom``. ``symbols`` is + accepted for API symmetry with the inverse and species-aware + extensions; today the permutation is species-independent because + our BasisSpec is uniform across species. + """ + n_atoms = coeffs.shape[0] + assert coeffs.shape == (n_atoms, basis_spec.n_coeffs_per_atom) + perm = _our_to_rholearn_permutation(basis_spec) + # ``coeffs[:, perm]`` reorders each atom's row from our layout to rholearn's + return coeffs[:, perm].ravel().astype(np.float64) + + +def rholearn_flat_to_dense( + flat: np.ndarray, + basis_spec: BasisSpec, + symbols: Iterable[str], +) -> np.ndarray: + """Inverse of ``dense_to_rholearn_flat``. Returns the dense + ``(n_atoms, n_coeffs_per_atom)`` array. + """ + n_coeffs = basis_spec.n_coeffs_per_atom + if flat.size % n_coeffs != 0: + raise ValueError( + f"flat vector length {flat.size} is not a multiple of " + f"n_coeffs_per_atom={n_coeffs}; cannot reshape to (n_atoms, n_coeffs)" + ) + n_atoms = flat.size // n_coeffs + reshaped = flat.reshape(n_atoms, n_coeffs).astype(np.float64) + # Inverse permutation: ``inv[perm[k]] = k``. + perm = _our_to_rholearn_permutation(basis_spec) + inv = np.empty_like(perm) + inv[perm] = np.arange(perm.size) + return reshaped[:, inv] + + +# --------------------------------------------------------------------------- +# Full TensorMap path +# --------------------------------------------------------------------------- +def dense_to_tensormap( + coeffs: np.ndarray, + basis_spec: BasisSpec, + symbols: Iterable[str], + positions: np.ndarray, + cell: np.ndarray, + structure_idx: int = 0, +): + """Convert dense coefficients to a ``metatensor.TensorMap`` using + rholearn's converter. + + Lazy-imports rholearn + metatensor so this module is importable + without those deps installed (the permutation tests above are + pure numpy). + """ + _ensure_rholearn_importable() + import chemfiles + from rholearn.utils import convert # type: ignore[import-not-found] + + flat = dense_to_rholearn_flat(coeffs, basis_spec, symbols) + lmax, nmax = build_lmax_nmax(basis_spec, set(symbols)) + + # Build a chemfiles Frame from the structure (rholearn's converter + # expects one). + frame = chemfiles.Frame() + frame.cell = chemfiles.UnitCell(np.asarray(cell, dtype=np.float64)) + for sym, pos in zip(list(symbols), np.asarray(positions), strict=True): + atom = chemfiles.Atom(sym) + frame.add_atom(atom, list(pos)) + + return convert.coeff_vector_ndarray_to_tensormap( + frame, + coeff_vector=flat, + lmax=lmax, + nmax=nmax, + structure_idx=structure_idx, + tests=0, + ) diff --git a/salted_ft/train_baseline.py b/salted_ft/train_baseline.py new file mode 100644 index 0000000..7e23143 --- /dev/null +++ b/salted_ft/train_baseline.py @@ -0,0 +1,294 @@ +"""SALTED arm: PyTorch baseline coefficient-prediction model + train loop (D6). + +Path B of the D6 plan: skip the rholearn integration, train a small +SchNet-style invariant message-passing network directly on the D2 +projected coefficients with MSE loss. Produces a checkpoint that +``scripts/density_model_eval.py`` can load and exercise via the +SALTED arm path. + +Architecture is deliberately minimal: + +* Per-atom species embedding (Z -> ``hidden_dim`` vector). +* Gaussian RBF distance featurisation over neighbours within the + ``BasisSpec.cutoff``. +* Two SchNet-style continuous-filter convolution layers. +* Per-atom readout MLP -> ``n_coeffs_per_atom`` channels. + +Notes +----- + +* The output is *invariant* under rotation. The l>0 channels of the + SALTED basis are equivariant by construction, so this baseline + will be systematically wrong on those channels. It still gives a + reasonable scalar density once reconstructed, and is a starting + point for the comparison table. Upgrade to e3nn/MACE for proper + equivariance. +* The dataset reads two parquet directories: D2 source (atom + positions) and D2 projected coefficients (training targets), + joined on ``row_index`` per matching chunk filename. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable +from pathlib import Path + +import ase +import numpy as np +import pyarrow.parquet as pq +import torch +import torch.nn.functional as F +from ase.neighborlist import primitive_neighbor_list +from torch import nn + +from salted_ft.basis import BasisSpec + + +class GaussianRBF(nn.Module): + """Gaussian radial basis expansion of distances.""" + + def __init__(self, n_basis: int = 16, cutoff: float = 4.0, sigma: float = 0.4): + super().__init__() + self.register_buffer("centers", torch.linspace(0.0, cutoff, n_basis)) + self.sigma = sigma + + def forward(self, d: torch.Tensor) -> torch.Tensor: + return torch.exp( + -0.5 * ((d[:, None] - self.centers[None, :]) / self.sigma) ** 2 + ) + + +class CfConv(nn.Module): + """SchNet-style continuous filter convolution.""" + + def __init__(self, hidden_dim: int, n_basis: int): + super().__init__() + self.filter_net = nn.Sequential( + nn.Linear(n_basis, hidden_dim), + nn.SiLU(), + nn.Linear(hidden_dim, hidden_dim), + ) + self.pre = nn.Linear(hidden_dim, hidden_dim) + self.post = nn.Sequential( + nn.Linear(hidden_dim, hidden_dim), + nn.SiLU(), + nn.Linear(hidden_dim, hidden_dim), + ) + + def forward( + self, + x: torch.Tensor, + edge_index: torch.Tensor, + edge_rbf: torch.Tensor, + ) -> torch.Tensor: + if edge_index.numel() == 0: + return x + self.post(self.pre(x) * 0) + src, dst = edge_index + msg = self.pre(x)[src] * self.filter_net(edge_rbf) + agg = torch.zeros_like(x) + agg.index_add_(0, dst, msg) + return x + self.post(agg) + + +class SaltedBaselineModel(nn.Module): + """SchNet-style invariant message-passing network for per-atom coefficients.""" + + def __init__( + self, + basis_spec: BasisSpec, + hidden_dim: int = 64, + n_basis: int = 16, + n_layers: int = 2, + max_z: int = 120, + ): + super().__init__() + self.basis_spec = basis_spec + self.cutoff = float(basis_spec.cutoff) + self.z_embed = nn.Embedding(max_z, hidden_dim) + self.rbf = GaussianRBF(n_basis=n_basis, cutoff=self.cutoff) + self.layers = nn.ModuleList( + [CfConv(hidden_dim, n_basis) for _ in range(n_layers)] + ) + self.readout = nn.Sequential( + nn.Linear(hidden_dim, hidden_dim), + nn.SiLU(), + nn.Linear(hidden_dim, basis_spec.n_coeffs_per_atom), + ) + + def forward(self, atoms: ase.Atoms) -> torch.Tensor: + device = self.z_embed.weight.device + z = torch.from_numpy(atoms.get_atomic_numbers().astype(np.int64)).to(device) + positions = atoms.get_positions().astype(np.float64) + cell = np.asarray(atoms.get_cell(), dtype=np.float64) + pbc = atoms.get_pbc() + + # ASE PBC-aware neighbour list within the cutoff. + # 'ijD' -> source idx, dest idx, displacement vector + i, j, D = primitive_neighbor_list("ijD", pbc, cell, positions, self.cutoff) + if len(i) == 0: + edge_index = torch.zeros((2, 0), dtype=torch.long, device=device) + edge_rbf = torch.zeros((0, self.rbf.centers.numel()), device=device) + else: + edge_index = torch.tensor(np.stack([i, j]), dtype=torch.long, device=device) + dist = torch.tensor( + np.linalg.norm(D, axis=1), dtype=torch.float32, device=device + ) + edge_rbf = self.rbf(dist) + + x = self.z_embed(z) + for layer in self.layers: + x = layer(x, edge_index, edge_rbf) + return self.readout(x) + + +class SaltedTrainingDataset: + """Join D2 source (positions) + projected coefficients (targets) by row_index.""" + + def __init__( + self, + source_dir: str | Path, + coeffs_dir: str | Path, + ): + source_dir = Path(source_dir) + coeffs_dir = Path(coeffs_dir) + + src_files = {p.name: p for p in source_dir.glob("chunk_*.parquet")} + coeffs_files = {p.name: p for p in coeffs_dir.glob("chunk_*.parquet")} + common = sorted(set(src_files) & set(coeffs_files)) + if not common: + raise RuntimeError( + f"No matching chunk_*.parquet in {source_dir} and {coeffs_dir}" + ) + + self._index: list[tuple[str, int]] = [] + for name in common: + n = pq.ParquetFile(coeffs_files[name]).metadata.num_rows + for ri in range(n): + self._index.append((name, ri)) + self._src_files = src_files + self._coeffs_files = coeffs_files + # Per-chunk cache so each parquet is read at most once per worker. + self._src_cache: dict[str, dict] = {} + self._coeffs_cache: dict[str, dict] = {} + + def __len__(self) -> int: + return len(self._index) + + def _load(self, name: str) -> tuple[dict, dict]: + if name not in self._src_cache: + self._src_cache[name] = pq.read_table(self._src_files[name]).to_pydict() + if name not in self._coeffs_cache: + self._coeffs_cache[name] = pq.read_table( + self._coeffs_files[name] + ).to_pydict() + return self._src_cache[name], self._coeffs_cache[name] + + def __getitem__(self, idx: int) -> tuple[ase.Atoms, torch.Tensor]: + name, ri = self._index[idx] + src, coeffs = self._load(name) + # Match by row_index in case projected rows are a subset (D2 skips + # rows with null charge density). + src_row_indices = src["row_index"] + try: + src_ri = src_row_indices.index(coeffs["row_index"][ri]) + except ValueError as err: + raise RuntimeError( + f"Row {ri} of {name} (row_index=" + f"{coeffs['row_index'][ri]}) has no source counterpart" + ) from err + + n_atoms = int(coeffs["n_atoms"][ri]) + positions = np.asarray(src["cartesian_site_positions"][src_ri]).reshape(-1, 3) + cell = np.asarray(src["lattice_vectors"][src_ri]).reshape(3, 3) + Z = np.asarray(coeffs["atomic_numbers"][ri]) + target = np.asarray(coeffs["coefficients"][ri]).reshape(n_atoms, -1) + atoms = ase.Atoms(numbers=Z, positions=positions, cell=cell, pbc=True) + return atoms, torch.from_numpy(target.astype(np.float32)) + + +def train( + source_dir: str | Path, + coeffs_dir: str | Path, + output_ckpt: str | Path, + basis_spec: BasisSpec, + n_epochs: int = 10, + batch_size: int = 8, + learning_rate: float = 1e-3, + device: str = "cpu", + log_every: int = 50, +) -> None: + """Standard PyTorch training loop with gradient accumulation per batch.""" + dataset = SaltedTrainingDataset(source_dir, coeffs_dir) + model = SaltedBaselineModel(basis_spec).to(device) + opt = torch.optim.AdamW(model.parameters(), lr=learning_rate) + + step = 0 + for epoch in range(n_epochs): + order = np.random.permutation(len(dataset)) + for start in range(0, len(order), batch_size): + batch_idx = order[start : start + batch_size] + opt.zero_grad() + losses = [] + for i in batch_idx: + atoms, target = dataset[int(i)] + target = target.to(device) + pred = model(atoms) + loss = F.mse_loss(pred, target) + (loss / len(batch_idx)).backward() + losses.append(loss.item()) + opt.step() + step += 1 + if step % log_every == 0: + mean = float(np.mean(losses)) + print(f"epoch {epoch} step {step} mse {mean:.6f}") + + torch.save( + {"basis_spec": basis_spec, "model": model.state_dict()}, + Path(output_ckpt), + ) + + +def _build_cli() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Train the SALTED baseline model.") + p.add_argument( + "--source-dir", + type=Path, + required=True, + help="D2 input parquet dir (cartesian_site_positions live here).", + ) + p.add_argument( + "--coeffs-dir", + type=Path, + required=True, + help="D2 projected coefficients parquet dir.", + ) + p.add_argument( + "--output-ckpt", + type=Path, + required=True, + help="Path for the trained checkpoint .pt file.", + ) + p.add_argument("--n-epochs", type=int, default=10) + p.add_argument("--batch-size", type=int, default=8) + p.add_argument("--learning-rate", type=float, default=1e-3) + p.add_argument("--device", default="cpu") + return p + + +def main(argv: Iterable[str] | None = None) -> None: + args = _build_cli().parse_args(argv) + train( + source_dir=args.source_dir, + coeffs_dir=args.coeffs_dir, + output_ckpt=args.output_ckpt, + basis_spec=BasisSpec(), + n_epochs=args.n_epochs, + batch_size=args.batch_size, + learning_rate=args.learning_rate, + device=args.device, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/density_model_comparison_table.py b/scripts/density_model_comparison_table.py new file mode 100644 index 0000000..ef48b05 --- /dev/null +++ b/scripts/density_model_comparison_table.py @@ -0,0 +1,123 @@ +"""Aggregate D7 per-arm eval outputs into a cross-arm comparison (D8). + +Reads one or more parquet files produced by +``scripts/density_model_eval.py`` and writes: + +* A CSV with one row per arm: ``model``, ``n_structures``, + ``nmape_mean``, ``nmape_std``, ``nmape_median`` and the same for + ``rmse`` / ``nrmse``. +* A GitHub-flavoured markdown table for paste-into-PR consumption. + +Each input parquet may carry rows from one arm (typical) or +multiple arms; rows are grouped by the ``model`` column so it +works either way. Multiple input files for the same arm are +concatenated before aggregation, which is the right behaviour +when a sharded eval run writes per-chunk outputs. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pandas as pd + +_METRIC_COLS = ("nmape", "rmse", "nrmse") + + +def aggregate_per_arm(inputs: list[str | Path]) -> pd.DataFrame: + """Concatenate the per-row eval parquets and aggregate per arm. + + Parameters + ---------- + inputs : + Paths to D7-shaped per-row eval parquets. + + Returns + ------- + pd.DataFrame with one row per arm and columns: + ``model``, ``n_structures``, ``{nmape,rmse,nrmse}_{mean,std,median}``. + """ + frames = [pd.read_parquet(p) for p in inputs] + df = pd.concat(frames, ignore_index=True) + + rows = [] + for model_name, group in df.groupby("model", sort=True): + row = {"model": model_name, "n_structures": len(group)} + for metric in _METRIC_COLS: + row[f"{metric}_mean"] = float(group[metric].mean()) + row[f"{metric}_std"] = float(group[metric].std(ddof=0)) + row[f"{metric}_median"] = float(group[metric].median()) + rows.append(row) + return pd.DataFrame(rows) + + +def render_markdown_table(agg: pd.DataFrame) -> str: + """Render the aggregated table as a GitHub-flavoured markdown table. + + Format:: + + | Model | N | NMAPE (%) | RMSE (e/A^3) | NRMSE (%) | + | --- | --- | --- | --- | --- | + | salted | 1500 | 32.10 +/- 8.42 | 0.0120 +/- 0.0050 | 28.70 +/- 7.20 | + """ + header = "| Model | N | NMAPE (%) | RMSE (e/A^3) | NRMSE (%) |" + sep = "| --- | --- | --- | --- | --- |" + lines = [header, sep] + for _, row in agg.iterrows(): + lines.append( + "| {model} | {n} | {nmape:.2f} +/- {nmape_s:.2f} | " + "{rmse:.4f} +/- {rmse_s:.4f} | {nrmse:.2f} +/- {nrmse_s:.2f} |".format( + model=row["model"], + n=int(row["n_structures"]), + nmape=row["nmape_mean"], + nmape_s=row["nmape_std"], + rmse=row["rmse_mean"], + rmse_s=row["rmse_std"], + nrmse=row["nrmse_mean"], + nrmse_s=row["nrmse_std"], + ) + ) + return "\n".join(lines) + "\n" + + +def build_comparison_table( + inputs: list[str | Path], + csv_path: str | Path, + markdown_path: str | Path, +) -> pd.DataFrame: + """End-to-end: aggregate + write CSV and markdown.""" + agg = aggregate_per_arm(inputs) + Path(csv_path).write_text(agg.to_csv(index=False)) + Path(markdown_path).write_text(render_markdown_table(agg)) + return agg + + +def _build_cli() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Aggregate per-arm density eval parquets into a comparison table." + ) + parser.add_argument( + "--inputs", + nargs="+", + type=Path, + required=True, + help="One or more D7-output parquets.", + ) + parser.add_argument("--csv", required=True, type=Path, help="Output CSV path.") + parser.add_argument( + "--markdown", required=True, type=Path, help="Output markdown path." + ) + return parser + + +def main() -> None: + args = _build_cli().parse_args() + agg = build_comparison_table( + inputs=args.inputs, csv_path=args.csv, markdown_path=args.markdown + ) + print(render_markdown_table(agg)) + + +if __name__ == "__main__": + main() diff --git a/scripts/density_model_eval.py b/scripts/density_model_eval.py new file mode 100644 index 0000000..811f340 --- /dev/null +++ b/scripts/density_model_eval.py @@ -0,0 +1,344 @@ +"""Single-model density evaluation across the LeMat-Rho arms (D7). + +Per-structure evaluator: load a model arm, predict the real-space +density on a regular grid for each test row, and write per-structure +NMAPE / RMSE / NRMSE against the ground-truth density into a +parquet file. Driven from the CLI; importable for D8 (the +comparison-table builder) which calls ``evaluate_dataset`` directly. + +Arm coverage +------------ + +* ``salted`` -- fully wired. Stub mode (no ckpt) is supported via + ``SALTEDModel(basis_spec, ckpt_path=None)``; real mode lands when + D6 (SALTED training driver) produces a checkpoint. +* ``charge3net`` -- fully wired via ``_charge3net_predict_grid`` + (full-grid graph built with charge3net's KdTreeGraphConstructor, + probes batched over the Nx*Ny*Nz grid coordinates). +* ``deepdft`` -- fully wired via ``_deepdft_predict_grid`` (reuses the + same graph construction; needs the DeepDFT sibling clone on + sys.path via ``deepdft_ft.runner``). + +The Graph2Mat arm is parked (see graph2mat_ft/__init__.py); not +exposed here. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import ase +import numpy as np +import pandas as pd + +from salted_ft.basis import BasisSpec + + +def density_nmape(pred: np.ndarray, target: np.ndarray) -> float: + """Integral-normalised MAPE: sum(|target - pred|) / sum(|target|) * 100.""" + return float(np.abs(pred - target).sum() / (np.abs(target).sum() + 1e-10) * 100.0) + + +def density_rmse(pred: np.ndarray, target: np.ndarray) -> float: + """Root mean squared error across all grid points.""" + return float(np.sqrt(((pred - target) ** 2).mean())) + + +def density_nrmse(pred: np.ndarray, target: np.ndarray) -> float: + """RMSE / mean(|target|) * 100. Comparable across electron counts.""" + return float( + np.sqrt(((pred - target) ** 2).mean()) / (np.abs(target).mean() + 1e-10) * 100.0 + ) + + +def predict_density( + model_name: str, + atoms: ase.Atoms, + grid_shape: tuple[int, int, int], + ckpt: str | Path | None, + basis_spec: BasisSpec, + model: object | None = None, + max_probe_batch: int = 2500, +) -> np.ndarray: + """Dispatch to the per-arm grid prediction path. + + Parameters + ---------- + model : + Optional pre-loaded model. If provided, ``ckpt`` is ignored. + Lets tests inject a mock without going through real ckpt loading. + max_probe_batch : + ChargE3Net / DeepDFT probe-batching size. Lower if the device + runs out of memory on big grids. + """ + if model_name == "salted": + # Lazy import: the deepdft / charge3net branches do not need + # rholearn or sibling repos available. + from salted_ft.model import SALTEDModel + + m = model if model is not None else SALTEDModel(basis_spec, ckpt_path=ckpt) + return m.reconstruct_density(atoms, grid_shape) + if model_name == "charge3net": + return _charge3net_predict_grid( + model=model, + ckpt=ckpt, + atoms=atoms, + grid_shape=grid_shape, + max_probe_batch=max_probe_batch, + ) + if model_name == "deepdft": + return _deepdft_predict_grid( + model=model, + ckpt=ckpt, + atoms=atoms, + grid_shape=grid_shape, + max_probe_batch=max_probe_batch, + ) + raise ValueError(f"unknown model arm: {model_name!r}") + + +def _charge3net_predict_grid( + model: object | None, + ckpt: str | Path | None, + atoms: ase.Atoms, + grid_shape: tuple[int, int, int], + max_probe_batch: int, +) -> np.ndarray: + """ChargE3Net grid prediction via probe-batched forward. + + Builds the full-grid graph using charge3net's own + ``KdTreeGraphConstructor`` so atom and probe edges match what + the model saw during training, batches probes through + ``split_batch``, and reshapes to ``(Nx, Ny, Nz)``. + + Loading paths + ------------- + * ``model`` provided: use it directly. The path tests rely on + to mock the network without a real ckpt. + * Else, ``ChargE3NetWrapper(ckpt_path=ckpt)`` is constructed. + Requires the charge3net sibling repo present at + ``../charge3net/`` (resolved by ``charge3net_ft.model``). + """ + import torch + + # Import charge3net_ft.model unconditionally for the sys.path side + # effect (it adds ../charge3net to sys.path so the src.* helpers + # below resolve). When the caller supplies a model directly we still + # need charge3net's data utilities to build the graph. + import charge3net_ft.model as _c3n_wrapper_module # noqa: F401 + + if model is None: + from charge3net_ft.model import ChargE3NetWrapper + + model = ChargE3NetWrapper(ckpt_path=ckpt) + + from src.charge3net.data.collate import collate_list_of_dicts + from src.charge3net.data.graph_construction import KdTreeGraphConstructor + from src.utils.data import calculate_grid_pos + from src.utils.predictions import split_batch + + grid_shape_arr = np.asarray(grid_shape, dtype=np.int64) + dummy_density = np.zeros(tuple(grid_shape_arr), dtype=np.float32) + origin = np.zeros(3, dtype=np.float64) + grid_pos = calculate_grid_pos(dummy_density, origin, atoms.get_cell()) + + constructor = KdTreeGraphConstructor(cutoff=4.0, num_probes=None, disable_pbc=False) + graph_dict = constructor(dummy_density, atoms, grid_pos) + batched = collate_list_of_dicts([graph_dict], pin_memory=False) + + if hasattr(model, "train"): + model.train(False) + + preds: list[torch.Tensor] = [] + with torch.no_grad(): + for sub_batch in split_batch(batched, max_probe_batch): + out = model(sub_batch) + preds.append(out.detach().cpu().squeeze(0)) + + rho_flat = torch.cat(preds, dim=0).numpy() + return rho_flat.reshape(tuple(grid_shape_arr)) + + +def _deepdft_predict_grid( + model: object | None, + ckpt: str | Path | None, + atoms: ase.Atoms, + grid_shape: tuple[int, int, int], + max_probe_batch: int, + num_interactions: int = 3, + node_size: int = 128, + cutoff: float = 4.0, + use_painn: bool = True, +) -> np.ndarray: + """DeepDFT grid prediction via probe-batched forward. + + DeepDFT is the upstream code that ChargE3Net forked, so the + forward input dict shape is identical: same probe_xyz / + probe_edges / num_probes / etc. We reuse charge3net's data + utilities (already imported by ``_charge3net_predict_grid``) + to build the graph. The arm-specific bits are: + + * sys.path side effect from ``deepdft_ft.runner`` (adds + ``../DeepDFT`` and stubs ``asap3`` if it is missing). + * model construction via ``densitymodel.PainnDensityModel`` or + ``densitymodel.DensityModel`` (SchNet variant). + * defaults match ``submit_deepdft_adastra.sh``: + num_interactions=3, node_size=128, cutoff=4.0, PaiNN. + + Loading paths + ------------- + * ``model`` provided: use it directly (tests inject mocks here). + * Else, build the model and ``torch.load`` the ckpt. + """ + import torch + + # sys.path side effect + asap3 stub, must happen before importing + # densitymodel even when caller supplied the model. + import deepdft_ft.runner as _deepdft_runner_module # noqa: F401 + + if model is None: + import densitymodel + + if use_painn: + model = densitymodel.PainnDensityModel(num_interactions, node_size, cutoff) + else: + model = densitymodel.DensityModel(num_interactions, node_size, cutoff) + if ckpt is not None: + state = torch.load(str(ckpt), map_location="cpu", weights_only=False) + # DeepDFT's ckpts wrap the state dict in a "model" key + state_dict = state.get("model", state) + model.load_state_dict(state_dict) + + # Reuse the charge3net data layer (DeepDFT input dict is the same). + from src.charge3net.data.collate import collate_list_of_dicts + from src.charge3net.data.graph_construction import KdTreeGraphConstructor + from src.utils.data import calculate_grid_pos + from src.utils.predictions import split_batch + + import charge3net_ft.model as _c3n_wrapper_module # noqa: F401 + + grid_shape_arr = np.asarray(grid_shape, dtype=np.int64) + dummy_density = np.zeros(tuple(grid_shape_arr), dtype=np.float32) + origin = np.zeros(3, dtype=np.float64) + grid_pos = calculate_grid_pos(dummy_density, origin, atoms.get_cell()) + + constructor = KdTreeGraphConstructor( + cutoff=cutoff, num_probes=None, disable_pbc=False + ) + graph_dict = constructor(dummy_density, atoms, grid_pos) + batched = collate_list_of_dicts([graph_dict], pin_memory=False) + + if hasattr(model, "train"): + model.train(False) + + preds: list[torch.Tensor] = [] + with torch.no_grad(): + for sub_batch in split_batch(batched, max_probe_batch): + out = model(sub_batch) + preds.append(out.detach().cpu().squeeze(0)) + + rho_flat = torch.cat(preds, dim=0).numpy() + return rho_flat.reshape(tuple(grid_shape_arr)) + + +def _row_to_atoms(row: pd.Series) -> ase.Atoms: + """Reconstruct an ase.Atoms from a LeMat-Rho-shaped parquet row.""" + positions = np.asarray(row["positions"]).reshape(-1, 3) + cell = np.asarray(row["lattice_vectors"]).reshape(3, 3) + numbers = np.asarray(row["atomic_numbers"]) + return ase.Atoms(numbers=numbers, positions=positions, cell=cell, pbc=True) + + +def _row_target_grid(row: pd.Series) -> tuple[np.ndarray, tuple[int, int, int]]: + grid_shape = tuple(int(x) for x in row["grid_shape"]) + target = np.asarray(row["charge_density"]).reshape(grid_shape) + return target, grid_shape + + +def evaluate_dataset( + model_name: str, + test_parquet: str | Path, + ckpt: str | Path | None, + basis_spec: BasisSpec, + output: str | Path, + limit: int | None = None, +) -> Path: + """Loop over rows in ``test_parquet`` and write per-row metrics.""" + df_in = pd.read_parquet(test_parquet) + if limit is not None: + df_in = df_in.head(limit) + + rows = [] + ckpt_label = str(ckpt) if ckpt is not None else "stub" + for _, row in df_in.iterrows(): + atoms = _row_to_atoms(row) + target, grid_shape = _row_target_grid(row) + pred = predict_density(model_name, atoms, grid_shape, ckpt, basis_spec) + rows.append( + { + "model": model_name, + "ckpt": ckpt_label, + "material_id": row.get("material_id"), + "n_atoms": int(row.get("n_atoms", len(atoms))), + "nmape": density_nmape(pred, target), + "rmse": density_rmse(pred, target), + "nrmse": density_nrmse(pred, target), + } + ) + + out_df = pd.DataFrame(rows) + out_path = Path(output) + out_df.to_parquet(out_path) + return out_path + + +def _build_cli() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Per-structure density-prediction eval for LeMat-Rho arms." + ) + parser.add_argument( + "--model", + required=True, + choices=("salted", "charge3net", "deepdft"), + help="Which arm to evaluate.", + ) + parser.add_argument( + "--test-parquet", + required=True, + type=Path, + help="Path to test split parquet (LeMat-Rho row layout).", + ) + parser.add_argument( + "--output", required=True, type=Path, help="Output parquet path." + ) + parser.add_argument( + "--ckpt", + type=Path, + default=None, + help="Model checkpoint. Omit for stub mode (where supported).", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Evaluate only the first N rows (smoke-test).", + ) + return parser + + +def main() -> None: + args = _build_cli().parse_args() + out_path = evaluate_dataset( + model_name=args.model, + test_parquet=args.test_parquet, + ckpt=args.ckpt, + basis_spec=BasisSpec(), + output=args.output, + limit=args.limit, + ) + print(f"Wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/scf_speedup_run.py b/scripts/scf_speedup_run.py new file mode 100644 index 0000000..69f863a --- /dev/null +++ b/scripts/scf_speedup_run.py @@ -0,0 +1,321 @@ +"""SCF-speedup experiment driver (P4). + +For each row in a held-out test parquet, the driver: + +1. Reconstructs the ``ase.Atoms`` + grid_shape + n_electrons. +2. Predicts the density via the chosen ML arm + (``scripts.density_model_eval.predict_density`` already supports + ``salted``, ``charge3net``, and ``deepdft``). +3. Writes a CHGCAR with VASP's electron-count rescaling so + ``ICHARG=1`` reads a self-consistent total. +4. Builds a paired baseline + predicted Flow via + ``entalsim.dft.scf_speedup.make_scf_speedup_pair`` and submits it + to MongoDB via ``entalsim.core.submit.submit_workflow``. + +The two entalsim callables are dependency-injectable so the driver +unit-tests pass locally without entalsim installed; the CLI imports +them at runtime. +""" + +from __future__ import annotations + +import argparse +import importlib +import json +import logging +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import ase +import numpy as np +import pandas as pd +from pymatgen.io.ase import AseAtomsAdaptor +from tqdm.auto import tqdm + +from salted_ft.basis import BasisSpec +from salted_ft.io import write_chgcar + +logger = logging.getLogger(__name__) + +# scripts/ is not a package; reach the sibling module via sys.path +# (same pattern the test fixture uses). +_SCRIPTS_DIR = Path(__file__).resolve().parent +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) +_density_eval = importlib.import_module("density_model_eval") +predict_density = _density_eval.predict_density + + +_ARMS_REQUIRING_CKPT = ("charge3net", "deepdft") + + +def _row_to_atoms(row: pd.Series) -> ase.Atoms: + positions = np.asarray(row["positions"]).reshape(-1, 3) + cell = np.asarray(row["lattice_vectors"]).reshape(3, 3) + numbers = np.asarray(row["atomic_numbers"]) + return ase.Atoms(numbers=numbers, positions=positions, cell=cell, pbc=True) + + +def _row_grid_shape(row: pd.Series) -> tuple[int, int, int]: + return tuple(int(x) for x in row["grid_shape"]) + + +def _load_submitted_ids(manifest_path: Path, model_name: str) -> set[str]: + """Read a JSONL manifest and return material_ids previously submitted. + + Failed rows (``submitted=False``) are intentionally NOT counted so + the next run retries them. + """ + if not manifest_path.exists(): + return set() + submitted: set[str] = set() + for line in manifest_path.read_text().splitlines(): + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + logger.warning("Skipping malformed manifest line: %s", line[:80]) + continue + if rec.get("model") == model_name and rec.get("submitted") is True: + submitted.add(str(rec["material_id"])) + return submitted + + +def run_experiment( + model_name: str, + test_parquet: str | Path, + chgcar_dir: str | Path, + basis_spec: BasisSpec, + project: str, + worker: str, + ckpt: str | Path | None = None, + limit: int | None = None, + dry_run: bool = False, + manifest_path: str | Path | None = None, + skip_existing: bool = False, + make_pair_fn: Callable[..., Any] | None = None, + submit_fn: Callable[..., Any] | None = None, +) -> list[dict[str, Any]]: + """Loop the test parquet and submit one paired Flow per row. + + The driver is resilient to per-row failures: a bad row records + an ``error`` entry and the loop continues. Results stream to a + JSONL manifest after each row so an interrupted run leaves a + resumable record. ``skip_existing=True`` skips rows whose + ``material_id`` is already marked ``submitted=True`` in the + manifest for this ``model_name`` (failed rows are retried). + """ + if model_name in _ARMS_REQUIRING_CKPT and ckpt is None: + raise ValueError( + f"--ckpt is required for arm {model_name!r}; running without " + "weights produces random-init predictions and wastes HPC time. " + "Stub mode is supported only for 'salted'." + ) + + # Lazy-import entalsim callables when the caller did not inject + # mocks. Keeps the test suite passable without entalsim installed. + if make_pair_fn is None: + from entalsim.dft.scf_speedup import make_scf_speedup_pair as make_pair_fn + if submit_fn is None: + from entalsim.core.submit import submit_workflow as submit_fn + + chgcar_root = Path(chgcar_dir) + chgcar_root.mkdir(parents=True, exist_ok=True) + if manifest_path is None: + manifest_path = chgcar_root / "manifest.jsonl" + else: + manifest_path = Path(manifest_path) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + + already_done = ( + _load_submitted_ids(manifest_path, model_name) if skip_existing else set() + ) + if already_done: + logger.info( + "Skipping %d rows already submitted (manifest=%s)", + len(already_done), + manifest_path, + ) + + df_in = pd.read_parquet(test_parquet) + if limit is not None: + df_in = df_in.head(limit) + + ckpt_label = str(ckpt) if ckpt is not None else "stub" + records: list[dict[str, Any]] = [] + + for _, row in tqdm( + df_in.iterrows(), + total=len(df_in), + desc=f"scf_speedup({model_name})", + ): + material_id = str(row["material_id"]) + if material_id in already_done: + logger.info("Skipping %s (already submitted)", material_id) + continue + + record: dict[str, Any] = { + "material_id": material_id, + "model": model_name, + "ckpt": ckpt_label, + "submitted": False, + "error": None, + } + try: + atoms = _row_to_atoms(row) + grid_shape = _row_grid_shape(row) + n_electrons = float(row["n_electrons"]) + + density = predict_density(model_name, atoms, grid_shape, ckpt, basis_spec) + + # One directory per (model, material_id) so make_scf_speedup_pair's + # prev_dir mechanism stages the right file. Nested layout + # (chgcar_root///CHGCAR) avoids ambiguity + # for material_ids that contain separator characters. + row_dir = chgcar_root / model_name / material_id + row_dir.mkdir(parents=True, exist_ok=True) + chgcar_path = row_dir / "CHGCAR" + write_chgcar(density, atoms, chgcar_path, n_electrons=n_electrons) + + structure = AseAtomsAdaptor.get_structure(atoms) + metadata = { + "experiment": "scf_speedup", + "material_id": material_id, + "model": model_name, + "ckpt": ckpt_label, + } + flow = make_pair_fn(structure, row_dir, metadata) + + if not dry_run: + submit_fn(flow, project=project, worker=worker) + + record.update( + { + "chgcar_path": str(chgcar_path), + "n_jobs": len(flow.jobs), + "submitted": not dry_run, + } + ) + logger.info( + "%s arm=%s n_jobs=%d submitted=%s", + material_id, + model_name, + record["n_jobs"], + record["submitted"], + ) + except Exception as exc: + # Catch broadly: any per-row exception (corrupt parquet, ML + # OOM, mongo timeout) must not kill the rest of the batch. + record["error"] = repr(exc) + logger.exception( + "Row failed material_id=%s arm=%s", + material_id, + model_name, + ) + finally: + # Stream to manifest after every row so an interrupted + # run leaves a resumable record. Open in append mode so + # parallel runs (different arms, different parquets) can + # share a manifest if pointed at the same path. + with manifest_path.open("a") as f: + f.write(json.dumps(record) + "\n") + records.append(record) + + return records + + +def _build_cli() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="SCF-speedup experiment driver: predict CHGCAR, " + "submit paired r2SCAN single-point Flow per structure." + ) + p.add_argument( + "--model", + required=True, + choices=("salted", "charge3net", "deepdft"), + help="Which ML arm to evaluate.", + ) + p.add_argument( + "--test-parquet", + required=True, + type=Path, + help="Held-out test split parquet (P-ID or P-OOD).", + ) + p.add_argument( + "--chgcar-dir", + required=True, + type=Path, + help="Directory for predicted CHGCAR files; per-row subdirs created.", + ) + p.add_argument( + "--project", + required=True, + help="jobflow_remote project name (matches a jfremote YAML).", + ) + p.add_argument( + "--worker", + required=True, + help="jobflow_remote worker name from the project YAML.", + ) + p.add_argument("--ckpt", type=Path, default=None, help="Model checkpoint path.") + p.add_argument( + "--limit", type=int, default=None, help="Process only the first N rows." + ) + p.add_argument( + "--dry-run", + action="store_true", + help="Write CHGCARs and build Flows but do not submit_workflow.", + ) + p.add_argument( + "--manifest", + type=Path, + default=None, + help="JSONL manifest path (default: /manifest.jsonl). " + "Streamed after each row so interrupted runs are resumable.", + ) + p.add_argument( + "--skip-existing", + action="store_true", + help="Skip rows whose material_id is already submitted=True in the " + "manifest for this model. Failed rows are always retried.", + ) + return p + + +def main(argv: list[str] | None = None) -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + args = _build_cli().parse_args(argv) + records = run_experiment( + model_name=args.model, + test_parquet=args.test_parquet, + chgcar_dir=args.chgcar_dir, + basis_spec=BasisSpec(), + project=args.project, + worker=args.worker, + ckpt=args.ckpt, + limit=args.limit, + dry_run=args.dry_run, + manifest_path=args.manifest, + skip_existing=args.skip_existing, + ) + submitted = sum(1 for r in records if r["submitted"]) + failed = sum(1 for r in records if r.get("error")) + logger.info( + "Processed %d rows for arm=%s; submitted=%d, failed=%d, dry_run=%s", + len(records), + args.model, + submitted, + failed, + args.dry_run, + ) + + +if __name__ == "__main__": + main() diff --git a/submit_boa_adastra.sh b/submit_boa_adastra.sh new file mode 100644 index 0000000..b527734 --- /dev/null +++ b/submit_boa_adastra.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# BOA (boa_ft) training on Adastra (CINES, AMD MI250X), single GCD. +# +# Trains BOA from scratch on the LeMat-Rho charge-density dataset. Mirrors the +# structure of submit_charge3net_adastra.sh (MI250 headers, proxy, venv311_fresh), but +# BOA is driven by Hydra rather than argparse. +# +# Env vars: +# LEMATRHO_ADASTRA_SETUP override $SETUP (default: /lus/scratch/CT10/cad16353/msiron/charge3net_setup) +# LEMATRHO_DRY_RUN 1 to print the resolved train command and exit +# +# Submit: +# sbatch submit_boa_adastra.sh +# +# Single-GCD layout (start small before scaling to DDP): +# - 1 GCD, 16 CPUs, memory proportional to CPU share. +#SBATCH --job-name=boa_ft +#SBATCH --nodes=1 +#SBATCH --ntasks-per-node=1 +#SBATCH --account=c1816212 +#SBATCH --constraint=MI250 +#SBATCH --gpus-per-node=1 +#SBATCH --cpus-per-task=16 +#SBATCH --time=06:00:00 +#SBATCH --output=%x_%j.out +#SBATCH --error=%x_%j.err + +set -eo pipefail + +# --- Paths --- +# Submit dir must be on a scratch with inode headroom (cad16353 currently); the +# account (--account=c1816212 above) handles billing independently. +SETUP="${LEMATRHO_ADASTRA_SETUP:-/lus/scratch/CT10/cad16353/msiron/charge3net_setup}" +WORK_DIR="$SETUP/LeMat-Rho" +BOA_CLONE="$SETUP/boa" +DATA_ROOT="$SETUP/boa_data" +MODEL_ROOT="$SETUP/boa_models" + +# BOA path variables (read by configs/paths/default.yaml). +export PROJECT_ROOT="$BOA_CLONE" +export BOA_DATA="$DATA_ROOT" +export BOA_MODELS="$MODEL_ROOT" +mkdir -p "$BOA_DATA" "$BOA_MODELS" 2>/dev/null || true + +# --- Build train command ----------------------------------------------------- +# Read the element set discovered at preprocess time so the basis matches data. +ATOMIC_NUMBERS_FILE="$BOA_DATA/lematrho/atomic_numbers.json" + +TRAIN_CMD=( + python "$BOA_CLONE/boa/train.py" + "hydra.searchpath=[file://$WORK_DIR/boa_ft/configs]" + experiment=lematrho + trainer.accelerator=gpu + +trainer.devices=1 + logger=tensorboard +) + +if [ "${LEMATRHO_DRY_RUN:-0}" = "1" ]; then + if [ -f "$ATOMIC_NUMBERS_FILE" ]; then + ATOMIC_NUMBERS=$(python -c "import json; print(json.load(open('$ATOMIC_NUMBERS_FILE')))") + TRAIN_CMD+=("data.basis_info.atomic_numbers=$ATOMIC_NUMBERS") + fi + printf '%s ' "${TRAIN_CMD[@]}" + printf '\n' + exit 0 +fi + +# --- Environment ------------------------------------------------------------- +# Proxy is required for any outbound HTTP (pip, HF, W&B). Already in ~/.bashrc +# on Adastra but we re-export here so the job script is self contained. +export HTTP_PROXY=http://proxy-l-adastra.cines.fr:3128 +export HTTPS_PROXY=$HTTP_PROXY +export http_proxy=$HTTP_PROXY +export https_proxy=$HTTP_PROXY + +source "$SETUP/venv311_fresh/bin/activate" + +export PYTHONPATH="$WORK_DIR:$BOA_CLONE:$PYTHONPATH" +export PYTHONUNBUFFERED=1 + +# Load W&B key from .env if present. +if [ -f "$WORK_DIR/.env" ]; then + set -a + source "$WORK_DIR/.env" + set +a +fi + +# --- ROCm device selection --- +export HIP_VISIBLE_DEVICES=0 +export CUDA_VISIBLE_DEVICES=$HIP_VISIBLE_DEVICES + +# atomic_numbers must be read after the venv is active (needs python). +if [ ! -f "$ATOMIC_NUMBERS_FILE" ]; then + echo "ERROR: $ATOMIC_NUMBERS_FILE not found. Run boa_ft.preprocess first." >&2 + exit 2 +fi +ATOMIC_NUMBERS=$(python -c "import json; print(json.load(open('$ATOMIC_NUMBERS_FILE')))") +TRAIN_CMD+=("data.basis_info.atomic_numbers=$ATOMIC_NUMBERS") + +echo "Node: $(hostname)" +echo "Account: ${SLURM_JOB_ACCOUNT:-unknown}" +echo "Job dir: $WORK_DIR" +echo "BOA clone: $BOA_CLONE" +echo "atomic_numbers: $ATOMIC_NUMBERS" +rocm-smi || true + +python3 -c " +import torch +print(f'torch: {torch.__version__}') +print(f'CUDA/ROCm available: {torch.cuda.is_available()}') +print(f'device count: {torch.cuda.device_count()}') +" + +cd "$WORK_DIR" + +# --- Train ------------------------------------------------------------------ +srun --kill-on-bad-exit=1 "${TRAIN_CMD[@]}" + +echo "Done. Exit code: $?" diff --git a/submit_charge3net.sh b/submit_charge3net.sh new file mode 100755 index 0000000..dab0f56 --- /dev/null +++ b/submit_charge3net.sh @@ -0,0 +1,48 @@ +#!/bin/bash +#SBATCH --job-name=charge3net_ft +#SBATCH --account=dqo@a100 +#SBATCH -C a100 +#SBATCH --gres=gpu:1 +#SBATCH --cpus-per-task=8 +#SBATCH --hint=nomultithread +#SBATCH --time=20:00:00 +#SBATCH --qos=qos_gpu_a100-t3 +#SBATCH --output=%x_%j.out +#SBATCH --error=%x_%j.err + +set -eo pipefail + +# --- Environment --- +source /etc/profile +eval "$($WORK/miniforge3/bin/conda shell.bash hook)" +conda activate uma12 + +cd $SCRATCH/LeMat-Rho + +# Load W&B API key and other secrets from .env +set -a; source .env; set +a +export PYTHONUNBUFFERED=1 + +# --- Train --- +RESUME_FLAG="" +if [ -f "$SCRATCH/charge3net_checkpoints/latest.pt" ]; then + RESUME_FLAG="--resume-from $SCRATCH/charge3net_checkpoints/latest.pt" + echo "Resuming from $SCRATCH/charge3net_checkpoints/latest.pt" +fi + +python -m charge3net_ft.train \ + --parquet-dir $SCRATCH/charge3net_data/lematrho_full_10x10x10 \ + --ckpt-path $SCRATCH/charge3net/models/charge3net_mp.pt \ + --save-dir $SCRATCH/charge3net_checkpoints \ + --epochs 50 \ + --batch-size 4 \ + --lr 5e-4 \ + --train-probes 200 \ + --val-probes 1000 \ + --num-workers 8 \ + --wandb-project lemat-rho-charge3net \ + --wandb-entity dtts \ + --wandb-mode offline \ + $RESUME_FLAG + +echo "Done. Exit code: $?" diff --git a/submit_charge3net_adastra.sh b/submit_charge3net_adastra.sh new file mode 100644 index 0000000..f63342a --- /dev/null +++ b/submit_charge3net_adastra.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# ChargE3Net fine-tuning on Adastra (CINES, AMD MI250X), half-node DDP. +# +# Two training modes (select via LEMATRHO_TRAINING_MODE env): +# pretrained (default) — fine-tune from charge3net_mp.pt (MP, 245 epochs) +# from_scratch — train from random init for direct comparison +# +# Env vars: +# LEMATRHO_TRAINING_MODE pretrained | from_scratch (default: pretrained) +# LEMATRHO_ADASTRA_SETUP override $SETUP (default: /lus/scratch/CT10/cad16353/msiron/charge3net_setup) +# LEMATRHO_DRY_RUN 1 to print the resolved train command and exit +# (used by tests/test_submit_script.py) +# +# Submit examples: +# sbatch submit_charge3net_adastra.sh # pretrained +# sbatch --export=ALL,LEMATRHO_TRAINING_MODE=from_scratch submit_charge3net_adastra.sh # from-scratch +# +# Half-node resource layout (g1xxx mi250-shared has 8 GCDs, 128 CPUs, 256 GB): +# - 4 GCDs (gpus-per-node=4) +# - 64 CPUs (16 per task * 4 tasks) +# - 128 GB RAM +# - 4 tasks, one per GCD, for torch DistributedDataParallel +# Effective batch = batch-size * world_size = 16 * 4 = 64 (matches the +# upstream paper's train_mp_e3_final.yaml: batch_size=16, nnodes=2 x nprocs=2). +#SBATCH --job-name=charge3net_ft +#SBATCH --nodes=1 +#SBATCH --ntasks-per-node=4 +#SBATCH --account=c1816212 +#SBATCH --constraint=MI250 +#SBATCH --gpus-per-node=4 +#SBATCH --cpus-per-task=16 +# No --mem here on purpose: SLURM allocates memory proportional to our CPU +# share (64 of 128 logical CPUs = ~128 GB out of the 256 GB node). The +# earlier --mem=125000M was being read as "asking for half the node memory" +# and contributed to SLURM auto-bumping us to EXCLUSIVE mode. Letting SLURM +# pick lets the other half of the node stay schedulable for other jobs. +#SBATCH --time=06:00:00 +#SBATCH --output=%x_%j.out +#SBATCH --error=%x_%j.err + +set -eo pipefail + +# --- Paths --- +# Submit dir must be on a scratch with inode headroom (cad16353 currently); the +# account (--account=c1816212 above) handles billing independently. See ADASTRA.md. +SETUP="${LEMATRHO_ADASTRA_SETUP:-/lus/scratch/CT10/cad16353/msiron/charge3net_setup}" +WORK_DIR="$SETUP/LeMat-Rho" +DATA_DIR="$SETUP/charge3net_data" +MP_CKPT="$SETUP/charge3net/models/charge3net_mp.pt" + +# --- Training mode ----------------------------------------------------------- +TRAINING_MODE="${LEMATRHO_TRAINING_MODE:-pretrained}" +case "$TRAINING_MODE" in + pretrained) + CKPT_PATH="$MP_CKPT" + CKPT_DIR="$SETUP/charge3net_checkpoints" + export WANDB_NAME="pretrained_mp" + ;; + from_scratch) + CKPT_PATH="" # no --ckpt-path -> ChargE3NetWrapper inits from random + CKPT_DIR="$SETUP/charge3net_checkpoints_fromscratch" + export WANDB_NAME="from_scratch" + ;; + *) + echo "ERROR: LEMATRHO_TRAINING_MODE must be 'pretrained' or 'from_scratch'," \ + "got '$TRAINING_MODE'" >&2 + exit 2 + ;; +esac + +mkdir -p "$CKPT_DIR" 2>/dev/null || true + +# --- Build train command ----------------------------------------------------- +# Constructed early so LEMATRHO_DRY_RUN can short-circuit before sourcing venv. +TRAIN_ARGS=( + --parquet-dir "$DATA_DIR" + --save-dir "$CKPT_DIR" + --epochs 50 + --batch-size 16 + --lr 5e-4 + --train-probes 200 + --val-probes 1000 + # num-workers=2 (down from 8): with 4 DDP ranks each forking workers, the + # previous setting created 32 worker processes total and the per-worker + # _TABLE_CACHE in data.py OOM-killed jobs 4971293/4971343 at ~140 GB + # cumulative RSS. The LRU eviction we landed in data.py would help on + # its own, but lowering worker count further drops cache pressure with + # zero loss in throughput at this dataset/grid size. + --num-workers 2 + --wandb-project lemat-rho-charge3net + --wandb-entity dtts + --wandb-mode offline +) +if [ -n "$CKPT_PATH" ]; then + TRAIN_ARGS+=(--ckpt-path "$CKPT_PATH") +fi +if [ -f "$CKPT_DIR/latest.pt" ]; then + TRAIN_ARGS+=(--resume-from "$CKPT_DIR/latest.pt") +fi + +if [ "${LEMATRHO_DRY_RUN:-0}" = "1" ]; then + echo "WANDB_NAME=$WANDB_NAME" + echo "TRAINING_MODE=$TRAINING_MODE" + echo "CKPT_DIR=$CKPT_DIR" + printf 'python -m charge3net_ft.train' + for arg in "${TRAIN_ARGS[@]}"; do + printf ' %s' "$arg" + done + printf '\n' + exit 0 +fi + +# --- Environment ------------------------------------------------------------- +# Proxy is required for any outbound HTTP (pip, HF, W&B). Already in ~/.bashrc +# on Adastra but we re-export here so the job script is self contained. +export HTTP_PROXY=http://proxy-l-adastra.cines.fr:3128 +export HTTPS_PROXY=$HTTP_PROXY +export http_proxy=$HTTP_PROXY +export https_proxy=$HTTP_PROXY + +source "$SETUP/venv311/bin/activate" + +export PYTHONPATH="$WORK_DIR:$SETUP/charge3net:$PYTHONPATH" +export PYTHONUNBUFFERED=1 + +# Load W&B key from .env if present. +if [ -f "$WORK_DIR/.env" ]; then + set -a + source "$WORK_DIR/.env" + set +a +fi + +# --- NCCL / DDP reliability tweaks --- +# Job 4977567 (2026-05-21) ran 2h41m, then died from NCCL TCPStore +# "Broken pipe / should dump flag" on the DDP heartbeat. Memory was +# fine (14 GB/task with the LRU cache fix). The crash is on the +# inter-rank communication channel, not the model. These three env +# vars expand the timeout budget so a transient slow rank doesn't +# tear down the whole job. +# NCCL_TIMEOUT per-collective timeout (seconds) +# NCCL_ASYNC_ERROR_HANDLING=1 clean shutdown on rank failure +# (no cascading hangs) +# TORCH_NCCL_HEARTBEAT_TIMEOUT_SEC how long a rank can stall +# before HeartbeatMonitor tears +# down the process group +export NCCL_TIMEOUT=3600 +export NCCL_ASYNC_ERROR_HANDLING=1 +export TORCH_NCCL_HEARTBEAT_TIMEOUT_SEC=1800 +export TORCH_NCCL_TRACE_BUFFER_SIZE=1000 # capture more debug info on next crash + +# --- Distributed-training env vars (read by train.py's _setup_ddp) --- +# SLURM sets SLURM_NTASKS, SLURM_PROCID, SLURM_LOCALID for us via srun. +# torch.distributed wants WORLD_SIZE / RANK / LOCAL_RANK plus MASTER_ADDR +# / MASTER_PORT. We export them once here, srun propagates to each task. +export WORLD_SIZE=$SLURM_NTASKS +export MASTER_ADDR=$(scontrol show hostname "$SLURM_NODELIST" | head -n 1) +export MASTER_PORT=29500 +# RANK / LOCAL_RANK are per-task — set in the wrapper srun command below. + +echo "Node: $(hostname)" +echo "Account: ${SLURM_JOB_ACCOUNT:-unknown}" +echo "Job dir: $WORK_DIR" +echo "Training mode: $TRAINING_MODE (wandb name: $WANDB_NAME)" +echo "Checkpoint dir: $CKPT_DIR" +echo "WORLD_SIZE=$WORLD_SIZE MASTER_ADDR=$MASTER_ADDR MASTER_PORT=$MASTER_PORT" +rocm-smi || true + +python3 -c " +import torch +print(f'torch: {torch.__version__}') +print(f'CUDA/ROCm available: {torch.cuda.is_available()}') +print(f'device count: {torch.cuda.device_count()}') +" + +cd "$WORK_DIR" + +# --- Train ------------------------------------------------------------------ +# srun launches 4 tasks (--ntasks-per-node=4 from #SBATCH). Each task sees +# SLURM_PROCID = global rank, SLURM_LOCALID = local rank within node. +# The TRAIN_ARGS array is exported as a quoted string so the srun-spawned +# bash can reconstruct it. +TRAIN_ARGS_QUOTED="" +for arg in "${TRAIN_ARGS[@]}"; do + TRAIN_ARGS_QUOTED+=" $(printf '%q' "$arg")" +done +export TRAIN_ARGS_QUOTED + +srun --kill-on-bad-exit=1 bash -c ' + export RANK=$SLURM_PROCID + export LOCAL_RANK=$SLURM_LOCALID + # Each task sees ALL 4 GCDs the job was allocated; torch.cuda.set_device(local_rank) + # inside _setup_ddp picks the right one. Restricting visibility per-task here + # would make every task target the same "GCD 0" within its own visibility set. + echo "task RANK=$RANK LOCAL_RANK=$LOCAL_RANK on $(hostname) (will use cuda:$LOCAL_RANK)" + eval "python3 -m charge3net_ft.train $TRAIN_ARGS_QUOTED" +' + +echo "Done. Exit code: $?" diff --git a/submit_deepdft_adastra.sh b/submit_deepdft_adastra.sh new file mode 100644 index 0000000..fc94163 --- /dev/null +++ b/submit_deepdft_adastra.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# DeepDFT training on Adastra (CINES, AMD MI250X), single-GPU paper-faithful. +# +# Faithful to peterbjorgensen/DeepDFT paper settings: +# - 1 GCD (paper used 1x RTX 3090; we use 1x MI250X) +# - batch=2 materials, train=1000 probes/material (same as upstream); +# val=1000 probes/material over a 200-material seeded subsample +# (upstream's val=5000 probes OOM-killed the 64 GB job 5004725: the +# probe neighborlist grows quadratically in the probe count) +# - cutoff=4 A, num_interactions=3, node_size=128, PaiNN model +# - max_steps=10,000,000 +# +# Single-GPU keeps the gradient-step semantics identical to the paper. +# DDP code paths in runner.py only fire when WORLD_SIZE>1 -- we leave them +# out here on purpose. If we ever want DDP for DeepDFT we'd also need to +# sweep the LR (effective batch grows with world_size). +# +# Env vars: +# LEMATRHO_ADASTRA_SETUP override $SETUP (default: cad16353 scratch) +# LEMATRHO_DEEPDFT_VARIANT painn (default) | schnet +# LEMATRHO_DRY_RUN 1 to print resolved cmd + exit +# +# Submit examples: +# sbatch submit_deepdft_adastra.sh # PaiNN +# sbatch --export=ALL,LEMATRHO_DEEPDFT_VARIANT=schnet submit_deepdft_adastra.sh # SchNet +# +#SBATCH --job-name=deepdft_ft +#SBATCH --nodes=1 +#SBATCH --ntasks-per-node=1 +#SBATCH --account=c1816212 +#SBATCH --constraint=MI250 +#SBATCH --gpus-per-node=1 +#SBATCH --cpus-per-task=16 +#SBATCH --mem=64000M +#SBATCH --time=24:00:00 +#SBATCH --output=%x_%j.out +#SBATCH --error=%x_%j.err + +set -eo pipefail + +# --- Paths --- +SETUP="${LEMATRHO_ADASTRA_SETUP:-/lus/scratch/CT10/cad16353/msiron/charge3net_setup}" +WORK_DIR="$SETUP/LeMat-Rho" +DATA_DIR="$SETUP/charge3net_data_15cube" +DEEPDFT_REPO="$SETUP/DeepDFT" + +# --- Model variant --- +VARIANT="${LEMATRHO_DEEPDFT_VARIANT:-painn}" +case "$VARIANT" in + painn) + EXTRA_ARGS=(--use_painn_model) + OUTPUT_DIR="$SETUP/deepdft_runs/painn" + export WANDB_NAME="deepdft_painn" + ;; + schnet) + EXTRA_ARGS=() # SchNet is the default architecture, no flag needed + OUTPUT_DIR="$SETUP/deepdft_runs/schnet" + export WANDB_NAME="deepdft_schnet" + ;; + *) + echo "ERROR: LEMATRHO_DEEPDFT_VARIANT must be 'painn' or 'schnet', got '$VARIANT'" >&2 + exit 2 + ;; +esac + +mkdir -p "$OUTPUT_DIR" 2>/dev/null || true + +# --- Build train command ----------------------------------------------------- +# Hyperparameters lifted from pretrained_models/{nmc,qm9,ethylenecarbonate}_painn +# in the upstream DeepDFT repo. Same values across all three published checkpoints. +TRAIN_ARGS=( + --dataset "$DATA_DIR" + --output_dir "$OUTPUT_DIR" + --cutoff 4 + --num_interactions 3 + --node_size 128 + --max_steps 10000000 + --device cuda + --val-probes 1000 + --val-max-samples 200 + "${EXTRA_ARGS[@]}" +) +if [ -f "$OUTPUT_DIR/best_model.pth" ]; then + TRAIN_ARGS+=(--load_model "$OUTPUT_DIR/best_model.pth") +fi + +if [ "${LEMATRHO_DRY_RUN:-0}" = "1" ]; then + echo "WANDB_NAME=$WANDB_NAME" + echo "VARIANT=$VARIANT" + echo "OUTPUT_DIR=$OUTPUT_DIR" + printf 'python -m deepdft_ft.runner' + for arg in "${TRAIN_ARGS[@]}"; do + printf ' %s' "$arg" + done + printf '\n' + exit 0 +fi + +# --- Environment ------------------------------------------------------------- +export HTTP_PROXY=http://proxy-l-adastra.cines.fr:3128 +export HTTPS_PROXY=$HTTP_PROXY +export http_proxy=$HTTP_PROXY +export https_proxy=$HTTP_PROXY + +source "$SETUP/venv311_fresh/bin/activate" + +export PYTHONPATH="$WORK_DIR:$DEEPDFT_REPO:$PYTHONPATH" +export PYTHONUNBUFFERED=1 + +if [ -f "$WORK_DIR/.env" ]; then + set -a + source "$WORK_DIR/.env" + set +a +fi + +# Pin to GCD 0 (single-GPU paper-faithful). Do NOT set WORLD_SIZE so that +# runner.py's _setup_ddp returns the single-process tuple (0, 0, 1). +export HIP_VISIBLE_DEVICES=0 +export CUDA_VISIBLE_DEVICES=0 + +echo "Node: $(hostname)" +echo "Account: ${SLURM_JOB_ACCOUNT:-unknown}" +echo "Variant: $VARIANT (wandb name: $WANDB_NAME)" +echo "Output dir: $OUTPUT_DIR" +echo "Single-GPU mode (WORLD_SIZE unset)" +rocm-smi || true + +python3 -c " +import torch +print(f'torch: {torch.__version__}') +print(f'CUDA/ROCm available: {torch.cuda.is_available()}') +print(f'device count: {torch.cuda.device_count()}') +" + +cd "$WORK_DIR" + +# --- Train (single GPU, no srun) -------------------------------------------- +python3 -m deepdft_ft.runner "${TRAIN_ARGS[@]}" + +echo "Done. Exit code: $?" diff --git a/submit_project_lematrho_adastra.sh b/submit_project_lematrho_adastra.sh new file mode 100644 index 0000000..dd9e66f --- /dev/null +++ b/submit_project_lematrho_adastra.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Phase D2: project the LeMat-Rho parquet dataset onto the SALTED basis. +# +# One-time CPU job. Reads $SETUP/charge3net_data/chunk_*.parquet, +# writes $SETUP/salted_projected_coefficients/chunk_*.parquet via +# salted_ft.project_dataset (one LSQR per row, ~75 ms per row). +# +# Adastra smoke test (1 chunk, 956 valid rows) timed at 71 s wall. +# Full dataset (69 chunks, ~65k rows) extrapolates to ~80 min. +# Budget 2 h with slack. +# +# Env vars +# LEMATRHO_ADASTRA_SETUP override $SETUP (default: cad16353 scratch) +# +#SBATCH --job-name=salted_project_dataset +#SBATCH --nodes=1 +#SBATCH --ntasks-per-node=1 +#SBATCH --account=c1816212 +#SBATCH --constraint=GENOA +#SBATCH --cpus-per-task=4 +#SBATCH --time=02:00:00 +#SBATCH --output=%x_%j.out +#SBATCH --error=%x_%j.err +# Resource sizing notes (2026-05-22): +# - --partition=genoa-shared rejected by CINES policy ("You are not allowed +# to ask for a partition"), same as --qos=debug. We use --constraint=GENOA +# and let SLURM auto-route based on resource size. +# - Bumped --cpus-per-task from 16 to 4 so SLURM keeps us in genoa-shared +# (it auto-routes to the shared partition for small CPU asks, exclusive +# for larger ones). 4 CPUs is enough for our numpy-LSQR + BLAS thread +# pool; the projection is ~1 min/chunk, single chunk is the bottleneck. + +set -eo pipefail + +SETUP="${LEMATRHO_ADASTRA_SETUP:-/lus/scratch/CT10/cad16353/msiron/charge3net_setup}" +WORK_DIR="$SETUP/LeMat-Rho" +INPUT_DIR="$SETUP/charge3net_data" +OUTPUT_DIR="$SETUP/salted_projected_coefficients" + +mkdir -p "$OUTPUT_DIR" 2>/dev/null || true + +source "$SETUP/venv311/bin/activate" +export PYTHONPATH="$WORK_DIR:$PYTHONPATH" +export PYTHONUNBUFFERED=1 + +# numpy / lstsq is already multi-threaded via BLAS; cap thread count +# to match the SLURM allocation so we do not oversubscribe the node. +export OMP_NUM_THREADS=$SLURM_CPUS_ON_NODE +export OPENBLAS_NUM_THREADS=$SLURM_CPUS_ON_NODE +export MKL_NUM_THREADS=$SLURM_CPUS_ON_NODE + +echo "Node: $(hostname)" +echo "Account: ${SLURM_JOB_ACCOUNT:-unknown}" +echo "Input: $INPUT_DIR" +echo "Output: $OUTPUT_DIR" +echo "CPUs: $SLURM_CPUS_ON_NODE" + +cd "$WORK_DIR" + +python -m salted_ft.project_dataset \ + --input-dir "$INPUT_DIR" \ + --output-dir "$OUTPUT_DIR" + +echo "Done. Exit code: $?" +echo "Counting output chunks:" +ls "$OUTPUT_DIR"/chunk_*.parquet | wc -l diff --git a/submit_salted_baseline_adastra.sh b/submit_salted_baseline_adastra.sh new file mode 100755 index 0000000..86efb1d --- /dev/null +++ b/submit_salted_baseline_adastra.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Phase D6 (path B): train the SALTED baseline coefficient-prediction +# model on the D2 projected outputs. +# +# Single-GPU MI250X job. Dataset is the 65k r2SCAN structures with +# their pre-projected per-atom basis coefficients (from D2). Loss is +# MSE on the (n_atoms, 100) coefficient vectors. See +# salted_ft/train_baseline.py for the model architecture (SchNet-style +# invariant message passing, 2 cfconv layers). +# +# Env vars +# LEMATRHO_ADASTRA_SETUP override $SETUP (default: cad16353 scratch) +# LEMATRHO_DRY_RUN 1 to print resolved cmd and exit +# +# Submit: +# sbatch submit_salted_baseline_adastra.sh +# +#SBATCH --job-name=salted_baseline +#SBATCH --nodes=1 +#SBATCH --ntasks-per-node=1 +#SBATCH --account=c1816212 +#SBATCH --constraint=MI250 +#SBATCH --gpus-per-node=1 +#SBATCH --cpus-per-task=16 +#SBATCH --mem=64000M +#SBATCH --time=24:00:00 +#SBATCH --output=%x_%j.out +#SBATCH --error=%x_%j.err +# +# Resource sizing notes: +# - Single GCD: the baseline model is tiny (~50k params) and +# saturates the per-atom forward path; DDP across multiple GCDs +# would only help if we batched many structures per step, which +# the per-atom variable size makes awkward. Single-GPU is fine. +# - 24h walltime: 10 epochs over 65k rows at ~0.1s/row =~ 2h, plus +# margin for I/O and Adastra cold-start. + +set -eo pipefail + +SETUP="${LEMATRHO_ADASTRA_SETUP:-/lus/scratch/CT10/cad16353/msiron/charge3net_setup}" +WORK_DIR="$SETUP/LeMat-Rho" +SOURCE_DIR="$SETUP/charge3net_data" +COEFFS_DIR="$SETUP/salted_projected_coefficients" +OUTPUT_DIR="$SETUP/salted_baseline_runs" +mkdir -p "$OUTPUT_DIR" +CKPT="$OUTPUT_DIR/salted_baseline_${SLURM_JOB_ID:-local}.pt" + +source "$SETUP/venv311/bin/activate" +export PYTHONPATH="$WORK_DIR:$PYTHONPATH" +export PYTHONUNBUFFERED=1 + +# ROCm visibility (mirrors submit_deepdft_adastra.sh) +export HIP_VISIBLE_DEVICES=0 +export CUDA_VISIBLE_DEVICES=$HIP_VISIBLE_DEVICES + +CMD=(python -m salted_ft.train_baseline + --source-dir "$SOURCE_DIR" + --coeffs-dir "$COEFFS_DIR" + --output-ckpt "$CKPT" + --n-epochs 10 + --batch-size 8 + --learning-rate 1e-3 + --device cuda) + +if [[ "${LEMATRHO_DRY_RUN:-0}" == "1" ]]; then + printf '%s ' "${CMD[@]}" + printf '\n' + exit 0 +fi + +echo "Node: $(hostname)" +echo "Account: ${SLURM_JOB_ACCOUNT:-unknown}" +echo "Source dir: $SOURCE_DIR" +echo "Coeffs dir: $COEFFS_DIR" +echo "Ckpt out: $CKPT" + +cd "$WORK_DIR" + +"${CMD[@]}" + +echo "Done. Exit code: $?" +echo "Wrote: $CKPT" +ls -lh "$CKPT" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_boa_preprocess.py b/tests/test_boa_preprocess.py new file mode 100644 index 0000000..af3ddfd --- /dev/null +++ b/tests/test_boa_preprocess.py @@ -0,0 +1,226 @@ +"""Tests for the boa_ft parquet -> LMDB preprocessor. + +Covers the pure row-to-graph conversion, the datasplit writer, and a small +end-to-end run that round-trips through BOA's ``LmdbDataset``. The pure-helper +tests (max-z filter, datasplit writer, bounded table cache) run anywhere; the +graph and LMDB round-trip tests require the sibling ``boa`` clone (for +``scdp``) and ``charge3net`` (for the shared parquet decoder), so they skip +outside the boa_ft environment described in boa_ft/README.md. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + +# A 4x4x4 grid keeps the probe count small (64) while still being a real 3D box. +_GRID_N = 4 +_CELL = [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]] + + +def _write_synthetic_chunk(path: Path, n_valid: int = 3) -> None: + """Write a chunk_*.parquet with the LeMat-Rho schema (H2 in a cubic box).""" + grid = json.dumps(np.ones((_GRID_N, _GRID_N, _GRID_N), dtype=np.float32).tolist()) + table = pa.table( + { + "compressed_charge_density": pa.array([grid] * n_valid, type=pa.string()), + "species_at_sites": pa.array([["H", "H"]] * n_valid), + "cartesian_site_positions": pa.array( + [[[0.0, 0.0, 0.0], [0.0, 0.0, 0.8]]] * n_valid + ), + "lattice_vectors": pa.array([_CELL] * n_valid), + # extras the preprocessor must ignore + "material_id": pa.array([f"mat_{i}" for i in range(n_valid)]), + } + ) + pq.write_table(table, path) + + +class TestRowToAtomicData: + """A parquet row becomes an scdp AtomicData with the expected fields.""" + + def _one_graph(self, tmp: Path): + pytest.importorskip("scdp") + from scdp.scripts.preprocess import get_atomic_number_table_from_zs + + from boa_ft.preprocess import row_to_atomic_data + from charge3net_ft.data import _build_parquet_index + + _write_synthetic_chunk(tmp / "chunk_000.parquet", n_valid=1) + file_paths, _ = _build_parquet_index(tmp) + table = pq.read_table(file_paths[0]) + row = {c: table.column(c)[0].as_py() for c in table.column_names} + z_table = get_atomic_number_table_from_zs(np.arange(100).tolist()) + return row_to_atomic_data( + row, "mat_0", z_table, atom_cutoff=4.0, max_neighbors=None + ) + + def test_atoms_and_cell(self): + with tempfile.TemporaryDirectory() as tmp: + data = self._one_graph(Path(tmp)) + assert data.n_atom == 2 + assert data.atom_types.tolist() == [1, 1] + np.testing.assert_allclose(data.cell[0].numpy(), np.array(_CELL), atol=1e-5) + + def test_no_virtual_nodes(self): + """vnode_method='none' means the graph carries only real atoms.""" + with tempfile.TemporaryDirectory() as tmp: + data = self._one_graph(Path(tmp)) + assert data.n_vnode == 0 + assert int(data.num_nodes) == 2 + + def test_probe_grid_matches_density(self): + with tempfile.TemporaryDirectory() as tmp: + data = self._one_graph(Path(tmp)) + n_points = _GRID_N**3 + assert data.chg_labels.shape[0] == n_points + assert data.probe_coords.shape == (n_points, 3) + + def test_grid_origin_is_cell_corner(self): + """Fractional (0, 0, 0) probe maps to Cartesian origin.""" + with tempfile.TemporaryDirectory() as tmp: + data = self._one_graph(Path(tmp)) + np.testing.assert_allclose( + data.probe_coords[0].numpy(), np.zeros(3), atol=1e-5 + ) + + +class TestRowExceedsMaxZ: + """Rows with elements beyond the basis coverage are flagged for skipping.""" + + def test_actinide_row_exceeds_def2_svp_ceiling(self): + from boa_ft.preprocess import row_exceeds_max_z + + assert row_exceeds_max_z({"species_at_sites": ["U", "O", "O"]}, 86) + + def test_light_row_passes(self): + from boa_ft.preprocess import row_exceeds_max_z + + assert not row_exceeds_max_z({"species_at_sites": ["H", "Rn"]}, 86) + + +class TestWriteDatasplits: + """Splits are disjoint, cover the whole range, and match charge3net sizes.""" + + def test_split_sizes_and_disjoint(self): + from boa_ft.preprocess import write_datasplits + + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + splits = write_datasplits(100, out, val_frac=0.05, test_frac=0.05, seed=42) + assert len(splits["train"]) == 90 + assert len(splits["validation"]) == 5 + assert len(splits["test"]) == 5 + all_idx = splits["train"] + splits["validation"] + splits["test"] + assert sorted(all_idx) == list(range(100)) + assert (out / "datasplits.json").exists() + + def test_split_is_seed_deterministic(self): + from boa_ft.preprocess import write_datasplits + + with tempfile.TemporaryDirectory() as t1, tempfile.TemporaryDirectory() as t2: + a = write_datasplits(50, Path(t1), 0.1, 0.1, seed=42) + b = write_datasplits(50, Path(t2), 0.1, 0.1, seed=42) + assert a == b + + +class TestReadRowCached: + """The preprocess table cache is a bounded LRU, not an unbounded dict. + + Mirrors the 5-chunk LRU in ``deepdft_ft.data`` (the unbounded variant + held every decompressed pyarrow table for the whole run). + """ + + def test_cache_never_exceeds_cap(self): + import collections + + from boa_ft.preprocess import _TABLE_CACHE_MAX_CHUNKS, read_row_cached + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + n_chunks = _TABLE_CACHE_MAX_CHUNKS + 2 + file_paths = [] + for i in range(n_chunks): + p = tmp / f"chunk_{i:03d}.parquet" + _write_synthetic_chunk(p, n_valid=1) + file_paths.append(p) + cache: collections.OrderedDict = collections.OrderedDict() + for fi in range(n_chunks): + read_row_cached(file_paths, fi, 0, cache) + assert len(cache) <= _TABLE_CACHE_MAX_CHUNKS + + def test_reread_after_eviction_roundtrips(self): + import collections + + from boa_ft.preprocess import _TABLE_CACHE_MAX_CHUNKS, read_row_cached + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + n_chunks = _TABLE_CACHE_MAX_CHUNKS + 2 + file_paths = [] + for i in range(n_chunks): + p = tmp / f"chunk_{i:03d}.parquet" + _write_synthetic_chunk(p, n_valid=1) + file_paths.append(p) + cache: collections.OrderedDict = collections.OrderedDict() + first = read_row_cached(file_paths, 0, 0, cache) + for fi in range(n_chunks): # cycle far enough to evict chunk 0 + read_row_cached(file_paths, fi, 0, cache) + again = read_row_cached(file_paths, 0, 0, cache) + assert again == first + + +class TestPreprocessEndToEnd: + """A full run writes shards + metadata that LmdbDataset can read back.""" + + def test_roundtrip_through_lmdb_dataset(self): + import argparse + + pytest.importorskip("scdp") + pytest.importorskip("boa") + from boa.data.dataset import LmdbDataset + + from boa_ft.preprocess import main + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + parquet_dir = tmp / "parquet" + parquet_dir.mkdir() + _write_synthetic_chunk(parquet_dir / "chunk_000.parquet", n_valid=5) + _write_synthetic_chunk(parquet_dir / "chunk_001.parquet", n_valid=3) + out_dir = tmp / "lematrho" + + args = argparse.Namespace( + parquet_dir=str(parquet_dir), + out_dir=str(out_dir), + num_shards=2, + limit=None, + atom_cutoff=4.0, + max_neighbors=None, + map_size_gb=1, + val_frac=0.2, + test_frac=0.2, + seed=42, + ) + main(args) + + # atomic_numbers.json reflects the only element present. + atomic_numbers = json.loads((out_dir / "atomic_numbers.json").read_text()) + assert atomic_numbers == [1] + + # datasplits cover every written sample. + splits = json.loads((out_dir / "datasplits.json").read_text()) + n = sum(len(splits[k]) for k in ("train", "validation", "test")) + assert n == 8 + + # LmdbDataset sees all 8 samples across the two shards. + ds = LmdbDataset(out_dir / "data") + assert len(ds) == 8 + sample = ds[0] + assert sample.n_atom == 2 diff --git a/tests/test_boa_transforms.py b/tests/test_boa_transforms.py new file mode 100644 index 0000000..70d0332 --- /dev/null +++ b/tests/test_boa_transforms.py @@ -0,0 +1,74 @@ +"""Tests for the boa_ft periodic neighbor graph. + +The BOA density decoder matches every edge with its flip, so the graph must +contain exactly one edge per ordered pair (plus self loops), and it must respect +periodic boundary conditions. These cases are hand-checked against a small cubic +cell so the minimum-image logic is pinned independent of the mldft package. +""" + +from __future__ import annotations + +import torch + +from boa_ft.transforms import pbc_radius_edge_index + + +def _edge_set(edge_index: torch.Tensor) -> set: + """Return the set of (src, dst) tuples in an edge index.""" + return {(int(s), int(d)) for s, d in edge_index.t().tolist()} + + +class TestPBCRadiusEdgeIndex: + """Minimum-image radius graph on a 4 A cubic cell.""" + + def test_periodic_neighbor_across_boundary(self): + """Atoms 0.1 A apart across the cell face must be connected. + + Direct distance is 3.9 A, but the nearest periodic image is 0.1 A away + (3.9 - 4.0). With radius 1.0 A the cross edges must appear; an + open-boundary graph would miss them. + """ + pos = torch.tensor([[0.0, 0.0, 0.0], [0.0, 0.0, 3.9]]) + cell = torch.eye(3) * 4.0 + edges = _edge_set(pbc_radius_edge_index(pos, cell, radius=1.0)) + assert edges == {(0, 0), (1, 1), (0, 1), (1, 0)} + + def test_no_edge_when_min_image_beyond_cutoff(self): + """With radius 0.05 A the 0.1 A cross distance is out of range.""" + pos = torch.tensor([[0.0, 0.0, 0.0], [0.0, 0.0, 3.9]]) + cell = torch.eye(3) * 4.0 + edges = _edge_set(pbc_radius_edge_index(pos, cell, radius=0.05)) + assert edges == {(0, 0), (1, 1)} + + def test_loop_false_drops_self_edges(self): + pos = torch.tensor([[0.0, 0.0, 0.0], [0.0, 0.0, 3.9]]) + cell = torch.eye(3) * 4.0 + edges = _edge_set(pbc_radius_edge_index(pos, cell, radius=1.0, loop=False)) + assert edges == {(0, 1), (1, 0)} + + def test_single_atom_has_one_self_loop_only(self): + """Periodic images of a lone atom (4 A away) must not add extra self edges. + + Duplicate (0, 0) columns would break the decoder's flip matching, so a + single atom yields exactly one self loop even though its own images sit + within a larger cutoff. + """ + pos = torch.tensor([[1.0, 1.0, 1.0]]) + cell = torch.eye(3) * 4.0 + edge_index = pbc_radius_edge_index(pos, cell, radius=5.0) + assert edge_index.shape == (2, 1) + assert _edge_set(edge_index) == {(0, 0)} + + def test_edge_set_is_symmetric(self): + pos = torch.tensor([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 2.0, 0.0]]) + cell = torch.eye(3) * 10.0 + edges = _edge_set(pbc_radius_edge_index(pos, cell, radius=1.5)) + for src, dst in edges: + assert (dst, src) in edges + + def test_returns_long_2xe(self): + pos = torch.tensor([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + cell = torch.eye(3) * 10.0 + edge_index = pbc_radius_edge_index(pos, cell, radius=1.5) + assert edge_index.dtype == torch.long + assert edge_index.shape[0] == 2 diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..ebd86f0 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,341 @@ +""" +Unit tests for charge3net_ft data utilities. + +Uses synthetic in-memory data — no real Parquet files, no charge3net dep. +""" + +import json +import tempfile +from pathlib import Path + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + + +# --------------------------------------------------------------------------- +# We test only the pure utility functions that don't import charge3net. +# Import them by reaching into the module after patching the sys.path block. +# --------------------------------------------------------------------------- +def _import_data_utils(): + """Import _parse_grid_json and _row_to_atoms_and_density without triggering + the charge3net RuntimeError (which fires if the sibling repo is absent).""" + import importlib + import sys + from unittest.mock import patch + + # Stub out the charge3net modules so the import succeeds without the repo + fake_modules = [ + "src", + "src.charge3net", + "src.charge3net.data", + "src.charge3net.data.collate", + "src.charge3net.data.graph_construction", + "src.utils", + "src.utils.data", + ] + stubs = {} + for mod in fake_modules: + stubs[mod] = type(sys)("mod") + stubs["src.charge3net.data.collate"].collate_list_of_dicts = lambda *a, **kw: None + stubs["src.charge3net.data.graph_construction"].KdTreeGraphConstructor = object + stubs["src.utils.data"].calculate_grid_pos = lambda *a, **kw: None + + # Also patch the existence check so it doesn't raise + with ( + patch.dict(sys.modules, stubs), + patch("pathlib.Path.exists", return_value=True), + ): + import importlib + + # Force reimport with stubs in place + if "charge3net_ft.data" in sys.modules: + del sys.modules["charge3net_ft.data"] + mod = importlib.import_module("charge3net_ft.data") + return mod + + +class TestParseGridJson: + def test_roundtrip_3d(self): + grid = [[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]] + json_str = json.dumps(grid) + from charge3net_ft.data import _parse_grid_json + + result = _parse_grid_json(json_str) + assert result.shape == (2, 2, 2) + assert result.dtype == np.float32 + np.testing.assert_allclose(result, np.array(grid, dtype=np.float32)) + + def test_10x10x10(self): + from charge3net_ft.data import _parse_grid_json + + grid = np.random.rand(10, 10, 10).tolist() + result = _parse_grid_json(json.dumps(grid)) + assert result.shape == (10, 10, 10) + + +class TestRowToAtomsAndDensity: + def _make_row(self): + return { + "species_at_sites": ["Fe", "O"], + "cartesian_site_positions": [[0.0, 0.0, 0.0], [1.4, 1.4, 1.4]], + "lattice_vectors": [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]], + "compressed_charge_density": json.dumps(np.ones((10, 10, 10)).tolist()), + } + + def test_atoms_species(self): + import ase + + from charge3net_ft.data import _row_to_atoms_and_density + + row = self._make_row() + atoms, _density, _origin = _row_to_atoms_and_density(row) + assert isinstance(atoms, ase.Atoms) + assert list(atoms.get_chemical_symbols()) == ["Fe", "O"] + + def test_pbc(self): + from charge3net_ft.data import _row_to_atoms_and_density + + atoms, _, _ = _row_to_atoms_and_density(self._make_row()) + assert all(atoms.pbc) + + def test_density_shape(self): + from charge3net_ft.data import _row_to_atoms_and_density + + _, density, _ = _row_to_atoms_and_density(self._make_row()) + assert density.shape == (10, 10, 10) + + def test_origin_is_zero(self): + from charge3net_ft.data import _row_to_atoms_and_density + + _, _, origin = _row_to_atoms_and_density(self._make_row()) + np.testing.assert_array_equal(origin, [0.0, 0.0, 0.0]) + + def test_unknown_species_raises(self): + from charge3net_ft.data import _row_to_atoms_and_density + + row = self._make_row() + row["species_at_sites"] = ["Xx"] # invalid symbol + with pytest.raises(KeyError): + _row_to_atoms_and_density(row) + + +class TestBuildParquetIndex: + def _write_chunk(self, path: Path, n_valid: int, n_null: int): + """Write a synthetic chunk_*.parquet file.""" + valid = [json.dumps(np.ones((10, 10, 10)).tolist())] * n_valid + null = [None] * n_null + table = pa.table( + { + "compressed_charge_density": pa.array(valid + null, type=pa.string()), + "species_at_sites": pa.array([["Fe"]] * (n_valid + n_null)), + "cartesian_site_positions": pa.array( + [[[0.0, 0.0, 0.0]]] * (n_valid + n_null) + ), + "lattice_vectors": pa.array( + [[[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]] + * (n_valid + n_null) + ), + } + ) + pq.write_table(table, path) + + def test_counts_valid_rows(self): + from charge3net_ft.data import _build_parquet_index + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + self._write_chunk(d / "chunk_000.parquet", n_valid=5, n_null=2) + self._write_chunk(d / "chunk_001.parquet", n_valid=3, n_null=1) + file_paths, index = _build_parquet_index(d) + assert len(index) == 8 # 5 + 3 valid + assert len(file_paths) == 2 + + def test_index_entries_reference_correct_file(self): + from charge3net_ft.data import _build_parquet_index + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + self._write_chunk(d / "chunk_000.parquet", n_valid=3, n_null=0) + self._write_chunk(d / "chunk_001.parquet", n_valid=2, n_null=0) + _, index = _build_parquet_index(d) + file_indices = [fi for fi, _ in index] + assert file_indices[:3] == [0, 0, 0] + assert file_indices[3:] == [1, 1] + + def test_raises_on_empty_dir(self): + from charge3net_ft.data import _build_parquet_index + + with tempfile.TemporaryDirectory() as tmp, pytest.raises(FileNotFoundError): + _build_parquet_index(Path(tmp)) + + def test_ignores_extra_columns(self): + """Newer LeMat-Rho dataset versions add Bader-analysis columns (e.g. + bader_charges, bader_volumes) alongside the four required columns. + _build_parquet_index and _row_to_atoms_and_density should ignore the + extras transparently: data.py:46 declares an explicit _COLUMNS allowlist + and pq.read_table is called with columns=_COLUMNS. + """ + from charge3net_ft.data import _build_parquet_index, _row_to_atoms_and_density + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + n = 3 + grid = json.dumps(np.ones((10, 10, 10)).tolist()) + table = pa.table( + { + # required columns + "compressed_charge_density": pa.array([grid] * n, type=pa.string()), + "species_at_sites": pa.array([["Fe"]] * n), + "cartesian_site_positions": pa.array([[[0.0, 0.0, 0.0]]] * n), + "lattice_vectors": pa.array( + [[[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]] * n + ), + # extras analogous to what Entalpic/lemat-rho-v1 added in 2026: + "bader_charges": pa.array([[0.42]] * n), + "bader_volumes": pa.array([[11.7]] * n), + "material_id": pa.array([f"mat_{i}" for i in range(n)]), + } + ) + pq.write_table(table, d / "chunk_000.parquet") + + # build_parquet_index should still find all 3 valid rows + file_paths, index = _build_parquet_index(d) + assert len(index) == n + assert len(file_paths) == 1 + + # _row_to_atoms_and_density should produce a usable atoms+density + # even when the row dict contains the extras (it indexes the + # required keys directly, so the extras are dead weight). + row = { + "species_at_sites": ["Fe"], + "cartesian_site_positions": [[0.0, 0.0, 0.0]], + "lattice_vectors": [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]], + "compressed_charge_density": grid, + "bader_charges": [0.42], + "bader_volumes": [11.7], + "material_id": "mat_0", + } + atoms, density, origin = _row_to_atoms_and_density(row) + assert len(atoms) == 1 + assert density.shape == (10, 10, 10) + np.testing.assert_array_equal(origin, np.zeros(3)) + + +# --------------------------------------------------------------------------- +# LRU eviction for the per-worker parquet table cache. +# +# Why this is here (regression test for the OOM that killed jobs 4971293 and +# 4971343): without eviction, each DataLoader worker accumulates every chunk +# it has ever read. With 8 workers per rank x 4 DDP ranks = 32 workers, and +# ~2 GB of pyarrow-decompressed table per chunk, the cache alone can grow to +# ~140 GB on a long run. The OOM hit at MaxRSS=35 GB per rank x 4 = 140 GB, +# above our 125 GB --mem budget. +# +# The fix: cap the cache. A small LRU bounded by `_TABLE_CACHE_MAX_CHUNKS` +# evicts the least-recently-used chunk before adding a new one. +# --------------------------------------------------------------------------- + + +class TestTableCacheLRU: + """LeMatRhoDataset's _TABLE_CACHE must evict to stay below a bounded size.""" + + def _write_n_chunks(self, d: Path, n: int): + for i in range(n): + _write_one_row_chunk(d / f"chunk_{i:03d}.parquet") + + def test_cache_size_is_bounded(self): + """After reading from many chunks, the cache must not contain all of them.""" + from charge3net_ft import data as data_mod + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + n_chunks = 10 + self._write_n_chunks(d, n_chunks) + + # Force a small cap so the test is fast and unambiguous. + original_max = getattr(data_mod, "_TABLE_CACHE_MAX_CHUNKS", None) + data_mod._TABLE_CACHE_MAX_CHUNKS = 3 + data_mod._TABLE_CACHE.clear() + try: + ds = data_mod.LeMatRhoDataset(parquet_dir=d, num_probes=None) + for i in range(len(ds)): + _ = ds._read_row(i) + assert len(data_mod._TABLE_CACHE) <= 3, ( + "cache grew beyond _TABLE_CACHE_MAX_CHUNKS=3; " + f"actual size {len(data_mod._TABLE_CACHE)}" + ) + finally: + if original_max is not None: + data_mod._TABLE_CACHE_MAX_CHUNKS = original_max + data_mod._TABLE_CACHE.clear() + + def test_cache_evicts_least_recently_used(self): + """When the cache is full, the next miss should drop the LRU entry.""" + from charge3net_ft import data as data_mod + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + self._write_n_chunks(d, 5) + data_mod._TABLE_CACHE_MAX_CHUNKS = 2 + data_mod._TABLE_CACHE.clear() + try: + ds = data_mod.LeMatRhoDataset(parquet_dir=d, num_probes=None) + # Touch chunks 0, 1 -> cache holds {0, 1} + ds._read_row(0) + ds._read_row(1) + assert set(data_mod._TABLE_CACHE.keys()) == {0, 1} + # Touch chunk 2 -> the LRU (0) should evict, cache holds {1, 2} + ds._read_row(2) + assert set(data_mod._TABLE_CACHE.keys()) == {1, 2}, ( + f"expected LRU eviction of chunk 0, got cache keys " + f"{set(data_mod._TABLE_CACHE.keys())}" + ) + # Re-access 1 -> bumps 1 to most-recent; cache still {1, 2} + ds._read_row(1) + # Touch 3 -> 2 is now LRU, evict 2, cache holds {1, 3} + ds._read_row(3) + assert set(data_mod._TABLE_CACHE.keys()) == {1, 3}, ( + f"expected LRU eviction of chunk 2 after re-access of 1; " + f"got cache keys {set(data_mod._TABLE_CACHE.keys())}" + ) + finally: + data_mod._TABLE_CACHE.clear() + + def test_cache_max_default_is_reasonable(self): + """The default cap must be > 0 and small enough that 8 workers x cap + worth of cached chunks fits well below per-rank memory budgets. + + With ~2 GB per chunk and ~8 workers per rank, a default of 5 caps + the per-rank cache at ~80 GB worst case (only chunks the worker + actually saw count; in practice well under). We pick 5 to leave + plenty of margin under a 32-GB-per-rank shared-mode allocation. + """ + from charge3net_ft import data as data_mod + + assert hasattr(data_mod, "_TABLE_CACHE_MAX_CHUNKS"), ( + "_TABLE_CACHE_MAX_CHUNKS must be defined for the LRU to work" + ) + assert 1 <= data_mod._TABLE_CACHE_MAX_CHUNKS <= 20, ( + f"_TABLE_CACHE_MAX_CHUNKS={data_mod._TABLE_CACHE_MAX_CHUNKS} is " + "outside the sensible range [1, 20]; very small evicts too " + "aggressively for shuffled access, very large defeats the cap" + ) + + +def _write_one_row_chunk(path: Path): + """Helper: one valid row per chunk; used by the LRU eviction tests.""" + table = pa.table( + { + "compressed_charge_density": pa.array( + [json.dumps(np.ones((10, 10, 10)).tolist())], type=pa.string() + ), + "species_at_sites": pa.array([["Fe"]]), + "cartesian_site_positions": pa.array([[[0.0, 0.0, 0.0]]]), + "lattice_vectors": pa.array( + [[[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]] + ), + } + ) + pq.write_table(table, path) diff --git a/tests/test_deepdft_data.py b/tests/test_deepdft_data.py new file mode 100644 index 0000000..71c7d4b --- /dev/null +++ b/tests/test_deepdft_data.py @@ -0,0 +1,229 @@ +"""TDD tests for the LeMat-Rho → DeepDFT data adapter. + +DeepDFT (peterbjorgensen/DeepDFT) consumes a per-sample dict of the form:: + + { + "density": np.ndarray (Nx, Ny, Nz), + "atoms": ase.Atoms, + "origin": np.ndarray (3,), + "grid_position": np.ndarray (Nx, Ny, Nz, 3), + "metadata": dict, # must contain "filename" + } + +Our adapter ``LeMatRhoDeepDFTDataset`` reuses the existing +``_row_to_atoms_and_density`` and ``_build_parquet_index`` helpers in +``charge3net_ft.data`` (so the input pipeline is shared between models) and +returns DeepDFT's dict shape directly. No tar/CHGCAR conversion needed. + +``charge3net_ft.data`` needs the ``../charge3net`` sibling clone (its +module-level sys.path block raises RuntimeError without it), so the whole +module skips when the sibling is absent. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import ase +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + +try: + import charge3net_ft.data # noqa: F401 +except (ImportError, RuntimeError) as exc: + pytest.skip(f"charge3net sibling repo unavailable: {exc}", allow_module_level=True) + + +# --------------------------------------------------------------------------- +# Helpers — write a synthetic chunk_*.parquet with the same schema the real +# LeMat-Rho data has, plus the Bader columns it gained in v1. +# --------------------------------------------------------------------------- +def _write_synthetic_chunk(path: Path, n_valid: int = 3) -> None: + grid = json.dumps(np.ones((10, 10, 10), dtype=np.float32).tolist()) + table = pa.table( + { + "compressed_charge_density": pa.array([grid] * n_valid, type=pa.string()), + "species_at_sites": pa.array([["Fe"]] * n_valid), + "cartesian_site_positions": pa.array([[[0.0, 0.0, 0.0]]] * n_valid), + "lattice_vectors": pa.array( + [[[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]] * n_valid + ), + # extras DeepDFT must ignore + "bader_charges": pa.array([[0.42]] * n_valid), + "material_id": pa.array([f"mat_{i}" for i in range(n_valid)]), + } + ) + pq.write_table(table, path) + + +class TestLeMatRhoDeepDFTDataset: + """Adapter __getitem__ returns DeepDFT's exact dict contract.""" + + def test_length_matches_valid_rows(self): + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=5) + _write_synthetic_chunk(d / "chunk_001.parquet", n_valid=3) + ds = LeMatRhoDeepDFTDataset(parquet_dir=d) + assert len(ds) == 8 + + def test_item_has_all_required_keys(self): + """DeepDFT's collate_fn reads density, atoms, origin, grid_position, metadata.""" + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=1) + ds = LeMatRhoDeepDFTDataset(parquet_dir=d) + sample = ds[0] + for key in ("density", "atoms", "origin", "grid_position", "metadata"): + assert key in sample, ( + f"DeepDFT expects key {key!r}; got {list(sample.keys())}" + ) + + def test_item_density_is_3d_numpy(self): + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=1) + sample = LeMatRhoDeepDFTDataset(parquet_dir=d)[0] + assert isinstance(sample["density"], np.ndarray) + assert sample["density"].shape == (10, 10, 10), ( + f"expected (10, 10, 10) density; got {sample['density'].shape}" + ) + + def test_item_atoms_is_ase_atoms_with_pbc(self): + """Periodic boundary conditions matter for any solid-state density.""" + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=1) + sample = LeMatRhoDeepDFTDataset(parquet_dir=d)[0] + assert isinstance(sample["atoms"], ase.Atoms) + assert all(sample["atoms"].pbc), ( + "LeMat-Rho cells are periodic; atoms.pbc must be (True, True, True)" + ) + + def test_item_origin_is_3vec_zeros(self): + """LeMat-Rho stores grids at fractional (0, 0, 0); the adapter mirrors that.""" + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=1) + sample = LeMatRhoDeepDFTDataset(parquet_dir=d)[0] + assert isinstance(sample["origin"], np.ndarray) + np.testing.assert_array_equal(sample["origin"], np.zeros(3)) + + def test_item_grid_position_shape_matches_density(self): + """grid_position is (Nx, Ny, Nz, 3) Cartesian probe coordinates.""" + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=1) + sample = LeMatRhoDeepDFTDataset(parquet_dir=d)[0] + assert sample["grid_position"].shape == (10, 10, 10, 3), ( + f"grid_position must be (Nx, Ny, Nz, 3); got {sample['grid_position'].shape}" + ) + + def test_grid_position_origin_is_zero(self): + """grid_position[0, 0, 0] must be the cell origin (0, 0, 0).""" + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=1) + sample = LeMatRhoDeepDFTDataset(parquet_dir=d)[0] + np.testing.assert_allclose(sample["grid_position"][0, 0, 0], np.zeros(3)) + + def test_grid_position_uses_cell_matrix(self): + """grid_position[1, 0, 0] should be one step along the a vector. + + For our synthetic 10×10×10 grid with a 4-Å cubic cell: + frac coord at index (1, 0, 0) = (1/10, 0, 0) + Cartesian = frac @ cell = (4/10, 0, 0) = (0.4, 0, 0) + """ + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=1) + sample = LeMatRhoDeepDFTDataset(parquet_dir=d)[0] + np.testing.assert_allclose( + sample["grid_position"][1, 0, 0], [0.4, 0.0, 0.0], atol=1e-5 + ) + + def test_item_metadata_has_filename(self): + """DeepDFT logs reference filename — must be a stable string per sample.""" + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=2) + ds = LeMatRhoDeepDFTDataset(parquet_dir=d) + for i in range(len(ds)): + meta = ds[i]["metadata"] + assert "filename" in meta, f"metadata missing 'filename'; got {meta}" + assert isinstance(meta["filename"], str) + # Filenames should differ across samples so DeepDFT logs don't collide. + assert ds[0]["metadata"]["filename"] != ds[1]["metadata"]["filename"] + + def test_ignores_extra_columns(self): + """Bader / material_id columns added to LeMat-Rho v1 are dead weight here. + + Same regression we already pinned for charge3net_ft.data; mirroring it + on the DeepDFT path keeps the two adapters honest in lockstep. + """ + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "chunk_000.parquet", n_valid=1) + sample = LeMatRhoDeepDFTDataset(parquet_dir=d)[0] + # The synthetic chunk includes bader_charges + material_id columns. + # The adapter should successfully ingest the row regardless. + assert sample["density"].shape == (10, 10, 10) + + +class TestGenerateDatasplits: + """The runner's train/val split must be restart-stable. + + submit_deepdft_adastra.sh resumes with --load_model and no --split_file, + so the split is regenerated on every restart. An unseeded permutation + then leaks previous val rows into train (and DDP ranks disagree); two + computations with identical args must produce identical splits. + """ + + def test_same_args_give_same_split(self): + from deepdft_ft.data import generate_datasplits + + assert generate_datasplits(100) == generate_datasplits(100) + + def test_split_is_disjoint_and_complete(self): + from deepdft_ft.data import generate_datasplits + + splits = generate_datasplits(100) + assert len(splits["validation"]) == 5 # ceil(100 * 0.05), as upstream + assert sorted(splits["train"] + splits["validation"]) == list(range(100)) + + def test_different_seed_changes_split(self): + from deepdft_ft.data import generate_datasplits + + assert generate_datasplits(100, seed=0) != generate_datasplits(100, seed=1) + + +class TestRaisesOnEmptyDir: + def test_no_chunks_in_dir_raises(self): + from deepdft_ft.data import LeMatRhoDeepDFTDataset + + with tempfile.TemporaryDirectory() as tmp, pytest.raises(FileNotFoundError): + LeMatRhoDeepDFTDataset(parquet_dir=Path(tmp)) diff --git a/tests/test_deepdft_val_sampling.py b/tests/test_deepdft_val_sampling.py new file mode 100644 index 0000000..6865978 --- /dev/null +++ b/tests/test_deepdft_val_sampling.py @@ -0,0 +1,219 @@ +"""TDD tests for the DeepDFT validation-pass host-RAM OOM fix. + +Job 5004725 (--mem=64000M) was OOM-killed during the step-0 validation +pass. The val collate asked for 5000 probes per material, and upstream +``probes_to_graph`` inserts every probe as a dummy atom into a periodic +neighborlist, so probe-probe pairs grow quadratically with the probe +count (5000 probes was ~75M pairs for the median LeMat-Rho cell, far +beyond 64 GB transient). Probes were also drawn WITH replacement from +grids of only 1000 points (5x duplicates, zero statistical value). + +These tests pin the fix contract: + +- ``deepdft_ft.data.sample_probe_indices`` never returns more indices + than the configured cap (checked on a 15x15x15 = 3375-point grid, + the upgraded LeMat-Rho grid size), +- sampling is without replacement whenever the grid has at least as + many points as the cap, +- the per-worker parquet table cache in ``deepdft_ft.data`` is bounded + (LRU), mirroring ``charge3net_ft.data``, +- ``submit_deepdft_adastra.sh`` passes the new --val-probes and + --val-max-samples flags and points at the 15cube dataset. + +Kept free of any DeepDFT-repo import: ``deepdft_ft.runner`` needs the +upstream DeepDFT clone on sys.path, but the sampling helper and the +dataset adapter live in ``deepdft_ft.data``, which needs only the +``../charge3net`` sibling (for the shared parquet helpers). The whole +module skips when that sibling is absent. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + +try: + import charge3net_ft.data # noqa: F401 +except (ImportError, RuntimeError) as exc: + pytest.skip(f"charge3net sibling repo unavailable: {exc}", allow_module_level=True) + + +# --------------------------------------------------------------------------- +# Probe index sampling (used by the validation collate in deepdft_ft.runner) +# --------------------------------------------------------------------------- +class TestSampleProbeIndices: + def test_probe_count_never_exceeds_cap_on_15cube_grid(self): + """A 15x15x15 grid has 3375 points; the cap (1000) must still hold.""" + from deepdft_ft.data import sample_probe_indices + + indices = sample_probe_indices(15**3, 1000) + assert len(indices) == 1000, ( + f"expected exactly 1000 probe indices; got {len(indices)}" + ) + + def test_without_replacement_when_grid_at_least_cap(self): + """n_probes <= grid points implies no duplicate probes.""" + from deepdft_ft.data import sample_probe_indices + + indices = sample_probe_indices(15**3, 1000) + assert len(np.unique(indices)) == len(indices), ( + "probe indices must be sampled without replacement when the grid " + "has at least n_probes points" + ) + + def test_grid_equal_to_cap_uses_every_point_exactly_once(self): + """1000-point grid + 1000-probe cap: the old code drew 5x duplicates.""" + from deepdft_ft.data import sample_probe_indices + + indices = sample_probe_indices(1000, 1000) + assert sorted(indices.tolist()) == list(range(1000)) + + def test_indices_stay_within_grid(self): + from deepdft_ft.data import sample_probe_indices + + indices = sample_probe_indices(3375, 1000) + assert indices.min() >= 0 + assert indices.max() < 3375 + + def test_small_grid_falls_back_to_exact_count(self): + """Grids smaller than the cap still yield exactly n_probes indices. + + Upstream padding/eval assumes a uniform probe count per sample, so + the fallback samples with replacement rather than shrinking. + """ + from deepdft_ft.data import sample_probe_indices + + indices = sample_probe_indices(10, 32) + assert len(indices) == 32 + assert indices.min() >= 0 + assert indices.max() < 10 + + def test_seeded_rng_is_deterministic(self): + from deepdft_ft.data import sample_probe_indices + + a = sample_probe_indices(3375, 1000, rng=np.random.default_rng(7)) + b = sample_probe_indices(3375, 1000, rng=np.random.default_rng(7)) + np.testing.assert_array_equal(a, b) + + +# --------------------------------------------------------------------------- +# Bounded per-worker parquet table cache +# --------------------------------------------------------------------------- +def _write_synthetic_chunk(path: Path, n_valid: int = 1) -> None: + """Same schema as the real LeMat-Rho chunks (see test_deepdft_data.py).""" + grid = json.dumps(np.ones((10, 10, 10), dtype=np.float32).tolist()) + table = pa.table( + { + "compressed_charge_density": pa.array([grid] * n_valid, type=pa.string()), + "species_at_sites": pa.array([["Fe"]] * n_valid), + "cartesian_site_positions": pa.array([[[0.0, 0.0, 0.0]]] * n_valid), + "lattice_vectors": pa.array( + [[[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]] * n_valid + ), + } + ) + pq.write_table(table, path) + + +class TestBoundedTableCache: + def test_cache_never_exceeds_cap(self, tmp_path): + """Touching more chunks than the cap must evict, not grow unbounded. + + The unbounded dict cache held one decompressed pyarrow table per + chunk file forever (same failure mode that OOM-killed the + charge3net jobs 4971293/4971343 before its LRU port). + """ + import deepdft_ft.data as dd + + cap = dd._DEEPDFT_TABLE_CACHE_MAX_CHUNKS + n_chunks = cap + 2 + for i in range(n_chunks): + _write_synthetic_chunk(tmp_path / f"chunk_{i:03d}.parquet") + + dd._DEEPDFT_TABLE_CACHE.clear() + ds = dd.LeMatRhoDeepDFTDataset(parquet_dir=tmp_path) + # One row per chunk: touches every file once. + for i in range(n_chunks): + ds[i] + assert len(dd._DEEPDFT_TABLE_CACHE) <= cap, ( + f"table cache grew to {len(dd._DEEPDFT_TABLE_CACHE)} entries; cap is {cap}" + ) + + def test_cache_hit_returns_same_row(self, tmp_path): + """Eviction must not corrupt reads: re-reading a row round-trips.""" + import deepdft_ft.data as dd + + n_chunks = dd._DEEPDFT_TABLE_CACHE_MAX_CHUNKS + 2 + for i in range(n_chunks): + _write_synthetic_chunk(tmp_path / f"chunk_{i:03d}.parquet") + + dd._DEEPDFT_TABLE_CACHE.clear() + ds = dd.LeMatRhoDeepDFTDataset(parquet_dir=tmp_path) + first = ds[0]["density"] + for i in range(n_chunks): # cycle far enough to evict chunk 0 + ds[i] + again = ds[0]["density"] # re-read forces a cache miss + reload + np.testing.assert_array_equal(first, again) + + +# --------------------------------------------------------------------------- +# Submit script wiring +# --------------------------------------------------------------------------- +SUBMIT_SCRIPT = Path(__file__).resolve().parent.parent / "submit_deepdft_adastra.sh" + + +def _run_submit(tmp_path: Path, env_extra: dict | None = None): + """Run submit_deepdft_adastra.sh under bash with LEMATRHO_DRY_RUN=1.""" + if shutil.which("bash") is None: + pytest.skip("bash not available in test environment") + env = { + **os.environ, + "LEMATRHO_DRY_RUN": "1", + # Avoid touching the user's real Adastra setup. + "LEMATRHO_ADASTRA_SETUP": str(tmp_path), + **(env_extra or {}), + } + return subprocess.run( + ["bash", str(SUBMIT_SCRIPT)], + env=env, + capture_output=True, + text=True, + check=False, + ) + + +class TestSubmitScriptValWiring: + def test_dry_run_passes_val_probe_and_subsample_flags(self, tmp_path): + result = _run_submit(tmp_path) + assert result.returncode == 0, ( + f"dry-run exited {result.returncode}; stderr={result.stderr}" + ) + assert "--val-probes 1000" in result.stdout, ( + f"run line must cap val probes at 1000; stdout={result.stdout}" + ) + assert "--val-max-samples 200" in result.stdout, ( + f"run line must subsample the val split to 200; stdout={result.stdout}" + ) + + def test_dataset_points_at_15cube_dir(self, tmp_path): + result = _run_submit(tmp_path) + assert "charge3net_data_15cube" in result.stdout, ( + f"DATA_DIR must be the 15cube dataset; stdout={result.stdout}" + ) + + def test_venv_is_fresh(self): + text = SUBMIT_SCRIPT.read_text() + assert "venv311_fresh/bin/activate" in text, ( + "script must activate venv311_fresh" + ) + assert "venv311/bin/activate" not in text, ( + "stale venv311 activation still present" + ) diff --git a/tests/test_density_model_comparison.py b/tests/test_density_model_comparison.py new file mode 100644 index 0000000..b53a194 --- /dev/null +++ b/tests/test_density_model_comparison.py @@ -0,0 +1,149 @@ +"""TDD tests for ``scripts/density_model_comparison_table.py`` (D8). + +Takes the per-arm parquet outputs from D7 and aggregates into a +single comparison table (markdown + CSV). Per-row metrics are +summarised per arm: mean +/- std and median, with the number of +structures evaluated. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + + +@pytest.fixture +def comparison_module(): + """Import scripts.density_model_comparison_table.""" + scripts_dir = Path(__file__).resolve().parent.parent / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + if "density_model_comparison_table" in sys.modules: + del sys.modules["density_model_comparison_table"] + return importlib.import_module("density_model_comparison_table") + + +def _toy_eval_parquet( + tmp_path: Path, + model_name: str, + nmape_values: list[float], + rmse_values: list[float], + nrmse_values: list[float], +) -> Path: + """Write a D7-shaped eval-output parquet with known metric values.""" + df = pd.DataFrame( + { + "model": model_name, + "ckpt": "stub", + "material_id": [f"mp-{model_name}-{i}" for i in range(len(nmape_values))], + "n_atoms": 2, + "nmape": nmape_values, + "rmse": rmse_values, + "nrmse": nrmse_values, + } + ) + out = tmp_path / f"eval_{model_name}.parquet" + df.to_parquet(out) + return out + + +class TestAggregate: + def test_returns_one_row_per_arm(self, tmp_path, comparison_module): + p1 = _toy_eval_parquet( + tmp_path, "salted", [10.0, 20.0], [0.1, 0.2], [5.0, 10.0] + ) + p2 = _toy_eval_parquet( + tmp_path, "charge3net", [5.0, 7.0], [0.05, 0.07], [2.0, 3.0] + ) + df = comparison_module.aggregate_per_arm([p1, p2]) + assert set(df["model"]) == {"salted", "charge3net"} + + def test_mean_nmape_matches_input(self, tmp_path, comparison_module): + p = _toy_eval_parquet(tmp_path, "salted", [10.0, 30.0], [0.1, 0.3], [5.0, 15.0]) + df = comparison_module.aggregate_per_arm([p]) + row = df.iloc[0] + assert row["nmape_mean"] == pytest.approx(20.0) + assert row["rmse_mean"] == pytest.approx(0.2) + assert row["nrmse_mean"] == pytest.approx(10.0) + + def test_std_present(self, tmp_path, comparison_module): + p = _toy_eval_parquet(tmp_path, "salted", [10.0, 30.0], [0.1, 0.3], [5.0, 15.0]) + df = comparison_module.aggregate_per_arm([p]) + for col in ("nmape_std", "rmse_std", "nrmse_std"): + assert col in df.columns + assert np.isfinite(df[col].iloc[0]) + + def test_median_present(self, tmp_path, comparison_module): + p = _toy_eval_parquet( + tmp_path, "salted", [10.0, 20.0, 30.0], [0.1, 0.2, 0.3], [5.0, 10.0, 15.0] + ) + df = comparison_module.aggregate_per_arm([p]) + assert df["nmape_median"].iloc[0] == pytest.approx(20.0) + + def test_n_structures_counts_rows(self, tmp_path, comparison_module): + p = _toy_eval_parquet(tmp_path, "salted", [1.0, 2.0, 3.0], [0.1] * 3, [1.0] * 3) + df = comparison_module.aggregate_per_arm([p]) + assert df["n_structures"].iloc[0] == 3 + + def test_aggregates_multiple_files_per_arm(self, tmp_path, comparison_module): + """If the same arm is split across two parquets, aggregate + should treat them as one group. Useful when sharded eval + runs write per-chunk outputs.""" + (tmp_path / "p1").mkdir() + (tmp_path / "p2").mkdir() + p1 = _toy_eval_parquet(tmp_path / "p1", "salted", [10.0], [0.1], [5.0]) + p2 = _toy_eval_parquet(tmp_path / "p2", "salted", [30.0], [0.3], [15.0]) + df = comparison_module.aggregate_per_arm([p1, p2]) + assert len(df) == 1 + assert df.iloc[0]["n_structures"] == 2 + assert df.iloc[0]["nmape_mean"] == pytest.approx(20.0) + + +class TestRenderMarkdown: + def test_markdown_contains_arm_names(self, tmp_path, comparison_module): + p1 = _toy_eval_parquet( + tmp_path, "salted", [10.0, 20.0], [0.1, 0.2], [5.0, 10.0] + ) + p2 = _toy_eval_parquet( + tmp_path, "charge3net", [5.0, 7.0], [0.05, 0.07], [2.0, 3.0] + ) + df = comparison_module.aggregate_per_arm([p1, p2]) + md = comparison_module.render_markdown_table(df) + assert "salted" in md + assert "charge3net" in md + + def test_markdown_has_header_row(self, tmp_path, comparison_module): + p = _toy_eval_parquet(tmp_path, "salted", [10.0], [0.1], [5.0]) + df = comparison_module.aggregate_per_arm([p]) + md = comparison_module.render_markdown_table(df) + # GitHub-flavored markdown table separator + assert "|" in md + assert "---" in md + + +class TestWriteOutputs: + def test_writes_csv(self, tmp_path, comparison_module): + p = _toy_eval_parquet(tmp_path, "salted", [10.0, 20.0], [0.1, 0.2], [5.0, 10.0]) + out_csv = tmp_path / "out.csv" + out_md = tmp_path / "out.md" + comparison_module.build_comparison_table( + inputs=[p], csv_path=out_csv, markdown_path=out_md + ) + assert out_csv.exists() + df = pd.read_csv(out_csv) + assert "model" in df.columns + + def test_writes_markdown(self, tmp_path, comparison_module): + p = _toy_eval_parquet(tmp_path, "salted", [10.0, 20.0], [0.1, 0.2], [5.0, 10.0]) + out_csv = tmp_path / "out.csv" + out_md = tmp_path / "out.md" + comparison_module.build_comparison_table( + inputs=[p], csv_path=out_csv, markdown_path=out_md + ) + assert out_md.exists() + assert "salted" in out_md.read_text() diff --git a/tests/test_density_model_eval.py b/tests/test_density_model_eval.py new file mode 100644 index 0000000..76feba9 --- /dev/null +++ b/tests/test_density_model_eval.py @@ -0,0 +1,347 @@ +"""TDD tests for ``scripts/density_model_eval.py`` (D7). + +Per-structure density evaluation across the LeMat-Rho arms. This +test exercises the SALTED stub path end-to-end (synthesize a tiny +parquet, run the eval, read back the result) and the structural +contract of the arm dispatcher. + +ChargE3Net and DeepDFT grid prediction lands in D7-beta (probe +batching); the eval script must raise NotImplementedError for them +rather than silently fall back to stubs, so a future user does not +get fake metrics on real arms. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +import ase +import numpy as np +import pandas as pd +import pytest + + +@pytest.fixture +def eval_module(): + """Import scripts.density_model_eval, adding scripts/ to sys.path.""" + scripts_dir = Path(__file__).resolve().parent.parent / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + if "density_model_eval" in sys.modules: + del sys.modules["density_model_eval"] + return importlib.import_module("density_model_eval") + + +def _toy_parquet(tmp_path: Path, n_rows: int = 2) -> Path: + """Synthesise a tiny LeMat-Rho-shaped parquet for eval tests. + + Layout matches the columns ``salted_ft.project_dataset`` writes + plus a ``charge_density`` grid and ``grid_shape`` (the eval is + grid-comparison so we need ground-truth grids).""" + rng = np.random.default_rng(0) + rows = [] + for i in range(n_rows): + grid_shape = (4, 4, 4) + rows.append( + { + "row_index": i, + "material_id": f"mp-toy-{i}", + "n_atoms": 2, + "atomic_numbers": np.array([1, 1], dtype=np.int64), + "positions": np.array( + [[0.0, 0.0, 0.0], [0.74 + 0.01 * i, 0.0, 0.0]], dtype=np.float64 + ).reshape(-1), + "lattice_vectors": (np.eye(3) * 5.0).reshape(-1), + "charge_density": rng.standard_normal(np.prod(grid_shape)).astype( + np.float64 + ), + "grid_shape": np.array(grid_shape, dtype=np.int64), + } + ) + df = pd.DataFrame(rows) + out = tmp_path / "toy_test.parquet" + df.to_parquet(out) + return out + + +class TestMetrics: + def test_nmape_perfect_prediction_is_zero(self, eval_module): + rho = np.array([1.0, 2.0, 3.0]) + assert eval_module.density_nmape(rho, rho) == pytest.approx(0.0) + + def test_nmape_zero_prediction_against_unit_target(self, eval_module): + pred = np.zeros(4) + target = np.ones(4) + # NMAPE = sum(|0 - 1|) / sum(|1|) * 100 = 4 / 4 * 100 = 100 + assert eval_module.density_nmape(pred, target) == pytest.approx(100.0) + + def test_rmse_perfect_prediction_is_zero(self, eval_module): + rho = np.array([1.0, 2.0]) + assert eval_module.density_rmse(rho, rho) == pytest.approx(0.0) + + def test_rmse_known(self, eval_module): + pred = np.array([0.0, 0.0]) + target = np.array([3.0, 4.0]) # MSE = (9+16)/2 = 12.5, RMSE = sqrt(12.5) + assert eval_module.density_rmse(pred, target) == pytest.approx(np.sqrt(12.5)) + + def test_nrmse_perfect_prediction_is_zero(self, eval_module): + rho = np.array([1.0, 2.0]) + assert eval_module.density_nrmse(rho, rho) == pytest.approx(0.0) + + def test_metrics_handle_3d_grids(self, eval_module): + """Metrics must work on (Nx, Ny, Nz) arrays, not just flat.""" + rng = np.random.default_rng(1) + pred = rng.standard_normal((4, 4, 4)) + target = rng.standard_normal((4, 4, 4)) + # Should not error and should be finite + for fn in ( + eval_module.density_nmape, + eval_module.density_rmse, + eval_module.density_nrmse, + ): + assert np.isfinite(fn(pred, target)) + + +class TestPredictDensity: + """Per-arm dispatcher contract.""" + + def test_salted_stub_returns_grid_of_correct_shape(self, eval_module): + from salted_ft.basis import BasisSpec + + atoms = ase.Atoms( + "HH", + positions=[[0, 0, 0], [0.74, 0, 0]], + cell=np.eye(3) * 5.0, + pbc=True, + ) + grid_shape = (6, 6, 6) + rho = eval_module.predict_density( + "salted", atoms, grid_shape, None, BasisSpec() + ) + assert rho.shape == grid_shape + + def test_charge3net_with_mock_model_returns_grid(self, eval_module): + """Charge3Net dispatcher must build the input dict, batch probes, + and reshape to grid. We mock the network with a callable that + returns ones at every probe so we can pin the shape contract + and the reshape order without a real ckpt.""" + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + + if not (Path(__file__).resolve().parent.parent.parent / "charge3net").exists(): + pytest.skip("charge3net sibling repo not present; integration only") + + atoms = ase.Atoms( + "HH", + positions=[[0, 0, 0], [0.74, 0, 0]], + cell=np.eye(3) * 5.0, + pbc=True, + ) + + class MockModel: + calls = 0 + + def train(self, mode): + return self + + def __call__(self, sub_batch): + MockModel.calls += 1 + n = int(sub_batch["num_probes"].item()) + # Charge3net returns shape [B=1, n_probes] + return torch.ones((1, n), dtype=torch.float32) + + grid_shape = (6, 6, 6) + rho = eval_module.predict_density( + "charge3net", + atoms, + grid_shape, + None, + BasisSpec(), + model=MockModel(), + max_probe_batch=64, + ) + assert rho.shape == grid_shape + np.testing.assert_array_equal(rho, np.ones(grid_shape, dtype=np.float32)) + # 6^3 = 216 probes, max_probe_batch=64 -> at least 3 forward calls + assert MockModel.calls >= 3 + + def test_charge3net_max_probe_batch_controls_chunking(self, eval_module): + """Lowering max_probe_batch must increase the number of forward + passes proportionally.""" + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + + if not (Path(__file__).resolve().parent.parent.parent / "charge3net").exists(): + pytest.skip("charge3net sibling repo not present") + + atoms = ase.Atoms( + "HH", + positions=[[0, 0, 0], [0.74, 0, 0]], + cell=np.eye(3) * 5.0, + pbc=True, + ) + + class CountingMock: + def __init__(self): + self.calls = 0 + + def train(self, mode): + return self + + def __call__(self, sub_batch): + self.calls += 1 + n = int(sub_batch["num_probes"].item()) + return torch.zeros((1, n), dtype=torch.float32) + + m1 = CountingMock() + eval_module.predict_density( + "charge3net", + atoms, + (8, 8, 8), + None, + BasisSpec(), + model=m1, + max_probe_batch=512, + ) + m2 = CountingMock() + eval_module.predict_density( + "charge3net", + atoms, + (8, 8, 8), + None, + BasisSpec(), + model=m2, + max_probe_batch=32, + ) + # Smaller batch -> more sub-batches + assert m2.calls > m1.calls + + def test_deepdft_with_mock_model_returns_grid(self, eval_module): + """DeepDFT shares ChargE3Net's input dict format (the latter was + forked from the former), so the dispatcher should reuse the same + probe-batching machinery with a DeepDFT-built model. Mock model + pins the shape contract.""" + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + + # DeepDFT sibling repo is required because the dispatcher's + # sys.path side effect goes through deepdft_ft.runner. + if not (Path(__file__).resolve().parent.parent.parent / "DeepDFT").exists(): + pytest.skip("DeepDFT sibling repo not present; integration only") + if not (Path(__file__).resolve().parent.parent.parent / "charge3net").exists(): + pytest.skip("charge3net sibling repo not present") + + atoms = ase.Atoms( + "HH", + positions=[[0, 0, 0], [0.74, 0, 0]], + cell=np.eye(3) * 5.0, + pbc=True, + ) + + class DeepDFTMock: + def train(self, mode): + return self + + def __call__(self, sub_batch): + n = int(sub_batch["num_probes"].item()) + return torch.full((1, n), 0.5, dtype=torch.float32) + + grid_shape = (4, 4, 4) + rho = eval_module.predict_density( + "deepdft", + atoms, + grid_shape, + None, + BasisSpec(), + model=DeepDFTMock(), + max_probe_batch=32, + ) + assert rho.shape == grid_shape + np.testing.assert_allclose(rho, np.full(grid_shape, 0.5, dtype=np.float32)) + + def test_unknown_arm_raises_value_error(self, eval_module): + from salted_ft.basis import BasisSpec + + atoms = ase.Atoms( + "HH", positions=[[0, 0, 0], [0.74, 0, 0]], cell=np.eye(3) * 5.0, pbc=True + ) + with pytest.raises(ValueError, match="unknown"): + eval_module.predict_density("bogus", atoms, (6, 6, 6), None, BasisSpec()) + + +class TestEvaluateDataset: + def test_writes_parquet_with_per_row_metrics(self, tmp_path, eval_module): + from salted_ft.basis import BasisSpec + + in_path = _toy_parquet(tmp_path, n_rows=2) + out_path = tmp_path / "eval_out.parquet" + eval_module.evaluate_dataset( + model_name="salted", + test_parquet=in_path, + ckpt=None, + basis_spec=BasisSpec(), + output=out_path, + ) + assert out_path.exists() + df = pd.read_parquet(out_path) + assert len(df) == 2 + for col in ("material_id", "nmape", "rmse", "nrmse"): + assert col in df.columns + + def test_metrics_are_finite(self, tmp_path, eval_module): + from salted_ft.basis import BasisSpec + + in_path = _toy_parquet(tmp_path, n_rows=2) + out_path = tmp_path / "eval_out.parquet" + eval_module.evaluate_dataset( + model_name="salted", + test_parquet=in_path, + ckpt=None, + basis_spec=BasisSpec(), + output=out_path, + ) + df = pd.read_parquet(out_path) + for col in ("nmape", "rmse", "nrmse"): + assert np.isfinite(df[col]).all() + + def test_records_model_and_ckpt_in_output(self, tmp_path, eval_module): + """Output rows must carry the arm name + ckpt path so a downstream + comparison table can group by model without re-deriving.""" + from salted_ft.basis import BasisSpec + + in_path = _toy_parquet(tmp_path, n_rows=1) + out_path = tmp_path / "eval_out.parquet" + eval_module.evaluate_dataset( + model_name="salted", + test_parquet=in_path, + ckpt=None, + basis_spec=BasisSpec(), + output=out_path, + ) + df = pd.read_parquet(out_path) + assert (df["model"] == "salted").all() + assert df["ckpt"].iloc[0] in (None, "", "stub") + + def test_limit_caps_n_rows_evaluated(self, tmp_path, eval_module): + from salted_ft.basis import BasisSpec + + in_path = _toy_parquet(tmp_path, n_rows=5) + out_path = tmp_path / "eval_out.parquet" + eval_module.evaluate_dataset( + model_name="salted", + test_parquet=in_path, + ckpt=None, + basis_spec=BasisSpec(), + output=out_path, + limit=2, + ) + df = pd.read_parquet(out_path) + assert len(df) == 2 diff --git a/tests/test_equivariance.py b/tests/test_equivariance.py new file mode 100644 index 0000000..2f6d0f6 --- /dev/null +++ b/tests/test_equivariance.py @@ -0,0 +1,164 @@ +"""Structural equivariance test for ChargE3Net. + +ChargE3Net predicts the scalar charge density ρ(r). For the model to be +rotationally equivariant (i.e. ρ(R·r; R·atoms) == ρ(r; atoms)), the output +irreps of the probe-side network must contain only ℓ=0 even-parity components +("0e", pure scalars). This is the e3nn-level guarantee: as long as the final +representation is a scalar irrep, the model's output is invariant under SO(3) +acting on the input frame. + +A runtime equivariance check (rotate inputs, predict, compare to predictions +on the unrotated inputs) is the gold standard but requires a real forward +pass on the production-sized model, which is too slow for a CPU unit test. +The structural test here covers the same property at the architecture level. + +Skipped automatically when the upstream charge3net repo isn't on disk. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +import torch + +# --------------------------------------------------------------------------- +# Skip if the sibling charge3net repo isn't installed locally +# --------------------------------------------------------------------------- +_CHARGE3NET_ROOT = Path(__file__).resolve().parent.parent.parent / "charge3net" +if not _CHARGE3NET_ROOT.exists(): + pytest.skip( + f"charge3net repo not at {_CHARGE3NET_ROOT}; " + "clone github.com/AIforGreatGood/charge3net there to run this test", + allow_module_level=True, + ) +if str(_CHARGE3NET_ROOT) not in sys.path: + sys.path.insert(0, str(_CHARGE3NET_ROOT)) + +from e3nn import o3 +from src.charge3net.models.e3 import E3DensityModel + + +@pytest.fixture(scope="module") +def production_model(): + """Build a model with the MP-checkpoint hyperparameters. + + Module-scoped so the (slow) construction happens once for all assertions. + """ + torch.manual_seed(0) + model = E3DensityModel( + num_interactions=3, + num_neighbors=20, + mul=500, + lmax=4, + cutoff=4.0, + basis="gaussian", + num_basis=20, + ) + model.train(False) + return model + + +def test_param_count_matches_mp_checkpoint(production_model): + """Sanity check: the model has the 1.9M params we expect. + + Guards against silently changing the architecture in a way that breaks + checkpoint loading from charge3net_mp.pt. + """ + n_params = sum(p.numel() for p in production_model.parameters()) + assert 1_900_000 <= n_params <= 1_920_000, ( + f"Architecture drift: expected ~1.91M params (MP checkpoint), got {n_params:,}" + ) + + +def test_atom_model_uses_higher_order_irreps(production_model): + """ChargE3Net's atom representation must include ℓ>0 irreps to be 'higher-order'. + + The paper's central claim is that going from ℓ_max=1 to ℓ_max=4 produces + substantially better densities on systems with subtle bonding. If someone + accidentally drops the higher-l components (e.g. by passing lmax=0), the + model degenerates to a scalar-only network and silently regresses to a + much weaker baseline. + """ + atom_irreps = production_model.atom_model.atom_irreps_sequence + assert len(atom_irreps) > 0, "atom_irreps_sequence is empty" + final_irreps = atom_irreps[-1] + max_l = max(ir.l for _mul, ir in final_irreps) + assert max_l >= 4, ( + f"Atom representation max ℓ is {max_l}; ChargE3Net's " + f"higher-order claim requires ℓ_max ≥ 4. Got {final_irreps}." + ) + + +def test_atom_model_has_both_parities(production_model): + """The atom representation should include both even (+) and odd (-) parity irreps. + + Without odd-parity components the model can't represent any vector- or + pseudovector-valued atom features, which the higher-order convolutions + need internally. The default get_irreps(mul, lmax) function in e3.py + generates both; this test pins that down. + """ + final_irreps = production_model.atom_model.atom_irreps_sequence[-1] + parities = {ir.p for _mul, ir in final_irreps} + assert parities == {-1, 1}, ( + f"Atom irreps should include both even (p=+1) and odd (p=-1) parities; " + f"got parities {parities}: {final_irreps}" + ) + + +def test_get_irreps_helper_is_balanced(): + """The get_irreps helper in e3.py should produce roughly balanced channel counts. + + This is the function used to construct atom_irreps. If it ever returns + zero-multiplicity for any (l, p) pair at production hyperparameters, the + architecture breaks silently (some irreps disappear). Tests the helper + directly to fail fast. + """ + from src.charge3net.models.e3 import get_irreps + + irreps = get_irreps(500, lmax=4) + multiplicities = [mul for mul, _ in irreps] + assert all(mul > 0 for mul in multiplicities), ( + f"get_irreps(500, 4) produced a zero-multiplicity irrep: {irreps}" + ) + # 5 ℓ levels × 2 parities = 10 entries + assert len(irreps) == 10, ( + f"Expected 10 irreps (5 ℓ × 2 parity), got {len(irreps)}: {irreps}" + ) + + +def test_atom_irreps_sequence_length_matches_num_interactions(production_model): + """One irreps entry per convolution layer (plus the input embedding).""" + seq = production_model.atom_model.atom_irreps_sequence + # num_interactions=3 → 3 convolutions; the sequence holds the post-conv + # representations. Length will be 3 or 4 depending on whether the input + # embedding is included; both are valid, but we pin a sane range. + assert 3 <= len(seq) <= 5, ( + f"atom_irreps_sequence length {len(seq)} is outside the expected " + f"range [3, 5] for num_interactions=3" + ) + + +def test_atom_model_uses_cutoff_consistent_with_kdtree(production_model): + """The cutoff baked into the atom model must match what the dataset uses. + + `KdTreeGraphConstructor` in LeMatRhoDataset uses cutoff=4.0; if the model + is built with a different cutoff, edges fed in at training time won't + match what the convolution layer expects. + """ + assert production_model.atom_model.cutoff == pytest.approx(4.0) + + +def test_e3nn_o3_irreps_are_proper_objects(production_model): + """The atom representation must use e3nn's o3.Irreps wrapper. + + Equivariance is enforced by the o3.Irreps abstraction (which carries + parity information and is consumed by FullyConnectedTensorProduct). If + someone replaces it with a plain list, equivariance silently breaks even + though the forward pass still produces output. + """ + final_irreps = production_model.atom_model.atom_irreps_sequence[-1] + assert isinstance(final_irreps, o3.Irreps), ( + f"Expected o3.Irreps for atom_irreps_sequence[-1]; got {type(final_irreps)}" + ) diff --git a/tests/test_graph2mat_basis.py b/tests/test_graph2mat_basis.py new file mode 100644 index 0000000..c91c0f2 --- /dev/null +++ b/tests/test_graph2mat_basis.py @@ -0,0 +1,163 @@ +"""TDD tests for the Graph2Mat-arm basis adapter (PR zeta-alpha). + +Wraps our uniform ``salted_ft.basis.BasisSpec`` into Graph2Mat's +``PointBasis`` per-species objects. Graph2Mat expects one +``PointBasis`` per atomic species, each carrying its own basis-size, +cutoff, and basis-convention. Our BasisSpec is species-uniform in v1 +so the adapter expands the same spec across every species in a +structure. + +Locked contracts: + +* ``point_basis_for_species(symbol, basis_spec)`` -> ``PointBasis`` + ``.type == symbol``, ``.R == basis_spec.cutoff``, + ``.basis_size == basis_spec.n_coeffs_per_atom``, + ``.basis_convention == 'spherical'``. + +* ``basis_table_for_species(symbols, basis_spec)`` -> dict + ``{symbol: PointBasis}`` so downstream Graph2Mat data processors + can look up by atomic symbol. + +Graph2Mat 0.0.13 PointBasis API: + + PointBasis( + type: str | int, + R: float | ndarray, + basis: str | Sequence[int | (int, int, int)] = (), + basis_convention: 'cartesian'|'spherical'|'siesta_spherical'|'qe_spherical' = 'spherical', + ) + + When ``basis`` is a sequence of ints, the int at position ``l`` + is the number of radial functions for that angular momentum. + So ``basis=[4, 4, 4, 4, 4]`` is 4 radials at each of l=0..4. + ``basis_size`` is the resulting total number of basis functions + per atom: sum_l (2l + 1) * n_radial[l]. +""" + +from __future__ import annotations + +import pytest + + +class TestPointBasisForSpecies: + def test_returns_pointbasis_instance(self): + pytest.importorskip("graph2mat") + from graph2mat import PointBasis + + from graph2mat_ft.basis import point_basis_for_species + from salted_ft.basis import BasisSpec + + pb = point_basis_for_species("Fe", BasisSpec()) + assert isinstance(pb, PointBasis) + + def test_type_field_is_species_symbol(self): + pytest.importorskip("graph2mat") + from graph2mat_ft.basis import point_basis_for_species + from salted_ft.basis import BasisSpec + + pb = point_basis_for_species("Fe", BasisSpec()) + assert pb.type == "Fe" + + def test_R_matches_basis_spec_cutoff(self): + """Radial cutoff: must equal our BasisSpec.cutoff so the + neighbor structure inside Graph2Mat matches charge3net_ft / + deepdft_ft / salted_ft.""" + pytest.importorskip("graph2mat") + from graph2mat_ft.basis import point_basis_for_species + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + pb = point_basis_for_species("Fe", spec) + assert float(pb.R) == pytest.approx(spec.cutoff) + + def test_basis_size_matches_n_coeffs_per_atom(self): + """The per-atom basis function count Graph2Mat sees must equal + the per-atom coefficient count salted_ft.projection produces. + Mismatch means our projected coefficients couldn't be loaded + into a Graph2Mat density-matrix at all. + """ + pytest.importorskip("graph2mat") + from graph2mat_ft.basis import point_basis_for_species + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + pb = point_basis_for_species("Fe", spec) + assert pb.basis_size == spec.n_coeffs_per_atom + + def test_basis_convention_is_spherical(self): + """Real spherical harmonics. Cartesian would be the wrong basis + for our projected coefficients (we use real Y_lm in + salted_ft.projection._real_sph_harm). + """ + pytest.importorskip("graph2mat") + from graph2mat_ft.basis import point_basis_for_species + from salted_ft.basis import BasisSpec + + pb = point_basis_for_species("Fe", BasisSpec()) + assert pb.basis_convention == "spherical" + + def test_basis_has_one_entry_per_l(self): + """basis is sanitised by Graph2Mat into a tuple of (n_radial, l, parity) + triples. We expect one triple per l in 0..max_l, each with the same + n_radial value matching our uniform spec. + """ + pytest.importorskip("graph2mat") + from graph2mat_ft.basis import point_basis_for_species + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + pb = point_basis_for_species("Fe", spec) + # After PointBasis.__post_init__ sanitisation, .basis is + # tuple[tuple[int, int, int], ...] with one entry per l value. + assert len(pb.basis) == spec.max_l + 1 + for entry in pb.basis: + n_radial, lam, _parity = entry + assert n_radial == spec.n_radial, ( + f"n_radial mismatch at l={lam}: got {n_radial}, want {spec.n_radial}" + ) + + def test_different_species_give_separate_pointbasis(self): + """Same spec, different species type field. Sanity check that + adapter doesn't cache or share across species. + """ + pytest.importorskip("graph2mat") + from graph2mat_ft.basis import point_basis_for_species + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + pb_h = point_basis_for_species("H", spec) + pb_fe = point_basis_for_species("Fe", spec) + assert pb_h.type == "H" and pb_fe.type == "Fe" + # But size + cutoff are the same since the spec is uniform + assert pb_h.basis_size == pb_fe.basis_size + assert float(pb_h.R) == float(pb_fe.R) + + +class TestBasisTableForSpecies: + def test_returns_dict_keyed_by_symbol(self): + pytest.importorskip("graph2mat") + from graph2mat_ft.basis import basis_table_for_species + from salted_ft.basis import BasisSpec + + table = basis_table_for_species(("H", "O", "Fe"), BasisSpec()) + assert set(table) == {"H", "O", "Fe"} + + def test_values_are_pointbasis(self): + pytest.importorskip("graph2mat") + from graph2mat import PointBasis + + from graph2mat_ft.basis import basis_table_for_species + from salted_ft.basis import BasisSpec + + table = basis_table_for_species(("H", "Fe"), BasisSpec()) + for v in table.values(): + assert isinstance(v, PointBasis) + + def test_deduplicates_repeated_species(self): + pytest.importorskip("graph2mat") + from graph2mat_ft.basis import basis_table_for_species + from salted_ft.basis import BasisSpec + + # Repeated species in the input list should collapse to one entry. + table = basis_table_for_species(("Fe", "Fe", "Fe", "O"), BasisSpec()) + assert set(table) == {"Fe", "O"} diff --git a/tests/test_graph2mat_io.py b/tests/test_graph2mat_io.py new file mode 100644 index 0000000..e8878b5 --- /dev/null +++ b/tests/test_graph2mat_io.py @@ -0,0 +1,24 @@ +"""TDD tests for the Graph2Mat IO surface (PR zeta-delta). + +graph2mat_ft.io should expose the same read_chgcar / write_chgcar +helpers as salted_ft.io, sharing a single implementation (no +duplicate code). These tests pin that the re-exports are the +identical callable, so a fix in salted_ft.io automatically +propagates to the Graph2Mat arm. +""" + +from __future__ import annotations + + +def test_read_chgcar_is_reexport(): + from graph2mat_ft.io import read_chgcar as g2m_read + from salted_ft.io import read_chgcar as salted_read + + assert g2m_read is salted_read + + +def test_write_chgcar_is_reexport(): + from graph2mat_ft.io import write_chgcar as g2m_write + from salted_ft.io import write_chgcar as salted_write + + assert g2m_write is salted_write diff --git a/tests/test_graph2mat_model.py b/tests/test_graph2mat_model.py new file mode 100644 index 0000000..4a942dd --- /dev/null +++ b/tests/test_graph2mat_model.py @@ -0,0 +1,146 @@ +"""TDD tests for ``Graph2MatModel`` (PR zeta-gamma). + +Mirrors ``salted_ft.model.SALTEDModel``: a single-call wrapper that +takes an ASE Atoms and returns ``(n_atoms, n_coeffs_per_atom)`` +coefficients. In stub mode (``ckpt_path=None``) the coefficients +are deterministic and seeded from positions / numbers / basis_spec. +The real Graph2Mat forward pass lands in D6 and is asserted here +to raise NotImplementedError until then -- so the failure mode is +loud rather than silently returning stub output. +""" + +from __future__ import annotations + +import ase +import numpy as np +import pytest + + +def _h2_atoms() -> ase.Atoms: + return ase.Atoms( + symbols=("H", "H"), + positions=[[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]], + cell=np.eye(3) * 5.0, + pbc=True, + ) + + +def _feo_atoms() -> ase.Atoms: + return ase.Atoms( + symbols=("Fe", "O"), + positions=[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + cell=np.eye(3) * 4.0, + pbc=True, + ) + + +class TestStubMode: + def test_constructible_without_ckpt(self): + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + m = Graph2MatModel(BasisSpec()) + assert m.ckpt_path is None + + def test_output_shape(self): + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + m = Graph2MatModel(spec) + out = m(_h2_atoms()) + assert out.shape == (2, spec.n_coeffs_per_atom) + + def test_output_dtype(self): + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + m = Graph2MatModel(BasisSpec()) + out = m(_h2_atoms()) + assert out.dtype == np.float64 + + def test_output_finite(self): + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + m = Graph2MatModel(BasisSpec()) + out = m(_feo_atoms()) + assert np.isfinite(out).all() + + def test_deterministic_same_input(self): + """Same atoms in -> same coefficients out. Required for the + downstream evaluation pipeline to be reproducible.""" + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + m = Graph2MatModel(spec) + out1 = m(_h2_atoms()) + out2 = m(_h2_atoms()) + np.testing.assert_array_equal(out1, out2) + + def test_position_dependent(self): + """Different positions -> different coefficients. Catches the + bug where the stub accidentally seeds only on species (which + would make every Fe2O3 polymorph have identical coeffs).""" + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + m = Graph2MatModel(BasisSpec()) + a = _h2_atoms() + b = _h2_atoms() + b.positions[1, 0] += 0.1 # nudge the second H + out_a = m(a) + out_b = m(b) + assert not np.array_equal(out_a, out_b) + + def test_species_dependent(self): + """Different atomic numbers should change the seed even at + identical positions.""" + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + m = Graph2MatModel(BasisSpec()) + a = _h2_atoms() + b = _h2_atoms() + b.numbers[1] = 8 # H -> O + out_a = m(a) + out_b = m(b) + assert not np.array_equal(out_a, out_b) + + def test_small_magnitude(self): + """Stub coefficients should be small (order 1e-3) so the + reconstructed densities stay in the regime where downstream + metric tests run without overflow.""" + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + m = Graph2MatModel(BasisSpec()) + out = m(_h2_atoms()) + assert np.max(np.abs(out)) < 1.0 + + +class TestRealMode: + def test_with_ckpt_raises_until_d6(self): + """Real Graph2Mat forward is deferred to D6. Until then a real + ckpt path must fail loudly rather than silently fall back to + the stub (which would corrupt benchmark results).""" + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + m = Graph2MatModel(BasisSpec(), ckpt_path="/tmp/fake.ckpt") + with pytest.raises(NotImplementedError): + m(_h2_atoms()) + + +class TestReconstructDensity: + """Convenience helper: predict + reconstruct on a VASP-like grid.""" + + def test_shape_matches_grid(self): + from graph2mat_ft.model import Graph2MatModel + from salted_ft.basis import BasisSpec + + m = Graph2MatModel(BasisSpec()) + grid_shape = (8, 8, 8) + rho = m.reconstruct_density(_h2_atoms(), grid_shape) + assert rho.shape == grid_shape diff --git a/tests/test_graph2mat_projection.py b/tests/test_graph2mat_projection.py new file mode 100644 index 0000000..3a02bcd --- /dev/null +++ b/tests/test_graph2mat_projection.py @@ -0,0 +1,215 @@ +"""TDD tests for the Graph2Mat coefficient projection (PR zeta-beta). + +Path A of the Graph2Mat arm: we keep the same regression target as +SALTED (per-atom basis coefficient vectors from +``salted_ft.projection``) and only ask Graph2Mat for a different +backbone. So the "projection" here is a layout transform, not a +basis change. + +Layout we map between: + +* dense ``coeffs[N_atoms, n_coeffs_per_atom]`` -- what + ``salted_ft.project_chgcar_to_basis`` returns +* flat ``point_labels[N_atoms * n_coeffs_per_atom]`` -- atom-major + concatenation, the shape Graph2Mat's per-node targets take + when every node has the same uniform basis + +Per-atom blocks are kept *contiguous* and *in input order* so the +flat vector lines up with the graph node order Graph2Mat builds +from the structure. + +These tests pin the pack/unpack roundtrip and order contract -- +they do not exercise Graph2Mat's matrix machinery (we do not have +off-site coefficients in v1). +""" + +from __future__ import annotations + +import numpy as np +import pytest + + +class TestPackCoeffsToPointLabels: + def test_output_shape(self): + from graph2mat_ft.projection import pack_coeffs_to_point_labels + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + coeffs = np.zeros((3, spec.n_coeffs_per_atom)) + flat = pack_coeffs_to_point_labels(coeffs, spec, ("Fe", "O", "H")) + assert flat.shape == (3 * spec.n_coeffs_per_atom,) + + def test_dtype_preserved(self): + from graph2mat_ft.projection import pack_coeffs_to_point_labels + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + rng = np.random.default_rng(0) + coeffs = rng.standard_normal((2, spec.n_coeffs_per_atom)).astype(np.float64) + flat = pack_coeffs_to_point_labels(coeffs, spec, ("Fe", "Fe")) + assert flat.dtype == np.float64 + + def test_atoms_concatenated_in_input_order(self): + """Per-atom blocks must appear contiguously and in the order of + the symbols argument, so the flat vector aligns with the graph + node order Graph2Mat builds from the structure.""" + from graph2mat_ft.projection import pack_coeffs_to_point_labels + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + per_atom = spec.n_coeffs_per_atom + coeffs = np.zeros((2, per_atom)) + coeffs[0, :] = 1.0 + coeffs[1, :] = 2.0 + flat = pack_coeffs_to_point_labels(coeffs, spec, ("Fe", "O")) + assert np.allclose(flat[:per_atom], 1.0) + assert np.allclose(flat[per_atom:], 2.0) + + def test_within_atom_order_preserved(self): + """Within one atom's block, channels must keep their input order + (no reordering across the channel axis). This is the + load-bearing contract for matching what the Graph2Mat model + head learns to emit.""" + from graph2mat_ft.projection import pack_coeffs_to_point_labels + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + per_atom = spec.n_coeffs_per_atom + coeffs = np.arange(per_atom, dtype=np.float64).reshape(1, per_atom) + flat = pack_coeffs_to_point_labels(coeffs, spec, ("Fe",)) + np.testing.assert_array_equal(flat, np.arange(per_atom, dtype=np.float64)) + + def test_empty_structure_returns_empty(self): + from graph2mat_ft.projection import pack_coeffs_to_point_labels + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + flat = pack_coeffs_to_point_labels( + np.zeros((0, spec.n_coeffs_per_atom)), spec, () + ) + assert flat.shape == (0,) + + def test_symbol_length_mismatch_raises(self): + """N_atoms in coeffs must match len(symbols). Catching this at + the boundary stops a silent off-by-one from polluting the + training set.""" + from graph2mat_ft.projection import pack_coeffs_to_point_labels + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + coeffs = np.zeros((2, spec.n_coeffs_per_atom)) + with pytest.raises(ValueError): + pack_coeffs_to_point_labels(coeffs, spec, ("Fe",)) + + def test_wrong_channel_width_raises(self): + """coeffs.shape[1] must equal spec.n_coeffs_per_atom.""" + from graph2mat_ft.projection import pack_coeffs_to_point_labels + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + coeffs = np.zeros((1, spec.n_coeffs_per_atom + 1)) + with pytest.raises(ValueError): + pack_coeffs_to_point_labels(coeffs, spec, ("Fe",)) + + +class TestUnpackPointLabelsToCoeffs: + def test_output_shape(self): + from graph2mat_ft.projection import unpack_point_labels_to_coeffs + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + flat = np.zeros(2 * spec.n_coeffs_per_atom) + coeffs = unpack_point_labels_to_coeffs(flat, spec, ("Fe", "O")) + assert coeffs.shape == (2, spec.n_coeffs_per_atom) + + def test_wrong_length_raises(self): + from graph2mat_ft.projection import unpack_point_labels_to_coeffs + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + bad = np.zeros(2 * spec.n_coeffs_per_atom + 1) + with pytest.raises(ValueError): + unpack_point_labels_to_coeffs(bad, spec, ("Fe", "O")) + + +class TestRoundtrip: + def test_roundtrip_single_atom(self): + from graph2mat_ft.projection import ( + pack_coeffs_to_point_labels, + unpack_point_labels_to_coeffs, + ) + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + rng = np.random.default_rng(1) + coeffs = rng.standard_normal((1, spec.n_coeffs_per_atom)) + flat = pack_coeffs_to_point_labels(coeffs, spec, ("Fe",)) + restored = unpack_point_labels_to_coeffs(flat, spec, ("Fe",)) + np.testing.assert_array_equal(restored, coeffs) + + def test_roundtrip_multi_atom_mixed_species(self): + from graph2mat_ft.projection import ( + pack_coeffs_to_point_labels, + unpack_point_labels_to_coeffs, + ) + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + rng = np.random.default_rng(2) + symbols = ("Fe", "O", "Fe", "H", "O") + coeffs = rng.standard_normal((len(symbols), spec.n_coeffs_per_atom)) + flat = pack_coeffs_to_point_labels(coeffs, spec, symbols) + restored = unpack_point_labels_to_coeffs(flat, spec, symbols) + np.testing.assert_array_equal(restored, coeffs) + + +class TestBasisConfiguration: + """Bundle structure + symbols + (optional) coefficients into a + Graph2Mat-ready container. Used by the ZETA-GAMMA training + driver. Lazy-imports graph2mat so test only runs when the dep is + installed (it is in our pyproject).""" + + def test_returns_basisconfiguration_instance(self): + pytest.importorskip("graph2mat") + from graph2mat import BasisConfiguration + + from graph2mat_ft.projection import make_basis_configuration + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + positions = np.array([[0.0, 0.0, 0.0], [2.0, 2.0, 2.0]]) + cell = np.eye(3) * 4.0 + symbols = ("Fe", "O") + cfg = make_basis_configuration(positions, cell, symbols, spec) + assert isinstance(cfg, BasisConfiguration) + + def test_point_types_indexes_into_basis(self): + """point_types[i] must point at the PointBasis whose type + equals symbols[i]. If this drifts Graph2Mat assigns the wrong + per-species head to each atom.""" + pytest.importorskip("graph2mat") + + from graph2mat_ft.projection import make_basis_configuration + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + positions = np.array([[0.0, 0.0, 0.0], [2.0, 2.0, 2.0]]) + cell = np.eye(3) * 4.0 + symbols = ("Fe", "O") + cfg = make_basis_configuration(positions, cell, symbols, spec) + # Graph2Mat resolves point_types as indices into the cfg.basis list + types_via_basis = [cfg.basis[t].type for t in cfg.point_types] + assert tuple(types_via_basis) == symbols + + def test_positions_and_cell_round_trip(self): + pytest.importorskip("graph2mat") + + from graph2mat_ft.projection import make_basis_configuration + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + positions = np.array([[0.1, 0.2, 0.3], [1.5, 1.5, 1.5]]) + cell = np.diag([3.0, 4.0, 5.0]) + cfg = make_basis_configuration(positions, cell, ("Fe", "O"), spec) + np.testing.assert_allclose(cfg.positions, positions) + np.testing.assert_allclose(cfg.cell, cell) diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..2ee367f --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,100 @@ +""" +Unit tests for charge3net_ft metric functions. + +These tests use synthetic tensors with known ground-truth values and no real +data or GPU. Importing ``charge3net_ft.train`` requires the ``../charge3net`` +sibling clone (its module-level sys.path block raises RuntimeError without +it), so the whole module skips when the sibling is absent. +""" + +import pytest +import torch + +try: + from charge3net_ft.train import compute_nmape, compute_nrmse, compute_rmse +except (ImportError, RuntimeError) as exc: + pytest.skip(f"charge3net sibling repo unavailable: {exc}", allow_module_level=True) + + +class TestComputeNmape: + def test_perfect_prediction(self): + t = torch.tensor([[1.0, 2.0, 3.0]]) + assert compute_nmape(t, t).item() == pytest.approx(0.0, abs=1e-5) + + def test_known_value(self): + # preds all zero, targets = [2, 2] → NMAPE = 4/(4) * 100 = 100% + preds = torch.zeros(1, 2) + targets = torch.tensor([[2.0, 2.0]]) + assert compute_nmape(preds, targets).item() == pytest.approx(100.0, rel=1e-4) + + def test_batch_average(self): + # Sample 1: 0% error; Sample 2: 100% error → average 50% + preds = torch.tensor([[1.0, 1.0], [0.0, 0.0]]) + targets = torch.tensor([[1.0, 1.0], [1.0, 1.0]]) + assert compute_nmape(preds, targets).item() == pytest.approx(50.0, rel=1e-4) + + def test_with_mask_ignores_padding(self): + # Probe 3 is padding (zero); should give same result as without it + preds = torch.tensor([[0.0, 0.0, 0.0]]) + targets = torch.tensor([[2.0, 2.0, 0.0]]) + num_probes = torch.tensor([2]) + masked = compute_nmape(preds, targets, num_probes).item() + assert masked == pytest.approx(100.0, rel=1e-4) + + def test_mask_vs_no_mask_differ_when_padding_predicted_well(self): + # Real probes are mispredicted while the padding probe is predicted + # perfectly, so including the padding in the sums dilutes the error: + # masked and unmasked NMAPE must disagree. (With all-zero preds the + # numerator equals the denominator and both paths give 100%, which + # is why preds here must be nonzero.) + preds = torch.tensor([[1.0, 1.0, 100.0]]) + targets = torch.tensor([[2.0, 2.0, 100.0]]) # probe 3 is "padding" + num_probes = torch.tensor([2]) + masked = compute_nmape(preds, targets, num_probes).item() + unmasked = compute_nmape(preds, targets).item() + # Masked: |2-1| + |2-1| over |2| + |2| = 2/4 = 50%. + assert masked == pytest.approx(50.0, rel=1e-4) + # Unmasked: 2 / 104, diluted by the well-predicted padding probe. + assert unmasked == pytest.approx(2.0 / 104.0 * 100.0, rel=1e-4) + assert masked != pytest.approx(unmasked, rel=1e-2) + + +class TestComputeRmse: + def test_perfect_prediction(self): + t = torch.tensor([[1.0, 2.0]]) + assert compute_rmse(t, t).item() == pytest.approx(0.0, abs=1e-5) + + def test_known_value(self): + # preds = [0], targets = [3] → MSE = 9 → RMSE = 3 + preds = torch.zeros(1, 1) + targets = torch.tensor([[3.0]]) + assert compute_rmse(preds, targets).item() == pytest.approx(3.0, rel=1e-4) + + def test_with_mask(self): + # Same as above but with a padding element appended + preds = torch.zeros(1, 2) + targets = torch.tensor([[3.0, 999.0]]) + num_probes = torch.tensor([1]) + assert compute_rmse(preds, targets, num_probes).item() == pytest.approx( + 3.0, rel=1e-4 + ) + + +class TestComputeNrmse: + def test_perfect_prediction(self): + t = torch.tensor([[2.0, 4.0]]) + assert compute_nrmse(t, t).item() == pytest.approx(0.0, abs=1e-5) + + def test_known_value(self): + # preds = [0, 0], targets = [2, 2] → RMSE = 2, mean(|target|) = 2 → NRMSE = 100% + preds = torch.zeros(1, 2) + targets = torch.tensor([[2.0, 2.0]]) + assert compute_nrmse(preds, targets).item() == pytest.approx(100.0, rel=1e-4) + + def test_with_mask(self): + preds = torch.zeros(1, 3) + targets = torch.tensor([[2.0, 2.0, 999.0]]) + num_probes = torch.tensor([2]) + assert compute_nrmse(preds, targets, num_probes).item() == pytest.approx( + 100.0, rel=1e-4 + ) diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..02ea5c6 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,107 @@ +""" +Unit tests for ChargE3NetWrapper checkpoint loading. + +Tests all three checkpoint format branches using mock state dicts, +without requiring the real charge3net repo or a GPU. +""" + +import sys +import tempfile +from unittest.mock import patch + +import pytest +import torch +from torch import nn + + +def _make_mock_e3density(): + """Return a tiny 2-param nn.Module standing in for E3DensityModel.""" + + class _Tiny(nn.Module): + def __init__(self, **kwargs): + super().__init__() + self.w = nn.Parameter(torch.zeros(2)) + + def forward(self, x): + return x + + return _Tiny + + +def _import_wrapper(): + """Import ChargE3NetWrapper with charge3net stubbed out.""" + fake = { + "src": type(sys)("src"), + "src.charge3net": type(sys)("src.charge3net"), + "src.charge3net.models": type(sys)("src.charge3net.models"), + "src.charge3net.models.e3": type(sys)("src.charge3net.models.e3"), + } + MockModel = _make_mock_e3density() + fake["src.charge3net.models.e3"].E3DensityModel = MockModel + + with ( + patch.dict(sys.modules, fake), + patch("pathlib.Path.exists", return_value=True), + ): + if "charge3net_ft.model" in sys.modules: + del sys.modules["charge3net_ft.model"] + import importlib + + mod = importlib.import_module("charge3net_ft.model") + return mod.ChargE3NetWrapper, MockModel + + +class TestLoadPretrained: + def _save_and_load(self, checkpoint, wrapper_cls): + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as f: + path = f.name + torch.save(checkpoint, path) + w = wrapper_cls() + w.load_pretrained(path) + return w + + def test_new_charge3net_format(self): + WrapperCls, MockModel = _import_wrapper() + state_dict = MockModel().state_dict() + ckpt = {"model": state_dict} + w = self._save_and_load(ckpt, WrapperCls) + # Should load without errors + assert w is not None + + def test_legacy_pl_format(self): + WrapperCls, MockModel = _import_wrapper() + state_dict = {f"network.{k}": v for k, v in MockModel().state_dict().items()} + ckpt = { + "pytorch-lightning_version": "1.9.0", + "state_dict": state_dict, + } + w = self._save_and_load(ckpt, WrapperCls) + assert w is not None + + def test_generic_state_dict_format(self): + WrapperCls, MockModel = _import_wrapper() + state_dict = {f"network.{k}": v for k, v in MockModel().state_dict().items()} + ckpt = {"state_dict": state_dict} + w = self._save_and_load(ckpt, WrapperCls) + assert w is not None + + def test_raw_state_dict_format(self): + WrapperCls, MockModel = _import_wrapper() + ckpt = MockModel().state_dict() # the dict IS the state_dict + w = self._save_and_load(ckpt, WrapperCls) + assert w is not None + + def test_missing_file_raises(self): + WrapperCls, _ = _import_wrapper() + w = WrapperCls() + with pytest.raises(FileNotFoundError): + w.load_pretrained("/nonexistent/path/checkpoint.pt") + + def test_unexpected_format_raises(self): + WrapperCls, _ = _import_wrapper() + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as f: + path = f.name + torch.save("not_a_dict", path) + w = WrapperCls() + with pytest.raises(TypeError): + w.load_pretrained(path) diff --git a/tests/test_salted_baseline.py b/tests/test_salted_baseline.py new file mode 100644 index 0000000..b4c339d --- /dev/null +++ b/tests/test_salted_baseline.py @@ -0,0 +1,259 @@ +"""TDD tests for ``salted_ft.train_baseline`` (D6 path B). + +A pragmatic PyTorch baseline that predicts per-atom basis +coefficients (the same target SALTED projects to). Architecture is +a small SchNet-style invariant message-passing net + linear +readout to ``n_coeffs_per_atom`` channels. Loss is MSE on the +ground-truth coefficient vectors from D2. + +Tests cover the model contract and the training-loop sanity +check (loss must decrease over a few steps). Real Adastra runs +validate end-to-end NMAPE on the held-out split. +""" + +from __future__ import annotations + +from pathlib import Path + +import ase +import numpy as np +import pandas as pd +import pytest + + +def _h2_atoms() -> ase.Atoms: + return ase.Atoms( + "HH", + positions=[[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]], + cell=np.eye(3) * 5.0, + pbc=True, + ) + + +def _feo_atoms() -> ase.Atoms: + return ase.Atoms( + "FeO", + positions=[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + cell=np.eye(3) * 4.0, + pbc=True, + ) + + +class TestModelForward: + def test_output_shape(self): + pytest.importorskip("torch") + from salted_ft.basis import BasisSpec + from salted_ft.train_baseline import SaltedBaselineModel + + m = SaltedBaselineModel(BasisSpec()) + out = m(_h2_atoms()) + assert out.shape == (2, BasisSpec().n_coeffs_per_atom) + + def test_output_finite(self): + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + from salted_ft.train_baseline import SaltedBaselineModel + + m = SaltedBaselineModel(BasisSpec()) + out = m(_feo_atoms()) + assert torch.isfinite(out).all() + + def test_output_dtype_is_float32(self): + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + from salted_ft.train_baseline import SaltedBaselineModel + + m = SaltedBaselineModel(BasisSpec()) + out = m(_h2_atoms()) + assert out.dtype == torch.float32 + + def test_deterministic_with_same_seed(self): + """Same model state + same atoms in -> same coefficients out. + Required for the eval pipeline to be reproducible.""" + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + from salted_ft.train_baseline import SaltedBaselineModel + + torch.manual_seed(0) + m1 = SaltedBaselineModel(BasisSpec()) + torch.manual_seed(0) + m2 = SaltedBaselineModel(BasisSpec()) + out1 = m1(_h2_atoms()) + out2 = m2(_h2_atoms()) + torch.testing.assert_close(out1, out2) + + def test_different_species_changes_output(self): + """Species embedding must carry signal. If H and Fe atoms with + identical positions give identical outputs the embedding is + ignored.""" + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + from salted_ft.train_baseline import SaltedBaselineModel + + torch.manual_seed(0) + m = SaltedBaselineModel(BasisSpec()) + a_hh = ase.Atoms( + "HH", positions=[[0, 0, 0], [2, 0, 0]], cell=np.eye(3) * 5.0, pbc=True + ) + a_he = ase.Atoms( + "HHe", positions=[[0, 0, 0], [2, 0, 0]], cell=np.eye(3) * 5.0, pbc=True + ) + out_hh = m(a_hh) + out_he = m(a_he) + assert not torch.allclose(out_hh, out_he) + + +class TestTrainingStep: + def test_loss_decreases_after_few_steps(self): + """Sanity: optimiser can drive the loss down on a tiny dataset. + Catches obvious wiring bugs (no grads flowing, frozen embedding).""" + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + from salted_ft.train_baseline import SaltedBaselineModel + + torch.manual_seed(0) + spec = BasisSpec() + model = SaltedBaselineModel(spec) + opt = torch.optim.Adam(model.parameters(), lr=1e-2) + atoms = _feo_atoms() + target = torch.randn(len(atoms), spec.n_coeffs_per_atom) * 0.1 + + # Loss before any training + with torch.no_grad(): + loss_before = torch.nn.functional.mse_loss(model(atoms), target).item() + + for _ in range(20): + opt.zero_grad() + pred = model(atoms) + loss = torch.nn.functional.mse_loss(pred, target) + loss.backward() + opt.step() + + with torch.no_grad(): + loss_after = torch.nn.functional.mse_loss(model(atoms), target).item() + assert loss_after < loss_before, ( + f"loss did not decrease: before={loss_before:.6f}, after={loss_after:.6f}" + ) + + +class TestSaveLoad: + def test_save_load_preserves_predictions(self, tmp_path): + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + from salted_ft.train_baseline import SaltedBaselineModel + + torch.manual_seed(0) + spec = BasisSpec() + m = SaltedBaselineModel(spec) + atoms = _h2_atoms() + out_before = m(atoms) + + ckpt = tmp_path / "model.pt" + torch.save({"basis_spec": spec, "model": m.state_dict()}, ckpt) + + m2 = SaltedBaselineModel(spec) + state = torch.load(ckpt, map_location="cpu", weights_only=False) + m2.load_state_dict(state["model"]) + out_after = m2(atoms) + torch.testing.assert_close(out_before, out_after) + + +def _toy_dataset_dirs(tmp_path: Path, basis_spec, n_rows: int = 2): + """Create matched D2 source + projected parquets in two subdirs. + + The training dataset joins them by ``row_index`` per chunk; the + file basename matches across the two directories so ``chunk_0000`` + in ``source/`` lines up with ``chunk_0000`` in ``coeffs/``. + """ + src_dir = tmp_path / "charge3net_data" + coeffs_dir = tmp_path / "salted_projected_coefficients" + src_dir.mkdir() + coeffs_dir.mkdir() + rng = np.random.default_rng(0) + + src_rows = [] + coeffs_rows = [] + for i in range(n_rows): + n_atoms = 2 + atomic_numbers = [1, 1] + positions = [[0.0, 0.0, 0.0], [0.74 + 0.01 * i, 0.0, 0.0]] + cell = (np.eye(3) * 5.0).tolist() + src_rows.append( + { + "row_index": i, + "material_id": f"mp-{i}", + "n_atoms": n_atoms, + "atomic_numbers": atomic_numbers, + "cartesian_site_positions": [c for row in positions for c in row], + "lattice_vectors": [c for row in cell for c in row], + # Tiny grid so this stays cheap; the projected file is what + # the training loop actually consumes + "grid_shape": [4, 4, 4], + "compressed_charge_density": rng.standard_normal(np.prod((4, 4, 4))) + .astype(np.float32) + .tobytes(), + } + ) + coeffs_rows.append( + { + "row_index": i, + "material_id": f"mp-{i}", + "n_atoms": n_atoms, + "atomic_numbers": atomic_numbers, + "lattice_vectors": cell, + "n_electrons": 2.0, + "grid_shape": [4, 4, 4], + "coefficients": rng.standard_normal( + (n_atoms, basis_spec.n_coeffs_per_atom) + ).tolist(), + "basis_set_NMAPE": 5.0, + } + ) + pd.DataFrame(src_rows).to_parquet(src_dir / "chunk_0000.parquet") + pd.DataFrame(coeffs_rows).to_parquet(coeffs_dir / "chunk_0000.parquet") + return src_dir, coeffs_dir + + +class TestTrainCLI: + """Higher-level: ``train`` end-to-end on a synthetic 2-row dataset. + + Validates that the full data path (parquet pair -> dataset -> + training loop -> ckpt) works without crashing. Real ckpts come + from running ``submit_salted_baseline_adastra.sh``. + """ + + def test_train_writes_ckpt(self, tmp_path): + pytest.importorskip("torch") + + from salted_ft.basis import BasisSpec + from salted_ft.train_baseline import train + + spec = BasisSpec() + src_dir, coeffs_dir = _toy_dataset_dirs(tmp_path, spec, n_rows=2) + ckpt = tmp_path / "salted_baseline.pt" + train( + source_dir=src_dir, + coeffs_dir=coeffs_dir, + output_ckpt=ckpt, + basis_spec=spec, + n_epochs=1, + batch_size=1, + learning_rate=1e-3, + ) + assert ckpt.exists() + import torch + + state = torch.load(ckpt, map_location="cpu", weights_only=False) + assert "model" in state diff --git a/tests/test_salted_basis.py b/tests/test_salted_basis.py new file mode 100644 index 0000000..b8ce7f4 --- /dev/null +++ b/tests/test_salted_basis.py @@ -0,0 +1,155 @@ +"""TDD tests for the SALTED-arm BasisSpec dataclass. + +Locks down the basis numbers chosen in +``plan_salted_graph2mat_basis_choice_may_20_pm.md`` (Phase A4): + +* ``max_l = 4`` +* ``n_radial = 4`` (uniform across species in v1) +* ``sigma = (0.5, 1.0, 2.0, 4.0)`` Å — geometric radial-width ladder +* ``cutoff = 4.0`` Å — matches ChargE3Net's KdTree cutoff +* ``n_coeffs_per_atom == n_radial * (max_l + 1) ** 2`` == 100 + +These numbers are referenced by every downstream PR (projection, +reconstruction, model wrapper, VASP I/O). Pinning them here means a +later edit shows up as a single failing test, not a silent drift. +""" + +from __future__ import annotations + +import pytest + + +class TestBasisSpecDefaults: + """Default BasisSpec must match the A4 lockdown.""" + + def test_default_max_l_is_four(self): + from salted_ft.basis import BasisSpec + + assert BasisSpec().max_l == 4 + + def test_default_n_radial_is_four(self): + from salted_ft.basis import BasisSpec + + assert BasisSpec().n_radial == 4 + + def test_default_sigma_ladder(self): + from salted_ft.basis import BasisSpec + + # Geometric ladder over tight + valence + diffuse regimes. + assert BasisSpec().sigma == (0.5, 1.0, 2.0, 4.0) + + def test_default_cutoff_matches_charge3net(self): + from salted_ft.basis import BasisSpec + + # ChargE3Net's KdTreeGraphConstructor uses cutoff=4.0; the SALTED-arm + # uses the same so atom-neighbor structure is identical between models. + assert BasisSpec().cutoff == pytest.approx(4.0) + + def test_default_n_coeffs_per_atom_is_100(self): + """4 radial * (4+1)^2 angular = 100 coefficients per atom.""" + from salted_ft.basis import BasisSpec + + assert BasisSpec().n_coeffs_per_atom == 100 + + +class TestBasisSpecArithmetic: + """n_coeffs_per_atom must equal n_radial * (max_l + 1)^2 for any valid spec.""" + + @pytest.mark.parametrize( + "max_l,n_radial,expected", + [ + (0, 1, 1), # one s function + (1, 2, 8), # 2 * (1 + 3) = 8 + (2, 3, 27), # 3 * (1 + 3 + 5) = 27 + (4, 4, 100), # the production default + (6, 4, 196), # 4 * (1 + 3 + 5 + 7 + 9 + 11 + 13) = 196 + ], + ) + def test_n_coeffs_formula(self, max_l, n_radial, expected): + from salted_ft.basis import BasisSpec + + spec = BasisSpec( + max_l=max_l, + n_radial=n_radial, + sigma=tuple(0.5 * 2**i for i in range(n_radial)), + cutoff=5.0, + ) + assert spec.n_coeffs_per_atom == expected + + def test_n_radial_matches_sigma_length(self): + """sigma is the per-radial-channel width; len(sigma) must equal n_radial.""" + from salted_ft.basis import BasisSpec + + with pytest.raises(ValueError, match=r"n_radial.*sigma"): + BasisSpec(max_l=2, n_radial=3, sigma=(0.5, 1.0), cutoff=4.0) + + +class TestBasisSpecValidation: + """Reject malformed specs at construction time, not at use time.""" + + def test_negative_max_l_rejected(self): + from salted_ft.basis import BasisSpec + + with pytest.raises(ValueError, match=r"max_l"): + BasisSpec(max_l=-1, n_radial=4, sigma=(0.5, 1.0, 2.0, 4.0), cutoff=4.0) + + def test_zero_n_radial_rejected(self): + from salted_ft.basis import BasisSpec + + with pytest.raises(ValueError, match=r"n_radial"): + BasisSpec(max_l=4, n_radial=0, sigma=(), cutoff=4.0) + + def test_negative_sigma_rejected(self): + """sigma is a Gaussian width; nonpositive widths are nonphysical.""" + from salted_ft.basis import BasisSpec + + with pytest.raises(ValueError, match=r"sigma"): + BasisSpec(max_l=2, n_radial=2, sigma=(0.5, -1.0), cutoff=4.0) + + def test_nonpositive_cutoff_rejected(self): + from salted_ft.basis import BasisSpec + + with pytest.raises(ValueError, match=r"cutoff"): + BasisSpec(max_l=2, n_radial=2, sigma=(0.5, 1.0), cutoff=0.0) + + +class TestBasisSpecShapes: + """Shape helpers for downstream tensor allocation.""" + + def test_n_angular_components_per_radial(self): + """(max_l + 1)^2 real spherical harmonic components per radial channel.""" + from salted_ft.basis import BasisSpec + + # l=0,1,2,3,4 -> 1+3+5+7+9 = 25 angular components per radial channel + assert BasisSpec().n_angular_components == 25 + + def test_total_coeffs_shape(self): + """coeffs tensor shape for a structure: (n_atoms, n_coeffs_per_atom).""" + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + assert spec.total_coeffs_shape(n_atoms=5) == (5, 100) + assert spec.total_coeffs_shape(n_atoms=1) == (1, 100) + + +class TestBasisSpecImmutable: + """BasisSpec must be hashable + immutable so it can key caches / metric runs.""" + + def test_is_hashable(self): + from salted_ft.basis import BasisSpec + + # Two specs with identical fields hash to the same value. + a = BasisSpec() + b = BasisSpec() + assert hash(a) == hash(b) + assert a == b + + def test_mutation_rejected(self): + """Frozen dataclass — assigning to a field raises FrozenInstanceError.""" + from dataclasses import FrozenInstanceError + + from salted_ft.basis import BasisSpec + + spec = BasisSpec() + with pytest.raises(FrozenInstanceError): + spec.max_l = 6 # type: ignore[misc] diff --git a/tests/test_salted_io.py b/tests/test_salted_io.py new file mode 100644 index 0000000..61a00e1 --- /dev/null +++ b/tests/test_salted_io.py @@ -0,0 +1,207 @@ +"""TDD tests for VASP CHGCAR I/O wrapper (PR delta). + +The wrapper exposes ``write_chgcar(density, atoms, path)`` so a +reconstructed real-space density grid can be persisted as a VASP +CHGCAR file. That file is then the input to a paired SCF run +(``ICHARG=1``) for the speedup comparison vs the +``ICHARG=2``-from-superposition baseline. + +Locked contract: + +* ``write_chgcar(density, atoms, path, n_electrons=None)`` + Writes a pymatgen ``Chgcar``-compatible file at ``path``. If + ``n_electrons`` is given, rescales the density so that + ``sum(density) * cell_volume / N_grid == n_electrons``. +* The written file round-trips through ``Chgcar.from_file`` and + preserves shape, atom species, and cell. +* ``read_chgcar(path)`` is the inverse: returns + ``(density: np.ndarray, atoms: ase.Atoms)``. + +End-to-end SCF speedup test is gated on the entalsim +``StructureVASPSinglePoint`` maker landing; pinned here as an +``importorskip`` placeholder so it auto-activates when the +dependency arrives. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import ase +import numpy as np +import pytest + + +def _cubic_atoms(symbols=("Fe",), fractional=((0.5, 0.5, 0.5),), a=4.0): + cell = np.eye(3) * a + cart = np.array(fractional) @ cell + return ase.Atoms(symbols=list(symbols), positions=cart, cell=cell, pbc=True) + + +class TestWriteChgcar: + def test_writes_file(self): + from salted_ft.io import write_chgcar + + atoms = _cubic_atoms() + density = np.ones((8, 8, 8), dtype=np.float64) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + write_chgcar(density, atoms, path) + assert path.exists() + assert path.stat().st_size > 0 + + def test_normalizes_to_total_electron_count(self): + """When ``n_electrons`` is set, the *integrated* density of the + written file must equal ``n_electrons`` to within ``1e-6 * n_electrons``. + That's what VASP reads as N_electrons on ICHARG=1. + """ + from salted_ft.io import read_chgcar, write_chgcar + + atoms = _cubic_atoms() + # Density that integrates to something arbitrary; write_chgcar + # should rescale to the requested electron count. + density = np.ones((8, 8, 8), dtype=np.float64) * 0.5 + target_n = 26.0 # Fe valence electron count, roughly + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + write_chgcar(density, atoms, path, n_electrons=target_n) + read_density, _ = read_chgcar(path) + # CHGCAR convention: density * volume / N_grid integrates to N_electrons + cell_volume = np.linalg.det(atoms.get_cell()) + n_grid = np.prod(read_density.shape) + total_e = read_density.sum() * cell_volume / n_grid + assert abs(total_e - target_n) / target_n < 1e-4, ( + f"integrated density {total_e:.6f} differs from target {target_n} " + "by more than 1e-4; CHGCAR normalization is wrong" + ) + + def test_rejects_non_3d_density(self): + from salted_ft.io import write_chgcar + + atoms = _cubic_atoms() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + with pytest.raises(ValueError, match=r"3D"): + write_chgcar(np.ones((8, 8)), atoms, path) + + def test_rejects_negative_n_electrons(self): + from salted_ft.io import write_chgcar + + atoms = _cubic_atoms() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + with pytest.raises(ValueError, match=r"n_electrons"): + write_chgcar(np.ones((8, 8, 8)), atoms, path, n_electrons=-1.0) + + +class TestReadChgcar: + def test_returns_density_and_atoms(self): + from salted_ft.io import read_chgcar, write_chgcar + + atoms = _cubic_atoms() + density = np.ones((8, 8, 8), dtype=np.float64) * 0.1 + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + write_chgcar(density, atoms, path) + read_density, read_atoms = read_chgcar(path) + assert read_density.shape == (8, 8, 8) + assert isinstance(read_atoms, ase.Atoms) + + def test_preserves_atom_species(self): + from salted_ft.io import read_chgcar, write_chgcar + + atoms = _cubic_atoms( + symbols=("Fe", "O"), fractional=((0.0, 0.0, 0.0), (0.5, 0.5, 0.5)) + ) + density = np.ones((8, 8, 8), dtype=np.float64) * 0.1 + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + write_chgcar(density, atoms, path) + _, read_atoms = read_chgcar(path) + # Order may differ but the multiset of species must match. + assert sorted(read_atoms.get_chemical_symbols()) == sorted( + atoms.get_chemical_symbols() + ) + + def test_preserves_cell(self): + from salted_ft.io import read_chgcar, write_chgcar + + atoms = _cubic_atoms(a=5.0) + density = np.ones((4, 4, 4), dtype=np.float64) * 0.05 + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + write_chgcar(density, atoms, path) + _, read_atoms = read_chgcar(path) + np.testing.assert_allclose( + np.asarray(read_atoms.get_cell()), + np.asarray(atoms.get_cell()), + atol=1e-6, + ) + + +class TestRoundtrip: + def test_density_roundtrip_within_tolerance(self): + """Write then read: shape exact, values within VASP-precision tolerance. + + VASP CHGCAR uses 5-decimal scientific notation per value, so + we expect ~1e-5 relative precision. + """ + from salted_ft.io import read_chgcar, write_chgcar + + atoms = _cubic_atoms() + rng = np.random.default_rng(7) + density = rng.random((8, 8, 8)).astype(np.float64) * 0.1 + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + write_chgcar(density, atoms, path) + read_density, _ = read_chgcar(path) + assert read_density.shape == density.shape + np.testing.assert_allclose(read_density, density, rtol=1e-3, atol=1e-5) + + +class TestSALTEDModelToChgcar: + """End-to-end: predict via SALTEDModel, reconstruct, write CHGCAR.""" + + def test_predicted_density_writes_to_chgcar(self): + from salted_ft.basis import BasisSpec + from salted_ft.io import read_chgcar, write_chgcar + from salted_ft.model import SALTEDModel + + atoms = _cubic_atoms() + model = SALTEDModel(basis_spec=BasisSpec()) + density = model.reconstruct_density(atoms, (8, 8, 8)) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "CHGCAR" + write_chgcar(density, atoms, path) + assert path.exists() + read_density, _ = read_chgcar(path) + assert read_density.shape == (8, 8, 8) + + +# --------------------------------------------------------------------------- +# Forward-looking placeholder for the entalsim integration. +# +# Once Entalpic/entalsim PR #56's PR 2 (StructureVASPSinglePoint maker) +# lands and is installable, this test will auto-activate. Until then it +# skips cleanly so the suite stays green. +# --------------------------------------------------------------------------- +class TestVASPSinglePointHook: + def test_chgcar_consumed_by_entalsim_single_point_maker(self): + # Skips until entalsim ships the maker. + pytest.importorskip("entalsim.dft.tasks.single_point") + from entalsim.dft.tasks.single_point import StructureVASPSinglePoint + + from salted_ft.basis import BasisSpec + from salted_ft.io import write_chgcar + from salted_ft.model import SALTEDModel + + atoms = _cubic_atoms() + model = SALTEDModel(basis_spec=BasisSpec()) + density = model.reconstruct_density(atoms, (8, 8, 8)) + with tempfile.TemporaryDirectory() as tmp: + chgcar = Path(tmp) / "CHGCAR" + write_chgcar(density, atoms, chgcar) + # Maker should accept the written CHGCAR for ICHARG=1. + maker = StructureVASPSinglePoint(initial_chgcar=chgcar) + assert maker.initial_chgcar == chgcar diff --git a/tests/test_salted_model.py b/tests/test_salted_model.py new file mode 100644 index 0000000..7977611 --- /dev/null +++ b/tests/test_salted_model.py @@ -0,0 +1,303 @@ +"""TDD tests for the SALTEDModel wrapper (PR gamma). + +The wrapper exposes ``__call__(atoms) -> coefficients`` so SALTED-style +predictions plug into the projection / reconstruction layer from PR beta. + +Locked contract: + +* ``SALTEDModel(basis_spec, ckpt_path=None)`` — construct. When + ``ckpt_path`` is None the wrapper produces deterministic + position-dependent stub coefficients (lets us run tests + the + reconstruction pipeline without a real rholearn checkpoint). + +* ``model(atoms)`` returns ``np.ndarray (n_atoms, n_coeffs_per_atom)``, + float64, finite, deterministic for fixed inputs. + +* ``model.reconstruct_density(atoms, grid_shape)`` returns the density + grid in the same shape ``reconstruct_grid_from_basis`` would have + produced from the predicted coefficients. Convenience method for the + VASP comparison pipeline. + +* Metric integration: the predicted density grid feeds into + ``compute_nmape`` / ``compute_rmse`` / ``compute_nrmse`` from + ``charge3net_ft.train`` and they return finite scalars. Pinned per the + brief: "Keep the metric calculations identical to our ChargE3Net pipeline." +""" + +from __future__ import annotations + +import ase +import numpy as np +import torch + + +def _cubic_atoms(symbols=("Fe",), fractional=((0.0, 0.0, 0.0),), a=4.0): + cell = np.eye(3) * a + cart = np.array(fractional) @ cell + return ase.Atoms(symbols=list(symbols), positions=cart, cell=cell, pbc=True) + + +class TestSALTEDModelConstruct: + def test_constructs_with_basis_spec(self): + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + spec = BasisSpec() + m = SALTEDModel(basis_spec=spec) + assert m.basis_spec is spec + + def test_default_ckpt_is_none(self): + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + m = SALTEDModel(basis_spec=BasisSpec()) + assert m.ckpt_path is None + + +class TestSALTEDModelOutputShape: + def test_single_atom_output_shape(self): + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + spec = BasisSpec() + m = SALTEDModel(basis_spec=spec) + coeffs = m(_cubic_atoms()) + assert coeffs.shape == (1, spec.n_coeffs_per_atom) + + def test_multi_atom_output_shape(self): + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + spec = BasisSpec() + m = SALTEDModel(basis_spec=spec) + atoms = _cubic_atoms( + symbols=("Fe", "O", "Fe"), + fractional=((0.0, 0.0, 0.0), (0.5, 0.5, 0.5), (0.25, 0.25, 0.25)), + ) + coeffs = m(atoms) + assert coeffs.shape == (3, spec.n_coeffs_per_atom) + + def test_output_dtype_is_float64(self): + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + m = SALTEDModel(basis_spec=BasisSpec()) + coeffs = m(_cubic_atoms()) + assert coeffs.dtype == np.float64 + + def test_output_is_finite(self): + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + m = SALTEDModel(basis_spec=BasisSpec()) + coeffs = m(_cubic_atoms()) + assert np.isfinite(coeffs).all() + + +class TestSALTEDModelDeterminism: + def test_same_input_gives_same_output(self): + """Reproducibility: critical for CI + regression tests.""" + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + m = SALTEDModel(basis_spec=BasisSpec()) + atoms = _cubic_atoms( + symbols=("Fe", "Fe"), fractional=((0.1, 0.2, 0.3), (0.4, 0.5, 0.6)) + ) + c1 = m(atoms) + c2 = m(atoms) + np.testing.assert_array_equal(c1, c2) + + def test_different_positions_give_different_coefficients(self): + """A degenerate stub that always returned zeros would pass shape + + determinism but be useless. Require some position-dependent + variation so downstream tests have signal to work with. + """ + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + m = SALTEDModel(basis_spec=BasisSpec()) + atoms_a = _cubic_atoms(fractional=((0.0, 0.0, 0.0),)) + atoms_b = _cubic_atoms(fractional=((0.5, 0.5, 0.5),)) + c_a = m(atoms_a) + c_b = m(atoms_b) + assert not np.allclose(c_a, c_b), ( + "predicted coefficients must depend on atom positions; the stub " + "appears to return position-independent constants" + ) + + def test_baseline_ckpt_loads_and_predicts(self, tmp_path): + """Real-mode path: save a D6 baseline ckpt, instantiate SALTEDModel + with its path, and verify forward returns the expected shape and + the prediction differs from stub-mode output (so we know the + ckpt actually drove the result).""" + import pytest + + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + from salted_ft.train_baseline import SaltedBaselineModel + + spec = BasisSpec() + torch.manual_seed(42) + baseline = SaltedBaselineModel(spec) + ckpt = tmp_path / "salted_baseline.pt" + torch.save({"basis_spec": spec, "model": baseline.state_dict()}, ckpt) + + atoms = _cubic_atoms( + symbols=("Fe", "Fe"), fractional=((0.1, 0.2, 0.3), (0.4, 0.5, 0.6)) + ) + + m_stub = SALTEDModel(spec) + m_loaded = SALTEDModel(spec, ckpt_path=ckpt) + + out_stub = m_stub(atoms) + out_loaded = m_loaded(atoms) + + assert out_loaded.shape == (2, spec.n_coeffs_per_atom) + assert not np.allclose(out_loaded, out_stub), ( + "loaded ckpt produced the same output as the stub seed; " + "the ckpt path likely is not being exercised" + ) + + def test_bad_ckpt_format_raises_clearly(self, tmp_path): + import pytest + + pytest.importorskip("torch") + import torch + + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + ckpt = tmp_path / "bad.pt" + torch.save({"not_a_baseline": "anything"}, ckpt) + m = SALTEDModel(BasisSpec(), ckpt_path=ckpt) + atoms = _cubic_atoms() + with pytest.raises(RuntimeError, match="baseline format"): + m(atoms) + + def test_perturbing_non_first_atom_changes_coefficients(self): + """Regression test for the int.from_bytes(seed_bytes[:16], ...) + bug: with the old seeding, only atom 0's xyz (the first 24 + bytes) contributed to the seed, so perturbing atom 1+ produced + identical coefficients. The blake2b hash fixes this. + """ + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + m = SALTEDModel(basis_spec=BasisSpec()) + atoms_a = _cubic_atoms( + symbols=("Fe", "Fe"), fractional=((0.0, 0.0, 0.0), (0.5, 0.5, 0.5)) + ) + atoms_b = _cubic_atoms( + symbols=("Fe", "Fe"), fractional=((0.0, 0.0, 0.0), (0.6, 0.5, 0.5)) + ) + c_a = m(atoms_a) + c_b = m(atoms_b) + assert not np.array_equal(c_a, c_b), ( + "perturbing atom 1 must change the coefficient output; " + "if not, the stub seed only uses atom 0's bytes" + ) + + +class TestSALTEDModelReconstructDensity: + def test_reconstruct_density_shape(self): + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + m = SALTEDModel(basis_spec=BasisSpec()) + grid = m.reconstruct_density(_cubic_atoms(), (8, 8, 8)) + assert grid.shape == (8, 8, 8) + + def test_reconstruct_density_matches_explicit_path(self): + """``model.reconstruct_density(atoms, shape)`` must equal calling + ``model(atoms)`` then ``reconstruct_grid_from_basis(c, ...)``. + Convenience method is just sugar. + """ + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + from salted_ft.projection import reconstruct_grid_from_basis + + spec = BasisSpec() + atoms = _cubic_atoms( + symbols=("Fe", "O"), fractional=((0.0, 0.0, 0.0), (0.5, 0.5, 0.5)) + ) + m = SALTEDModel(basis_spec=spec) + c = m(atoms) + expected = reconstruct_grid_from_basis(c, atoms, (8, 8, 8), spec) + got = m.reconstruct_density(atoms, (8, 8, 8)) + np.testing.assert_array_equal(got, expected) + + def test_reconstruct_density_dtype_and_finite(self): + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + m = SALTEDModel(basis_spec=BasisSpec()) + grid = m.reconstruct_density(_cubic_atoms(), (8, 8, 8)) + assert grid.dtype == np.float64 + assert np.isfinite(grid).all() + + +class TestMetricIntegration: + """Predicted density grid feeds the existing ChargE3Net metric functions.""" + + def _to_torch_batch(self, grid: np.ndarray) -> torch.Tensor: + """Flatten a (Nx, Ny, Nz) grid into a (B=1, N_probes) torch tensor. + + ChargE3Net's compute_nmape signature is (preds, targets, num_probes). + For full-grid evaluation we use B=1 and num_probes=None. + """ + return torch.from_numpy(grid.astype(np.float32).reshape(1, -1)) + + def test_compute_nmape_returns_finite_scalar(self): + from charge3net_ft.train import compute_nmape + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + atoms = _cubic_atoms(fractional=((0.5, 0.5, 0.5),)) + m = SALTEDModel(basis_spec=BasisSpec()) + preds = self._to_torch_batch(m.reconstruct_density(atoms, (8, 8, 8))) + # Synthetic target: same shape, non-zero so the NMAPE denominator is positive + targets = torch.ones_like(preds) + nmape = compute_nmape(preds, targets, num_probes=None) + assert nmape.numel() == 1 + assert torch.isfinite(nmape).all() + + def test_compute_rmse_returns_finite_scalar(self): + from charge3net_ft.train import compute_rmse + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + atoms = _cubic_atoms(fractional=((0.5, 0.5, 0.5),)) + m = SALTEDModel(basis_spec=BasisSpec()) + preds = self._to_torch_batch(m.reconstruct_density(atoms, (8, 8, 8))) + targets = torch.ones_like(preds) + rmse = compute_rmse(preds, targets, num_probes=None) + assert torch.isfinite(rmse).all() + + def test_compute_nrmse_returns_finite_scalar(self): + from charge3net_ft.train import compute_nrmse + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + atoms = _cubic_atoms(fractional=((0.5, 0.5, 0.5),)) + m = SALTEDModel(basis_spec=BasisSpec()) + preds = self._to_torch_batch(m.reconstruct_density(atoms, (8, 8, 8))) + targets = torch.ones_like(preds) + nrmse = compute_nrmse(preds, targets, num_probes=None) + assert torch.isfinite(nrmse).all() + + def test_perfect_prediction_gives_zero_nmape(self): + """Sanity check: NMAPE of a tensor against itself is zero.""" + from charge3net_ft.train import compute_nmape + from salted_ft.basis import BasisSpec + from salted_ft.model import SALTEDModel + + atoms = _cubic_atoms(fractional=((0.5, 0.5, 0.5),)) + m = SALTEDModel(basis_spec=BasisSpec()) + preds = self._to_torch_batch(m.reconstruct_density(atoms, (8, 8, 8))) + # Self-similarity: target identical to prediction => zero error. + nmape = compute_nmape(preds, preds.clone(), num_probes=None) + assert nmape.item() == 0.0 diff --git a/tests/test_salted_project_dataset.py b/tests/test_salted_project_dataset.py new file mode 100644 index 0000000..f9c8253 --- /dev/null +++ b/tests/test_salted_project_dataset.py @@ -0,0 +1,236 @@ +"""TDD tests for the Phase D2 dataset-projection module. + +Locks the contract for ``salted_ft.project_dataset.project_chunk``, +which reads a LeMat-Rho-format parquet chunk, runs +``project_chgcar_to_basis`` row by row, and writes a parallel parquet +chunk of projected coefficients. + +Output schema per row:: + + { + "row_index": int (matches the original chunk row index), + "material_id": str (carried through if present, else "" ), + "n_atoms": int, + "atomic_numbers": list[int], + "lattice_vectors": list[list[float]], # 3x3 + "n_electrons": float (integrated density * cell_volume / n_grid), + "grid_shape": list[int], # [Nx, Ny, Nz] + "coefficients": list[list[float]], # (n_atoms, n_coeffs_per_atom) + "basis_set_NMAPE": float (basis-ceiling NMAPE for this row), + } + +The basis_set_NMAPE column is the per-row reconstruction error from +roundtripping; we keep it so downstream sanity-checks can know each +sample's basis ceiling. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq + + +def _write_synthetic_chunk(path: Path, n_rows: int = 3) -> None: + """Write a LeMat-Rho-format chunk for use by the projection script.""" + rng = np.random.default_rng(42) + grids = [ + json.dumps(rng.random((10, 10, 10), dtype=np.float64).tolist()) + for _ in range(n_rows) + ] + table = pa.table( + { + "compressed_charge_density": pa.array(grids, type=pa.string()), + "species_at_sites": pa.array([["Fe"]] * n_rows), + "cartesian_site_positions": pa.array([[[2.0, 2.0, 2.0]]] * n_rows), + "lattice_vectors": pa.array( + [[[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]] * n_rows + ), + # extras: confirm they get ignored + "bader_charges": pa.array([[0.4]] * n_rows), + "material_id": pa.array([f"mat_{i:03d}" for i in range(n_rows)]), + } + ) + pq.write_table(table, path) + + +class TestProjectChunkContract: + """``project_chunk(in_path, out_path, basis_spec)`` -> None. + + Reads ``in_path`` (LeMat-Rho format), projects each row, writes + ``out_path`` in the schema documented at the top of this file. + """ + + def test_output_file_written(self): + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_chunk + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "in.parquet", n_rows=2) + out = d / "out.parquet" + project_chunk(d / "in.parquet", out, BasisSpec()) + assert out.exists() + assert out.stat().st_size > 0 + + def test_row_count_matches_valid_input(self): + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_chunk + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "in.parquet", n_rows=3) + out = d / "out.parquet" + project_chunk(d / "in.parquet", out, BasisSpec()) + t = pq.read_table(out) + assert len(t) == 3 + + def test_required_columns_present(self): + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_chunk + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "in.parquet", n_rows=2) + out = d / "out.parquet" + project_chunk(d / "in.parquet", out, BasisSpec()) + t = pq.read_table(out) + required = { + "row_index", + "material_id", + "n_atoms", + "atomic_numbers", + "lattice_vectors", + "n_electrons", + "grid_shape", + "coefficients", + "basis_set_NMAPE", + } + missing = required - set(t.column_names) + assert not missing, f"missing required columns: {missing}" + + def test_coefficient_shape_per_row(self): + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_chunk + + spec = BasisSpec() + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "in.parquet", n_rows=2) + out = d / "out.parquet" + project_chunk(d / "in.parquet", out, spec) + t = pq.read_table(out).to_pydict() + for c, n_atoms in zip(t["coefficients"], t["n_atoms"], strict=True): + # Each row has its own coefficient block; first dim is n_atoms, + # second is n_coeffs_per_atom. + arr = np.asarray(c) + assert arr.shape == (n_atoms, spec.n_coeffs_per_atom), ( + f"row coefficient shape mismatch: got {arr.shape}, " + f"expected ({n_atoms}, {spec.n_coeffs_per_atom})" + ) + + def test_basis_set_NMAPE_is_finite_and_nonnegative(self): + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_chunk + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "in.parquet", n_rows=3) + out = d / "out.parquet" + project_chunk(d / "in.parquet", out, BasisSpec()) + t = pq.read_table(out).to_pydict() + for x in t["basis_set_NMAPE"]: + assert np.isfinite(x) + assert x >= 0.0 + + def test_material_id_preserved(self): + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_chunk + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write_synthetic_chunk(d / "in.parquet", n_rows=3) + out = d / "out.parquet" + project_chunk(d / "in.parquet", out, BasisSpec()) + t = pq.read_table(out).to_pydict() + assert t["material_id"] == ["mat_000", "mat_001", "mat_002"] + + def test_handles_null_charge_density_rows(self): + """Real LeMat-Rho chunks have some rows with NULL density (failed + DFT extraction). Those should be skipped, not crash the projection. + """ + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_chunk + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + grids = [ + json.dumps(np.ones((10, 10, 10)).tolist()), + None, # null density - should be skipped + json.dumps(np.ones((10, 10, 10)).tolist()), + ] + table = pa.table( + { + "compressed_charge_density": pa.array(grids, type=pa.string()), + "species_at_sites": pa.array([["Fe"]] * 3), + "cartesian_site_positions": pa.array([[[2.0, 2.0, 2.0]]] * 3), + "lattice_vectors": pa.array( + [[[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]]] * 3 + ), + "material_id": pa.array(["a", "b", "c"]), + } + ) + pq.write_table(table, d / "in.parquet") + out = d / "out.parquet" + project_chunk(d / "in.parquet", out, BasisSpec()) + t = pq.read_table(out).to_pydict() + assert len(t["row_index"]) == 2 + assert t["row_index"] == [0, 2] + + +class TestProjectDirectory: + """Driver that runs project_chunk over every chunk_*.parquet in a dir.""" + + def test_processes_all_chunks(self): + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_directory + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + in_d = d / "in" + in_d.mkdir() + out_d = d / "out" + for i in range(3): + _write_synthetic_chunk(in_d / f"chunk_{i:06d}.parquet", n_rows=2) + project_directory(in_d, out_d, BasisSpec()) + outputs = sorted(out_d.glob("chunk_*.parquet")) + assert len(outputs) == 3 + for out in outputs: + assert pq.read_table(out).num_rows == 2 + + def test_skips_existing_outputs(self): + """Idempotent: a re-run does not re-project chunks that already exist. + + Lets us resume a partially-completed projection job after an + interruption without paying the LSQR cost again. + """ + from salted_ft.basis import BasisSpec + from salted_ft.project_dataset import project_directory + + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + in_d = d / "in" + in_d.mkdir() + out_d = d / "out" + _write_synthetic_chunk(in_d / "chunk_000000.parquet", n_rows=2) + # First run + project_directory(in_d, out_d, BasisSpec()) + first_mtime = (out_d / "chunk_000000.parquet").stat().st_mtime + # Second run should be a no-op + project_directory(in_d, out_d, BasisSpec()) + second_mtime = (out_d / "chunk_000000.parquet").stat().st_mtime + assert first_mtime == second_mtime diff --git a/tests/test_salted_projection.py b/tests/test_salted_projection.py new file mode 100644 index 0000000..fc79801 --- /dev/null +++ b/tests/test_salted_projection.py @@ -0,0 +1,225 @@ +"""TDD tests for VASP CHGCAR <-> SALTED basis projection / reconstruction. + +These two operations are the DIY bridge layer between VASP plane-wave +densities and the rholearn/SALTED localized-basis world (see the +``plan_salted_graph2mat_basis_choice_may_20_pm.md`` memo for context). + +Locked contracts here: + +* ``project_chgcar_to_basis(density, atoms, basis_spec)`` + -> ``np.ndarray (n_atoms, n_coeffs_per_atom)`` float64. + Zero density gives zero coefficients. Linear in the input density. + +* ``reconstruct_grid_from_basis(coefficients, atoms, grid_shape, basis_spec)`` + -> ``np.ndarray (Nx, Ny, Nz)`` float64. + Zero coefficients give a zero grid. Linear in the coefficients. + A single-atom, l=0, n=0 unit coefficient produces a Gaussian peaked + at the atom position. + +The roundtrip is intentionally NOT pinned to high accuracy in this PR. +A simple orthonormal-approximation projection is enough to land the +contract; a future PR will swap in least-squares solving against the +full basis overlap matrix for tight roundtrip accuracy. +""" + +from __future__ import annotations + +import ase +import numpy as np + + +# --------------------------------------------------------------------------- +# Helpers — small synthetic structures so tests stay fast and inspectable. +# --------------------------------------------------------------------------- +def _cubic_atoms(symbols=("Fe",), fractional=((0.0, 0.0, 0.0),), a=4.0): + """Single-cell ase.Atoms with the requested species/positions in fractional coords.""" + cell = np.eye(3) * a + cart = np.array(fractional) @ cell + return ase.Atoms(symbols=list(symbols), positions=cart, cell=cell, pbc=True) + + +def _zero_grid(shape=(8, 8, 8)) -> np.ndarray: + return np.zeros(shape, dtype=np.float32) + + +def _random_grid(shape=(8, 8, 8), seed: int = 0) -> np.ndarray: + rng = np.random.default_rng(seed) + return rng.random(shape, dtype=np.float32) + + +# --------------------------------------------------------------------------- +# Projection: density grid -> coefficients +# --------------------------------------------------------------------------- +class TestProjectChgcarToBasis: + def test_output_shape_is_n_atoms_by_n_coeffs(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import project_chgcar_to_basis + + spec = BasisSpec() + atoms = _cubic_atoms( + symbols=("Fe", "Fe"), fractional=((0.0, 0.0, 0.0), (0.5, 0.5, 0.5)) + ) + coeffs = project_chgcar_to_basis(_zero_grid(), atoms, spec) + assert coeffs.shape == (2, spec.n_coeffs_per_atom) + + def test_zero_density_gives_zero_coefficients(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import project_chgcar_to_basis + + coeffs = project_chgcar_to_basis(_zero_grid(), _cubic_atoms(), BasisSpec()) + np.testing.assert_array_equal(coeffs, 0.0) + + def test_output_dtype_is_float64(self): + """float64 because we'll feed these to scipy/least-squares downstream.""" + from salted_ft.basis import BasisSpec + from salted_ft.projection import project_chgcar_to_basis + + coeffs = project_chgcar_to_basis(_random_grid(), _cubic_atoms(), BasisSpec()) + assert coeffs.dtype == np.float64 + + def test_linearity_in_density(self): + """project(alpha * rho) == alpha * project(rho); a basic sanity check + since both projection and reconstruction must be linear maps. + """ + from salted_ft.basis import BasisSpec + from salted_ft.projection import project_chgcar_to_basis + + atoms = _cubic_atoms() + spec = BasisSpec() + rho = _random_grid(seed=1) + c1 = project_chgcar_to_basis(rho, atoms, spec) + c_scaled = project_chgcar_to_basis(2.5 * rho, atoms, spec) + np.testing.assert_allclose(c_scaled, 2.5 * c1, rtol=1e-5, atol=1e-8) + + def test_additivity_in_density(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import project_chgcar_to_basis + + atoms = _cubic_atoms() + spec = BasisSpec() + rho1 = _random_grid(seed=2) + rho2 = _random_grid(seed=3) + c1 = project_chgcar_to_basis(rho1, atoms, spec) + c2 = project_chgcar_to_basis(rho2, atoms, spec) + c_sum = project_chgcar_to_basis(rho1 + rho2, atoms, spec) + np.testing.assert_allclose(c_sum, c1 + c2, rtol=1e-5, atol=1e-8) + + def test_output_is_finite(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import project_chgcar_to_basis + + coeffs = project_chgcar_to_basis(_random_grid(), _cubic_atoms(), BasisSpec()) + assert np.isfinite(coeffs).all() + + +# --------------------------------------------------------------------------- +# Reconstruction: coefficients -> density grid +# --------------------------------------------------------------------------- +class TestReconstructGridFromBasis: + def test_output_shape_matches_grid_shape(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import reconstruct_grid_from_basis + + spec = BasisSpec() + atoms = _cubic_atoms() + coeffs = np.zeros((1, spec.n_coeffs_per_atom)) + grid = reconstruct_grid_from_basis(coeffs, atoms, (8, 8, 8), spec) + assert grid.shape == (8, 8, 8) + + def test_zero_coefficients_give_zero_grid(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import reconstruct_grid_from_basis + + spec = BasisSpec() + atoms = _cubic_atoms() + coeffs = np.zeros((1, spec.n_coeffs_per_atom)) + grid = reconstruct_grid_from_basis(coeffs, atoms, (8, 8, 8), spec) + np.testing.assert_array_equal(grid, 0.0) + + def test_output_dtype_is_float64(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import reconstruct_grid_from_basis + + spec = BasisSpec() + atoms = _cubic_atoms() + rng = np.random.default_rng(4) + coeffs = rng.standard_normal((1, spec.n_coeffs_per_atom)) + grid = reconstruct_grid_from_basis(coeffs, atoms, (8, 8, 8), spec) + assert grid.dtype == np.float64 + + def test_linearity_in_coefficients(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import reconstruct_grid_from_basis + + spec = BasisSpec() + atoms = _cubic_atoms() + rng = np.random.default_rng(5) + c = rng.standard_normal((1, spec.n_coeffs_per_atom)) + g1 = reconstruct_grid_from_basis(c, atoms, (8, 8, 8), spec) + g_scaled = reconstruct_grid_from_basis(3.0 * c, atoms, (8, 8, 8), spec) + np.testing.assert_allclose(g_scaled, 3.0 * g1, rtol=1e-5, atol=1e-8) + + def test_single_atom_l0_n0_peaks_at_atom_position(self): + """Unit s-coefficient on the first radial channel: density should peak + at the atom position (not somewhere else in the cell).""" + from salted_ft.basis import BasisSpec + from salted_ft.projection import reconstruct_grid_from_basis + + spec = BasisSpec() + # Atom at the (0.5, 0.5, 0.5) interior point, away from cell edges. + atoms = _cubic_atoms(fractional=((0.5, 0.5, 0.5),), a=4.0) + coeffs = np.zeros((1, spec.n_coeffs_per_atom)) + coeffs[0, 0] = 1.0 # l=0, m=0, n=0 (the most localized s channel) + grid = reconstruct_grid_from_basis(coeffs, atoms, (16, 16, 16), spec) + + # Peak index in (i, j, k) integer grid should be near the center. + peak_idx = np.unravel_index(np.argmax(grid), grid.shape) + center = (8, 8, 8) # fractional 0.5 on a 16-point grid + for actual, expected in zip(peak_idx, center, strict=True): + assert abs(actual - expected) <= 1, ( + f"density peak {peak_idx} is far from atom (expected near {center}); " + "either the atom-position lookup or the basis evaluation is wrong" + ) + + def test_output_is_finite(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import reconstruct_grid_from_basis + + spec = BasisSpec() + atoms = _cubic_atoms() + rng = np.random.default_rng(6) + coeffs = rng.standard_normal((1, spec.n_coeffs_per_atom)) + grid = reconstruct_grid_from_basis(coeffs, atoms, (8, 8, 8), spec) + assert np.isfinite(grid).all() + + +# --------------------------------------------------------------------------- +# Roundtrip: project then reconstruct (and vice versa). +# --------------------------------------------------------------------------- +class TestProjectionReconstructionRoundtrip: + def test_roundtrip_of_zero_density_is_zero(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import ( + project_chgcar_to_basis, + reconstruct_grid_from_basis, + ) + + atoms = _cubic_atoms() + spec = BasisSpec() + coeffs = project_chgcar_to_basis(_zero_grid(), atoms, spec) + roundtrip = reconstruct_grid_from_basis(coeffs, atoms, (8, 8, 8), spec) + np.testing.assert_array_equal(roundtrip, 0.0) + + def test_roundtrip_of_zero_coefficients_is_zero(self): + from salted_ft.basis import BasisSpec + from salted_ft.projection import ( + project_chgcar_to_basis, + reconstruct_grid_from_basis, + ) + + atoms = _cubic_atoms() + spec = BasisSpec() + c = np.zeros((1, spec.n_coeffs_per_atom)) + grid = reconstruct_grid_from_basis(c, atoms, (8, 8, 8), spec) + c_back = project_chgcar_to_basis(grid, atoms, spec) + np.testing.assert_array_equal(c_back, 0.0) diff --git a/tests/test_salted_rholearn_adapter.py b/tests/test_salted_rholearn_adapter.py new file mode 100644 index 0000000..3eb6570 --- /dev/null +++ b/tests/test_salted_rholearn_adapter.py @@ -0,0 +1,234 @@ +"""TDD tests for the SALTED -> rholearn data adapter (Phase D3). + +rholearn's training loop consumes basis-coefficient vectors in a +specific flat layout (see ``rholearn/utils/convert.py::_get_flat_index``): + + atom (outer) -> o3_lambda -> n (radial, INNER to lambda) -> o3_mu (innermost) + +Our ``salted_ft.projection`` layout differs: + + atom (outer) -> n (radial, OUTER to lambda) -> (lambda, mu) packed + +The adapter functions tested here move between the two layouts and +produce the ``lmax`` / ``nmax`` dicts rholearn's metatensor converter +needs to know the basis shape. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# rholearn's lmax / nmax dict format (from rholearn/utils/convert.py docstrings) +# +# lmax = {"H": 1, "C": 2} per-species max lambda +# nmax = {("H", 0): 2, ("H", 1): 3, ("C", 0): 4, ...} per-species per-lambda n +# +# Our uniform BasisSpec has max_l + n_radial constant across species. The +# adapter expands that into rholearn's per-species dicts so the same basis +# spec works for arbitrary species sets. +# --------------------------------------------------------------------------- + + +class TestBuildLmaxNmaxDicts: + """Convert our uniform BasisSpec into rholearn's per-species dicts.""" + + def test_lmax_contains_every_species(self): + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import build_lmax_nmax + + lmax, _nmax = build_lmax_nmax(BasisSpec(), species=("H", "O", "Fe")) + assert set(lmax) == {"H", "O", "Fe"} + + def test_lmax_value_matches_basis_spec(self): + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import build_lmax_nmax + + spec = BasisSpec() + lmax, _ = build_lmax_nmax(spec, species=("Fe",)) + assert lmax["Fe"] == spec.max_l + + def test_nmax_keyed_by_species_and_lambda(self): + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import build_lmax_nmax + + spec = BasisSpec() + _, nmax = build_lmax_nmax(spec, species=("H", "Fe")) + # Both species share the same n_radial at every lambda + for s in ("H", "Fe"): + for lam in range(spec.max_l + 1): + assert nmax[(s, lam)] == spec.n_radial, ( + f"nmax[({s!r}, {lam})] must be {spec.n_radial}, " + f"got {nmax[(s, lam)]}" + ) + + def test_total_per_atom_coefficients_match(self): + """Sum of ``(2*l + 1) * nmax[(s, l)]`` across l must equal + ``BasisSpec.n_coeffs_per_atom``. If this drifts the flat vector + produced by the adapter will be the wrong length. + """ + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import build_lmax_nmax + + spec = BasisSpec() + lmax, nmax = build_lmax_nmax(spec, species=("Fe",)) + total = sum((2 * lam + 1) * nmax[("Fe", lam)] for lam in range(lmax["Fe"] + 1)) + assert total == spec.n_coeffs_per_atom + + +# --------------------------------------------------------------------------- +# Reordering: our (atom, n_outer, lm_packed) <-> rholearn (atom, l, n, mu). +# Pure ndarray math, no metatensor required. +# --------------------------------------------------------------------------- + + +class TestDenseToRholearnFlat: + """``dense_to_rholearn_flat(coeffs, basis_spec, symbols) -> np.ndarray``.""" + + def test_output_length_matches_total_basis(self): + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import dense_to_rholearn_flat + + spec = BasisSpec() + atoms = ("Fe", "Fe") + coeffs = np.zeros((2, spec.n_coeffs_per_atom)) + flat = dense_to_rholearn_flat(coeffs, spec, atoms) + assert flat.shape == (2 * spec.n_coeffs_per_atom,) + + def test_zero_in_gives_zero_out(self): + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import dense_to_rholearn_flat + + spec = BasisSpec() + flat = dense_to_rholearn_flat( + np.zeros((1, spec.n_coeffs_per_atom)), spec, ("Fe",) + ) + np.testing.assert_array_equal(flat, 0.0) + + def test_dtype_preserved(self): + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import dense_to_rholearn_flat + + spec = BasisSpec() + rng = np.random.default_rng(0) + coeffs = rng.standard_normal((1, spec.n_coeffs_per_atom)).astype(np.float64) + flat = dense_to_rholearn_flat(coeffs, spec, ("Fe",)) + assert flat.dtype == np.float64 + + def test_concatenates_atoms_in_order(self): + """Per-atom blocks must appear in input order (atom 0 first, then 1, ...).""" + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import dense_to_rholearn_flat + + spec = BasisSpec() + # Use distinguishable per-atom values + coeffs = np.zeros((2, spec.n_coeffs_per_atom)) + coeffs[0, :] = 1.0 + coeffs[1, :] = 2.0 + flat = dense_to_rholearn_flat(coeffs, spec, ("Fe", "Fe")) + per_atom = spec.n_coeffs_per_atom + assert np.allclose(flat[:per_atom], 1.0) + assert np.allclose(flat[per_atom:], 2.0) + + +class TestRoundtrip: + """dense -> rholearn-flat -> dense must be exactly the identity.""" + + def test_roundtrip_random_single_atom(self): + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import ( + dense_to_rholearn_flat, + rholearn_flat_to_dense, + ) + + spec = BasisSpec() + rng = np.random.default_rng(1) + coeffs = rng.standard_normal((1, spec.n_coeffs_per_atom)) + flat = dense_to_rholearn_flat(coeffs, spec, ("Fe",)) + restored = rholearn_flat_to_dense(flat, spec, ("Fe",)) + np.testing.assert_array_equal(restored, coeffs) + + def test_roundtrip_random_multi_atom(self): + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import ( + dense_to_rholearn_flat, + rholearn_flat_to_dense, + ) + + spec = BasisSpec() + rng = np.random.default_rng(2) + symbols = ("Fe", "O", "Fe", "H") + coeffs = rng.standard_normal((len(symbols), spec.n_coeffs_per_atom)) + flat = dense_to_rholearn_flat(coeffs, spec, symbols) + restored = rholearn_flat_to_dense(flat, spec, symbols) + np.testing.assert_array_equal(restored, coeffs) + + def test_permutation_is_actually_nontrivial(self): + """The reordering must MOVE values around -- if dense -> flat were + the identity that would mean we'd silently fed misordered data to + rholearn. Pinning this catches a future 'simplification' that + accidentally drops the permutation. + """ + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import dense_to_rholearn_flat + + spec = BasisSpec() + # Distinguishable per-channel values via arange + coeffs = np.arange(spec.n_coeffs_per_atom, dtype=np.float64).reshape( + 1, spec.n_coeffs_per_atom + ) + flat = dense_to_rholearn_flat(coeffs, spec, ("Fe",)) + # rholearn's ordering is atom -> lambda -> n -> mu; ours is + # atom -> n -> lambda -> mu. So flat[0] is c[atom=0, lambda=0, n=0, mu=0] + # which in OUR layout is at position [n=0, lm=0] = 0. So flat[0] == 0. + # But flat[1] is c[atom=0, lambda=1, n=0, mu=-1] which in OUR layout + # is at [n=0, lm=1] = 1. flat[1] == 1. + # The DIFFERENT ordering kicks in for flat[3]: rholearn says lambda=1 + # n=1 mu=-1, which in ours is at [n=1, lm=1] = 25, not 3. + # So flat[3] != coeffs[0, 3] is the load-bearing check. + assert flat[3] != coeffs[0, 3], ( + "ordering is trivial; the reordering should move values around" + ) + + +# --------------------------------------------------------------------------- +# Smoke test for the full TensorMap path. Heavier dependency on metatensor +# but the test is short. +# --------------------------------------------------------------------------- + + +class TestDenseToTensorMap: + """``dense_to_tensormap`` returns a metatensor TensorMap with the right keys. + + Requires the rholearn sibling repo at ``../rholearn/`` (auto-skips + when absent). On Adastra (where rholearn IS installed) this test + activates and exercises the full conversion path. + """ + + def test_tensormap_has_o3_lambda_center_type_keys(self): + pytest.importorskip("metatensor") + pytest.importorskip("chemfiles") + + from pathlib import Path + + if not (Path(__file__).resolve().parent.parent.parent / "rholearn").exists(): + pytest.skip("rholearn sibling repo not present; skipping live conversion") + + from salted_ft.basis import BasisSpec + from salted_ft.rholearn_adapter import dense_to_tensormap + + spec = BasisSpec() + positions = np.array([[0.0, 0.0, 0.0], [2.0, 2.0, 2.0]]) + cell = np.eye(3) * 4.0 + symbols = ("Fe", "Fe") + rng = np.random.default_rng(3) + coeffs = rng.standard_normal((2, spec.n_coeffs_per_atom)) + tmap = dense_to_tensormap( + coeffs, spec, symbols, positions, cell, structure_idx=0 + ) + # Keys must contain ``o3_lambda`` and ``center_type`` per rholearn's + # convention (see rholearn/utils/convert.py docstrings). + names = list(tmap.keys.names) + assert "o3_lambda" in names + assert "center_type" in names diff --git a/tests/test_scf_speedup_run.py b/tests/test_scf_speedup_run.py new file mode 100644 index 0000000..1ac3e1d --- /dev/null +++ b/tests/test_scf_speedup_run.py @@ -0,0 +1,596 @@ +"""TDD tests for ``scripts/scf_speedup_run.py`` (P4). + +The driver loops a held-out test parquet, predicts each row's +density via the chosen ML arm, writes a CHGCAR with the right +electron-count rescaling, and submits a paired baseline + predicted +VASP Flow via ``entalsim.dft.scf_speedup.make_scf_speedup_pair`` + +``entalsim.core.submit.submit_workflow``. + +Tests use dependency injection (``make_pair_fn`` and ``submit_fn``) +so they pass locally without entalsim installed. The real CLI +imports entalsim's functions at runtime. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + + +@pytest.fixture +def run_module(): + scripts_dir = Path(__file__).resolve().parent.parent / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + if "scf_speedup_run" in sys.modules: + del sys.modules["scf_speedup_run"] + return importlib.import_module("scf_speedup_run") + + +def _toy_parquet(tmp_path: Path, n_rows: int = 2) -> Path: + """Synthesise a held-out-split-shaped parquet. + + Columns mirror what the held-out split builder will emit: + material_id, atomic_numbers, positions (flat), lattice_vectors + (flat 9), grid_shape, n_electrons. + """ + rows = [] + grid_shape = (4, 4, 4) + for i in range(n_rows): + n_atoms = 2 + rows.append( + { + "material_id": f"mp-toy-{i}", + "n_atoms": n_atoms, + "atomic_numbers": np.array([1, 1], dtype=np.int64), + "positions": np.array( + [[0.0, 0.0, 0.0], [0.74 + 0.01 * i, 0.0, 0.0]], + dtype=np.float64, + ).reshape(-1), + "lattice_vectors": (np.eye(3) * 5.0).reshape(-1), + "grid_shape": np.array(grid_shape, dtype=np.int64), + "n_electrons": 2.0, + } + ) + out = tmp_path / "held_out.parquet" + pd.DataFrame(rows).to_parquet(out) + return out + + +def _fake_flow(n_jobs: int = 2): + return SimpleNamespace( + jobs=[SimpleNamespace(uuid=f"j{i}") for i in range(n_jobs)], + name="fake_flow", + ) + + +def _make_pair_mock(captured: list): + """Returns a (mock, captured) pair. ``captured`` records each call.""" + + def make_pair(structure, predicted_chgcar_dir, metadata): + captured.append( + { + "structure_formula": structure.composition.reduced_formula, + "predicted_chgcar_dir": str(predicted_chgcar_dir), + "metadata": dict(metadata), + "chgcar_exists": (Path(predicted_chgcar_dir) / "CHGCAR").exists(), + } + ) + return _fake_flow() + + return make_pair + + +def _submit_mock(captured: list): + def submit(flow, project, worker): + captured.append( + {"project": project, "worker": worker, "n_jobs": len(flow.jobs)} + ) + + return submit + + +class TestDriverBasics: + def test_dry_run_writes_one_chgcar_per_row(self, tmp_path, run_module): + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=2) + chgcar_dir = tmp_path / "chgcars" + make_calls: list = [] + submit_calls: list = [] + + records = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="test_project", + worker="test_worker", + dry_run=True, + make_pair_fn=_make_pair_mock(make_calls), + submit_fn=_submit_mock(submit_calls), + ) + assert len(records) == 2 + for r in records: + assert Path(r["chgcar_path"]).exists() + assert submit_calls == [], "dry_run=True must not submit" + + def test_make_pair_invoked_with_metadata(self, tmp_path, run_module): + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=2) + chgcar_dir = tmp_path / "chgcars" + make_calls: list = [] + + run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="test_project", + worker="test_worker", + dry_run=True, + make_pair_fn=_make_pair_mock(make_calls), + submit_fn=_submit_mock([]), + ) + assert len(make_calls) == 2 + for call in make_calls: + md = call["metadata"] + assert md["experiment"] == "scf_speedup" + assert md["model"] == "salted" + assert md["material_id"].startswith("mp-toy-") + assert call["chgcar_exists"], ( + "make_scf_speedup_pair must see a real CHGCAR file at the path " + "we hand it; otherwise its FileNotFoundError fires on every row" + ) + + def test_limit_caps_rows_processed(self, tmp_path, run_module): + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=5) + chgcar_dir = tmp_path / "chgcars" + make_calls: list = [] + + records = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="p", + worker="w", + limit=2, + dry_run=True, + make_pair_fn=_make_pair_mock(make_calls), + submit_fn=_submit_mock([]), + ) + assert len(records) == 2 + assert len(make_calls) == 2 + + +class TestSubmitWiring: + def test_non_dry_run_calls_submit_per_row(self, tmp_path, run_module): + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=2) + chgcar_dir = tmp_path / "chgcars" + submit_calls: list = [] + + run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="jz_scf_speedup", + worker="jean_zay_cpu", + dry_run=False, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock(submit_calls), + ) + assert len(submit_calls) == 2 + for call in submit_calls: + assert call["project"] == "jz_scf_speedup" + assert call["worker"] == "jean_zay_cpu" + assert call["n_jobs"] == 2 + + def test_records_include_submitted_flag(self, tmp_path, run_module): + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=1) + chgcar_dir = tmp_path / "chgcars" + + dry = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + wet = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=tmp_path / "chgcars_wet", + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=False, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + assert dry[0]["submitted"] is False + assert wet[0]["submitted"] is True + + +class TestArmCheckpointGuard: + def test_charge3net_without_ckpt_fails_fast(self, tmp_path, run_module): + """ChargE3Net and DeepDFT without a checkpoint run as random-init + models. Their predictions would be meaningless, and we would + silently waste HPC time. The driver must refuse before any + prediction or submit. + """ + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=1) + with pytest.raises(ValueError, match="ckpt"): + run_module.run_experiment( + model_name="charge3net", + test_parquet=in_parquet, + chgcar_dir=tmp_path / "c", + basis_spec=BasisSpec(), + project="p", + worker="w", + ckpt=None, + dry_run=True, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + + def test_deepdft_without_ckpt_fails_fast(self, tmp_path, run_module): + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=1) + with pytest.raises(ValueError, match="ckpt"): + run_module.run_experiment( + model_name="deepdft", + test_parquet=in_parquet, + chgcar_dir=tmp_path / "c", + basis_spec=BasisSpec(), + project="p", + worker="w", + ckpt=None, + dry_run=True, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + + def test_salted_without_ckpt_uses_stub(self, tmp_path, run_module): + """SALTED stub mode is the documented fallback. The driver must + let it through so we can dry-run the pipeline before D6 trained + weights are available.""" + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=1) + records = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=tmp_path / "c", + basis_spec=BasisSpec(), + project="p", + worker="w", + ckpt=None, + dry_run=True, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + assert records[0]["ckpt"] == "stub" + + +class TestPerRowResilience: + """A multi-hour batch must not die on a single bad row.""" + + def test_per_row_failure_does_not_abort_loop(self, tmp_path, run_module): + """If row 2 of 3 has a corrupt cell (positions with wrong + length) the loop must skip it, record the failure, and keep + going. Otherwise the prior rows' Flows are submitted to + Mongo with no clean resume path.""" + from salted_ft.basis import BasisSpec + + # 3 rows, middle one has corrupt positions. + rows = [] + grid_shape = (4, 4, 4) + good_positions = np.array( + [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]], dtype=np.float64 + ).reshape(-1) + for i in range(3): + pos = good_positions + if i == 1: + # Length 2: positions.reshape(-1, 3) raises. + pos = np.array([0.0, 0.0], dtype=np.float64) + rows.append( + { + "material_id": f"mp-toy-{i}", + "n_atoms": 2, + "atomic_numbers": np.array([1, 1], dtype=np.int64), + "positions": pos, + "lattice_vectors": (np.eye(3) * 5.0).reshape(-1), + "grid_shape": np.array(grid_shape, dtype=np.int64), + "n_electrons": 2.0, + } + ) + in_parquet = tmp_path / "held_out_with_bad_row.parquet" + pd.DataFrame(rows).to_parquet(in_parquet) + + records = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=tmp_path / "chgcars", + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + assert len(records) == 3 + good = [r for r in records if r.get("error") is None] + bad = [r for r in records if r.get("error") is not None] + assert len(good) == 2 + assert len(bad) == 1 + assert bad[0]["material_id"] == "mp-toy-1" + assert bad[0]["submitted"] is False + assert "reshape" in bad[0]["error"] or "cannot" in bad[0]["error"] + + +class TestManifest: + def test_manifest_jsonl_written_after_each_row(self, tmp_path, run_module): + """The manifest must be written incrementally so an + interrupted run leaves a resumable record. After all rows + complete the manifest should have one JSONL line per row.""" + import json + + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=3) + chgcar_dir = tmp_path / "chgcars" + manifest = tmp_path / "manifest.jsonl" + + run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + manifest_path=manifest, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + assert manifest.exists() + lines = manifest.read_text().splitlines() + assert len(lines) == 3 + for line in lines: + rec = json.loads(line) + assert "material_id" in rec + assert "model" in rec + + def test_manifest_defaults_to_chgcar_dir(self, tmp_path, run_module): + """If --manifest is not given, default to + chgcar_dir/manifest.jsonl so a re-run can find it by + convention.""" + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=1) + chgcar_dir = tmp_path / "chgcars" + + run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + assert (chgcar_dir / "manifest.jsonl").exists() + + +class TestSkipExisting: + def test_skip_existing_skips_already_submitted_rows(self, tmp_path, run_module): + """Pre-populate a manifest with one submitted row, then + re-run with skip_existing=True; only the unseen rows should + be processed.""" + import json + + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=3) + chgcar_dir = tmp_path / "chgcars" + chgcar_dir.mkdir() + manifest = chgcar_dir / "manifest.jsonl" + # Mark mp-toy-1 as already done. + manifest.write_text( + json.dumps( + { + "material_id": "mp-toy-1", + "model": "salted", + "submitted": True, + "error": None, + } + ) + + "\n" + ) + make_calls: list = [] + records = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + skip_existing=True, + manifest_path=manifest, + make_pair_fn=_make_pair_mock(make_calls), + submit_fn=_submit_mock([]), + ) + # mp-toy-1 should NOT have been re-processed. + processed_ids = {call["metadata"]["material_id"] for call in make_calls} + assert "mp-toy-1" not in processed_ids + assert processed_ids == {"mp-toy-0", "mp-toy-2"} + # Records reflect what THIS run did, not the historical entry. + assert len(records) == 2 + + def test_skip_existing_does_not_skip_failed_rows(self, tmp_path, run_module): + """A row in the manifest with submitted=False (error from a + previous run) should be retried on the next run, not skipped.""" + import json + + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=2) + chgcar_dir = tmp_path / "chgcars" + chgcar_dir.mkdir() + manifest = chgcar_dir / "manifest.jsonl" + manifest.write_text( + json.dumps( + { + "material_id": "mp-toy-0", + "model": "salted", + "submitted": False, + "error": "previous_run_died", + } + ) + + "\n" + ) + make_calls: list = [] + run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + skip_existing=True, + manifest_path=manifest, + make_pair_fn=_make_pair_mock(make_calls), + submit_fn=_submit_mock([]), + ) + processed_ids = {call["metadata"]["material_id"] for call in make_calls} + # mp-toy-0 was previously failed, should be retried. + assert "mp-toy-0" in processed_ids + + +class TestChgcarOrganisation: + def test_per_row_chgcar_dirs_are_unique(self, tmp_path, run_module): + """make_scf_speedup_pair takes a directory and stages CHGCAR + from it. Multiple rows must NOT share one directory or the + last write wins.""" + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=3) + chgcar_dir = tmp_path / "chgcars" + make_calls: list = [] + + records = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + make_pair_fn=_make_pair_mock(make_calls), + submit_fn=_submit_mock([]), + ) + seen = {Path(call["predicted_chgcar_dir"]).resolve() for call in make_calls} + assert len(seen) == len(records) == 3 + + def test_chgcar_layout_is_nested_by_model_then_material_id( + self, tmp_path, run_module + ): + """Layout must be ``chgcar_root///CHGCAR`` + so a material_id containing separator characters never causes + ambiguity. Was previously a flat ``{model}__{material_id}/`` + which broke on synthesised IDs like ``oqmd__1234``.""" + from salted_ft.basis import BasisSpec + + in_parquet = _toy_parquet(tmp_path, n_rows=1) + chgcar_dir = tmp_path / "chgcars" + + records = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=chgcar_dir, + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + make_pair_fn=_make_pair_mock([]), + submit_fn=_submit_mock([]), + ) + chgcar_path = Path(records[0]["chgcar_path"]) + # Path tail must be ...///CHGCAR + parts = chgcar_path.parts + assert parts[-1] == "CHGCAR" + assert parts[-2] == "mp-toy-0" + assert parts[-3] == "salted" + + +class TestRealisticRow: + """Catch mutation-killers a 2-atom H2 toy row misses: a missing + n_electrons rescale, a positions-reshape bug, or a grid/atom + mismatch all pass silently on the degenerate fixture.""" + + def test_5_atom_asymmetric_grid_unequal_n_electrons(self, tmp_path, run_module): + from salted_ft.basis import BasisSpec + + # 5 atoms: 1 Fe + 4 O (chosen so sum(Z)=26+4*8=58 != n_electrons=12.5). + # Asymmetric grid_shape catches axes-swap bugs. + n_atoms = 5 + atomic_numbers = np.array([26, 8, 8, 8, 8], dtype=np.int64) + rng = np.random.default_rng(0) + positions = rng.uniform(0, 5, size=(n_atoms, 3)).astype(np.float64) + rows = [ + { + "material_id": "mp-realistic-0", + "n_atoms": n_atoms, + "atomic_numbers": atomic_numbers, + "positions": positions.reshape(-1), + "lattice_vectors": (np.eye(3) * 5.0).reshape(-1), + "grid_shape": np.array([8, 10, 12], dtype=np.int64), + "n_electrons": 12.5, + } + ] + in_parquet = tmp_path / "realistic.parquet" + pd.DataFrame(rows).to_parquet(in_parquet) + + make_calls: list = [] + records = run_module.run_experiment( + model_name="salted", + test_parquet=in_parquet, + chgcar_dir=tmp_path / "chgcars", + basis_spec=BasisSpec(), + project="p", + worker="w", + dry_run=True, + make_pair_fn=_make_pair_mock(make_calls), + submit_fn=_submit_mock([]), + ) + # The row completed without error -- reshape correct, write_chgcar + # accepted asymmetric grid, n_electrons propagated to write_chgcar. + assert len(records) == 1 + assert records[0]["error"] is None, f"unexpected error: {records[0]['error']}" + assert records[0]["submitted"] is False # dry-run diff --git a/tests/test_submit_script.py b/tests/test_submit_script.py new file mode 100644 index 0000000..dcaa250 --- /dev/null +++ b/tests/test_submit_script.py @@ -0,0 +1,183 @@ +"""TDD tests for the parameterized Adastra submit script. + +The script `submit_charge3net_adastra.sh` is now configurable via two env +vars: + + LEMATRHO_TRAINING_MODE "pretrained" (default) or "from_scratch" + LEMATRHO_DRY_RUN "1" prints the resolved train command and exits + +These tests pin the contract. + +They don't depend on Adastra. The script is sourced under bash with +LEMATRHO_DRY_RUN=1 so the venv activate / rocm-smi / srun calls are +skipped and the train invocation is printed instead of executed. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +SUBMIT_SCRIPT = Path(__file__).resolve().parent.parent / "submit_charge3net_adastra.sh" + + +def _run(env_extra: dict) -> subprocess.CompletedProcess: + """Run the submit script under bash with LEMATRHO_DRY_RUN=1.""" + if shutil.which("bash") is None: + pytest.skip("bash not available in test environment") + env = { + **os.environ, + "LEMATRHO_DRY_RUN": "1", + # Avoid touching the user's real Adastra setup or W&B credentials. + "LEMATRHO_ADASTRA_SETUP": "/tmp/fake_setup_for_tests", + # SLURM env vars that the script would normally inherit. + "SLURM_NTASKS": "4", + "SLURM_NODELIST": "g0001", + "SLURM_JOB_ACCOUNT": "c1816212_mi250", + **env_extra, + } + return subprocess.run( + ["bash", str(SUBMIT_SCRIPT)], + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def test_dry_run_mode_prints_train_command(): + """LEMATRHO_DRY_RUN=1 must print the resolved train command and exit 0.""" + result = _run({}) + assert result.returncode == 0, ( + f"dry-run exited {result.returncode}; stderr={result.stderr}" + ) + assert "charge3net_ft.train" in result.stdout, ( + f"dry-run output missing the train invocation; stdout={result.stdout}" + ) + + +def test_default_mode_is_pretrained(): + """Unset LEMATRHO_TRAINING_MODE -> pretrained MP checkpoint path is used.""" + result = _run({}) + assert result.returncode == 0 + out = result.stdout + assert "--ckpt-path" in out, ( + f"default (pretrained) run must pass --ckpt-path; stdout={out}" + ) + assert "charge3net_mp.pt" in out, ( + f"default run must point --ckpt-path at the MP checkpoint; stdout={out}" + ) + + +def test_pretrained_mode_uses_default_save_dir(): + """Pretrained mode writes to charge3net_checkpoints/ (no fromscratch suffix).""" + result = _run({"LEMATRHO_TRAINING_MODE": "pretrained"}) + assert result.returncode == 0 + assert ( + "charge3net_checkpoints " in (result.stdout + " ") + or "charge3net_checkpoints\n" in result.stdout + or "/charge3net_checkpoints" in result.stdout + ) + assert "charge3net_checkpoints_fromscratch" not in result.stdout, ( + f"pretrained mode must NOT use the fromscratch save dir; stdout={result.stdout}" + ) + + +def test_from_scratch_mode_drops_ckpt_path(): + """LEMATRHO_TRAINING_MODE=from_scratch -> no --ckpt-path flag at all. + + Without --ckpt-path, ChargE3NetWrapper.__init__ initializes weights + fresh (no MP transfer). This is the comparison arm for the + pretrained vs from-scratch experiment. + """ + result = _run({"LEMATRHO_TRAINING_MODE": "from_scratch"}) + assert result.returncode == 0, ( + f"from_scratch run exited {result.returncode}; stderr={result.stderr}" + ) + out = result.stdout + assert "--ckpt-path" not in out, ( + f"from_scratch must not pass --ckpt-path; stdout={out}" + ) + # also confirm charge3net_mp.pt isn't referenced anywhere in the + # resolved command (defense against accidental partial passing) + assert "charge3net_mp.pt" not in out, ( + f"from_scratch must not reference the MP checkpoint; stdout={out}" + ) + + +def test_from_scratch_mode_uses_separate_save_dir(): + """From-scratch run writes to a different dir so checkpoints don't collide + with the pretrained run. + """ + result = _run({"LEMATRHO_TRAINING_MODE": "from_scratch"}) + assert result.returncode == 0 + out = result.stdout + assert "charge3net_checkpoints_fromscratch" in out, ( + f"from_scratch must write to charge3net_checkpoints_fromscratch/; stdout={out}" + ) + + +def test_from_scratch_mode_uses_distinct_wandb_name(): + """W&B run name differs between the two modes so the dashboard tells them apart.""" + # WANDB_NAME is what wandb reads at init time when no --name is passed. + pretrained = _run({"LEMATRHO_TRAINING_MODE": "pretrained"}).stdout + fromscratch = _run({"LEMATRHO_TRAINING_MODE": "from_scratch"}).stdout + # Both must mention WANDB_NAME or set it somehow. + assert "WANDB_NAME" in pretrained or "wandb-run-name" in pretrained, ( + f"pretrained mode must set the wandb run name; stdout={pretrained}" + ) + assert "WANDB_NAME" in fromscratch or "wandb-run-name" in fromscratch, ( + f"from_scratch mode must set the wandb run name; stdout={fromscratch}" + ) + + # And they must differ. + # Extract WANDB_NAME value from each (simple regex-free parsing). + def _wandb_name(blob: str) -> str: + for line in blob.splitlines(): + if "WANDB_NAME=" in line: + return line.split("WANDB_NAME=", 1)[1].split()[0].strip("'\"") + return "" + + p_name = _wandb_name(pretrained) + f_name = _wandb_name(fromscratch) + assert p_name and f_name and p_name != f_name, ( + f"WANDB_NAME must differ between modes; pretrained={p_name!r}, fromscratch={f_name!r}" + ) + + +def test_invalid_mode_exits_with_clear_error(): + """An unrecognized mode must fail fast with a helpful message.""" + result = _run({"LEMATRHO_TRAINING_MODE": "garbage"}) + assert result.returncode != 0, ( + f"invalid mode must exit non-zero; stdout={result.stdout} stderr={result.stderr}" + ) + combined = (result.stdout + " " + result.stderr).lower() + assert "garbage" in combined or "training_mode" in combined or "mode" in combined, ( + f"error message should mention the bad value or the env var; " + f"stdout={result.stdout} stderr={result.stderr}" + ) + + +def test_batch_size_and_val_probes_match_paper(): + """Regression test: per-GPU batch=16, val_probes=1000 match the upstream paper.""" + result = _run({}) + assert "--batch-size 16" in result.stdout, ( + f"per-GPU batch must be 16 (paper); stdout={result.stdout}" + ) + assert "--val-probes 1000" in result.stdout, ( + f"val_probes must be 1000 (paper); stdout={result.stdout}" + ) + + +def test_wandb_mode_is_offline(): + """W&B must default to offline; api.wandb.ai is unreachable from + Adastra compute nodes (caused job 4969727 to crash after 1h47m). + """ + result = _run({}) + assert "--wandb-mode offline" in result.stdout, ( + f"wandb-mode must default to offline; stdout={result.stdout}" + ) diff --git a/uv.lock b/uv.lock index 2f0e990..223c6bf 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,18 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform != 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and platform_machine != 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'", ] @@ -85,6 +93,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/47/b11d0089875a23bff0abd3edb5516bcd454db3fefab8604f5e4b07bd6210/aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706", size = 446735, upload-time = "2025-06-14T15:15:02.858Z" }, ] +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + [[package]] name = "aiosignal" version = "1.3.2" @@ -97,6 +114,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + [[package]] name = "appnope" version = "0.1.4" @@ -106,6 +147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, ] +[[package]] +name = "argcomplete" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/c0/c8e94135e66fabf89a120d9b4b123fe6993506beca6c1938a74c24cfa5fd/argcomplete-3.7.0.tar.gz", hash = "sha256:afde224f753f874807b1dc1414e883ab8fe0cda9c04807b6047dcb8e1ac23913", size = 73284, upload-time = "2026-06-30T22:28:22.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/f6/5b8ec087cd9cfa9449491ec83f76fb6b7006b4dff57d2ba8aaab330fe8e4/argcomplete-3.7.0-py3-none-any.whl", hash = "sha256:d8f0f22d2a8a7caa383be1e22b6caf1ecaf0ebd10d8f83cc125e36540c95830c", size = 42575, upload-time = "2026-06-30T22:28:20.547Z" }, +] + [[package]] name = "ase" version = "3.25.0" @@ -129,6 +179,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] +[[package]] +name = "atomate2" +version = "0.0.23" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "custodian" }, + { name = "emmet-core" }, + { name = "jobflow" }, + { name = "monty" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pymatgen" }, + { name = "pymongo" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/41/de55c6f2840e125b62903cc7a010ca303becd9a8ca446bb66d4c574e099d/atomate2-0.0.23.tar.gz", hash = "sha256:473294553fa5c9373a32cb536a18df72a6b6725d151c3851119fbf2e0543c2cf", size = 390240, upload-time = "2025-12-03T22:40:13.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/26/3cabcc7c7d7ba008f24af88ac9c4ae104ad64529a70d9c4dc58eb3d921d3/atomate2-0.0.23-py3-none-any.whl", hash = "sha256:716a81674497b96110ae9d70e60c2a0b9c8e38fda38b7e219f29227b9e724286", size = 502888, upload-time = "2025-12-03T22:40:11.822Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -157,6 +229,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/6f/845f028e3df8b5cea9720056156770bd5d96104ded8b1f31c58a0d1c1d34/average_minimum_distance-1.6.0-py3-none-any.whl", hash = "sha256:7e259af6b84a77914b1db8e5130793c3097c71462b4b4060bebb7bc765ec4ced", size = 61864, upload-time = "2025-06-17T19:41:27.365Z" }, ] +[[package]] +name = "basis-set-exchange" +version = "0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "jsonschema" }, + { name = "regex" }, + { name = "unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/bf/3b16c289f127b22a21bf5ef6740ef911329d4ba63dd40416f4ce8db37773/basis_set_exchange-0.12.tar.gz", hash = "sha256:bb26ef560cea0ac5631b66bfb46eb3accd5a2ac943738be5d7854c70cc8c2393", size = 36823294, upload-time = "2026-02-19T16:48:37.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/64/6e8f5d1bd239ea91c0e8658b77183249cfe5106c97142711d168dbd04376/basis_set_exchange-0.12-py3-none-any.whl", hash = "sha256:8d20ea9075fa100bff28f7562333c1fc75c9723215ba4d498e05e6fa8e1594a8", size = 40139376, upload-time = "2026-02-19T16:48:27.731Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + [[package]] name = "bibtexparser" version = "1.4.3" @@ -166,6 +323,126 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/92/8d/e296c7af03757debd8fc80df2898cbed4fb69fc61ed2c9b4a1d42e923a9e/bibtexparser-1.4.3.tar.gz", hash = "sha256:a9c7ded64bc137720e4df0b1b7f12734edc1361185f1c9097048ff7c35af2b8f", size = 55582, upload-time = "2024-12-19T20:41:57.754Z" } +[[package]] +name = "blake3" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/aa/abcd75e9600987a0bc6cfe9b6b2ff3f0e2cb08c170addc6e76035b5c4cb3/blake3-1.0.8.tar.gz", hash = "sha256:513cc7f0f5a7c035812604c2c852a0c1468311345573de647e310aca4ab165ba", size = 117308, upload-time = "2025-10-14T06:47:48.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/e1/1df74c915fde3c48940247ad64984f40f5968191d7b5230bcc7b31402e7c/blake3-1.0.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9a8946cb6b1d2b2096daaaa89856f39887bce2b78503fa31b78173e3a86fa281", size = 350481, upload-time = "2025-10-14T06:45:26.625Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/7c47ae1f5f8d60783ce6234a8b31db351fc62be243006a6276284ca3d40d/blake3-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:adccc3a139207e02bb7d7bb0715fe0b87069685aad5f3afff820b2f829467904", size = 328039, upload-time = "2025-10-14T06:45:32.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0a/515209b0c282c360e249b89cd85350d97cfd55fadbb4df736c67b77b27a1/blake3-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fcfe81b3ae3fb5d2e88be0d3259603ff95f0d5ed69f655c28fdaef31e49a470", size = 371092, upload-time = "2025-10-14T06:45:34.062Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/9d342a2bf5817f006bbe947335e5d387327541ea47590854947befd01251/blake3-1.0.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58ce8d45a5bb5326482de72ea1969a378634236186a970fef63058a5b7b8b435", size = 374859, upload-time = "2025-10-14T06:45:35.262Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fc/ea4bef850a7ec9fbb383503fd3c56056dd9fa44e10c3bc61050ab7b2bac0/blake3-1.0.8-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83605dbf43f581d8b7175b7f3bfe5388bad5a7c6ac175c9c11d669da31133f4b", size = 448585, upload-time = "2025-10-14T06:45:36.542Z" }, + { url = "https://files.pythonhosted.org/packages/a5/67/167a65a4c431715407d07b1b8b1367698a3ad88e7260edb85f0c5293f08a/blake3-1.0.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b5573b052777142b2cecc453d022c3f21aa4aba75011258410bb98f41c1a727", size = 507519, upload-time = "2025-10-14T06:45:37.814Z" }, + { url = "https://files.pythonhosted.org/packages/32/e2/0886e192d634b264c613b0fbf380745b39992b424a0effc00ef08783644e/blake3-1.0.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe1b02ab49bfd969ef50b9f17482a2011c77536654af21807ba5c2674e0bb2a0", size = 393645, upload-time = "2025-10-14T06:45:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3b/7fb2fe615448caaa5f6632b2c7551117b38ccac747a3a5769181e9751641/blake3-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7780666dc6be809b49442d6d5ce06fdbe33024a87560b58471103ec17644682", size = 387640, upload-time = "2025-10-14T06:45:40.546Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8c/2bfc942c6c97cb3d20f341859343bb86ee20af723fedfc886373e606079b/blake3-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:af394b50c6aa0b1b957a99453d1ee440ef67cd2d1b5669c731647dc723de8a3a", size = 550316, upload-time = "2025-10-14T06:45:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/7e/75/0252be37620699b79dbaa799c9b402d63142a131d16731df4ef09d135dd7/blake3-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c63ece266a43014cf29e772a82857cd8e90315ae3ed53e3c5204851596edd5f2", size = 554463, upload-time = "2025-10-14T06:45:43.22Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6d/d698ae2d5ddd25976fd2c11b079ca071334aecbba6414da8c9cc8e19d833/blake3-1.0.8-cp311-cp311-win32.whl", hash = "sha256:44c2815d4616fad7e2d757d121c0a11780f70ffc817547b3059b5c7e224031a7", size = 228375, upload-time = "2025-10-14T06:45:44.425Z" }, + { url = "https://files.pythonhosted.org/packages/34/d7/33b01e27dc3542dc9ec44132684506f880cd0257b04da0bf7f4b2afa41c8/blake3-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:8f2ef8527a7a8afd99b16997d015851ccc0fe2a409082cebb980af2554e5c74c", size = 215733, upload-time = "2025-10-14T06:45:46.049Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a0/b7b6dff04012cfd6e665c09ee446f749bd8ea161b00f730fe1bdecd0f033/blake3-1.0.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8da4233984d51471bd4e4366feda1d90d781e712e0a504ea54b1f2b3577557b", size = 347983, upload-time = "2025-10-14T06:45:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a2/264091cac31d7ae913f1f296abc20b8da578b958ffb86100a7ce80e8bf5c/blake3-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1257be19f2d381c868a34cc822fc7f12f817ddc49681b6d1a2790bfbda1a9865", size = 325415, upload-time = "2025-10-14T06:45:48.482Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/85a4c0782f613de23d114a7a78fcce270f75b193b3ff3493a0de24ba104a/blake3-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:269f255b110840e52b6ce9db02217e39660ebad3e34ddd5bca8b8d378a77e4e1", size = 371296, upload-time = "2025-10-14T06:45:49.674Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/488475254976ed93fab57c67aa80d3b40df77f7d9db6528c9274bff53e08/blake3-1.0.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66ca28a673025c40db3eba21a9cac52f559f83637efa675b3f6bd8683f0415f3", size = 374516, upload-time = "2025-10-14T06:45:51.23Z" }, + { url = "https://files.pythonhosted.org/packages/7b/21/2a1c47fedb77fb396512677ec6d46caf42ac6e9a897db77edd0a2a46f7bb/blake3-1.0.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb04966537777af56c1f399b35525aa70a1225816e121ff95071c33c0f7abca", size = 447911, upload-time = "2025-10-14T06:45:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7d/db0626df16029713e7e61b67314c4835e85c296d82bd907c21c6ea271da2/blake3-1.0.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5b5da177d62cc4b7edf0cea08fe4dec960c9ac27f916131efa890a01f747b93", size = 505420, upload-time = "2025-10-14T06:45:54.445Z" }, + { url = "https://files.pythonhosted.org/packages/5b/55/6e737850c2d58a6d9de8a76dad2ae0f75b852a23eb4ecb07a0b165e6e436/blake3-1.0.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38209b10482c97e151681ea3e91cc7141f56adbbf4820a7d701a923124b41e6a", size = 394189, upload-time = "2025-10-14T06:45:55.719Z" }, + { url = "https://files.pythonhosted.org/packages/5b/94/eafaa5cdddadc0c9c603a6a6d8339433475e1a9f60c8bb9c2eed2d8736b6/blake3-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504d1399b7fb91dfe5c25722d2807990493185faa1917456455480c36867adb5", size = 388001, upload-time = "2025-10-14T06:45:57.067Z" }, + { url = "https://files.pythonhosted.org/packages/17/81/735fa00d13de7f68b25e1b9cb36ff08c6f165e688d85d8ec2cbfcdedccc5/blake3-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c84af132aa09abeadf9a0118c8fb26f4528f3f42c10ef8be0fcf31c478774ec4", size = 550302, upload-time = "2025-10-14T06:45:58.657Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/d1fe8bdea4a6088bd54b5a58bc40aed89a4e784cd796af7722a06f74bae7/blake3-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a25db3d36b55f5ed6a86470155cc749fc9c5b91c949b8d14f48658f9d960d9ec", size = 554211, upload-time = "2025-10-14T06:46:00.269Z" }, + { url = "https://files.pythonhosted.org/packages/55/d1/ca74aa450cbe10e396e061f26f7a043891ffa1485537d6b30d3757e20995/blake3-1.0.8-cp312-cp312-win32.whl", hash = "sha256:e0fee93d5adcd44378b008c147e84f181f23715307a64f7b3db432394bbfce8b", size = 228343, upload-time = "2025-10-14T06:46:01.533Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/bbd02647169e3fbed27558555653ac2578c6f17ccacf7d1956c58ef1d214/blake3-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:6a6eafc29e4f478d365a87d2f25782a521870c8514bb43734ac85ae9be71caf7", size = 215704, upload-time = "2025-10-14T06:46:02.79Z" }, + { url = "https://files.pythonhosted.org/packages/55/b8/11de9528c257f7f1633f957ccaff253b706838d22c5d2908e4735798ec01/blake3-1.0.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:46dc20976bd6c235959ef0246ec73420d1063c3da2839a9c87ca395cf1fd7943", size = 347771, upload-time = "2025-10-14T06:46:04.248Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/f7668be55c909678b001ecacff11ad7016cd9b4e9c7cc87b5971d638c5a9/blake3-1.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d17eb6382634b3a5bc0c0e0454d5265b0becaeeadb6801ed25150b39a999d0cc", size = 325431, upload-time = "2025-10-14T06:46:06.136Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/e8a85fa261894bf7ce7af928ff3408aab60287ab8d58b55d13a3f700b619/blake3-1.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19fc6f2b7edab8acff6895fc6e38c19bd79f4c089e21153020c75dfc7397d52d", size = 370994, upload-time = "2025-10-14T06:46:07.398Z" }, + { url = "https://files.pythonhosted.org/packages/62/cd/765b76bb48b8b294fea94c9008b0d82b4cfa0fa2f3c6008d840d01a597e4/blake3-1.0.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f54cff7f15d91dc78a63a2dd02a3dccdc932946f271e2adb4130e0b4cf608ba", size = 374372, upload-time = "2025-10-14T06:46:08.698Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/32084eadbb28592bb07298f0de316d2da586c62f31500a6b1339a7e7b29b/blake3-1.0.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7e12a777f6b798eb8d06f875d6e108e3008bd658d274d8c676dcf98e0f10537", size = 447627, upload-time = "2025-10-14T06:46:10.002Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f4/3788a1d86e17425eea147e28d7195d7053565fc279236a9fd278c2ec495e/blake3-1.0.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddfc59b0176fb31168f08d5dd536e69b1f4f13b5a0f4b0c3be1003efd47f9308", size = 507536, upload-time = "2025-10-14T06:46:11.614Z" }, + { url = "https://files.pythonhosted.org/packages/fe/01/4639cba48513b94192681b4da472cdec843d3001c5344d7051ee5eaef606/blake3-1.0.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2336d5b2a801a7256da21150348f41610a6c21dae885a3acb1ebbd7333d88d8", size = 394105, upload-time = "2025-10-14T06:46:12.808Z" }, + { url = "https://files.pythonhosted.org/packages/21/ae/6e55c19c8460fada86cd1306a390a09b0c5a2e2e424f9317d2edacea439f/blake3-1.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4072196547484c95a5a09adbb952e9bb501949f03f9e2a85e7249ef85faaba8", size = 386928, upload-time = "2025-10-14T06:46:16.284Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6c/05b7a5a907df1be53a8f19e7828986fc6b608a44119641ef9c0804fbef15/blake3-1.0.8-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0eab3318ec02f8e16fe549244791ace2ada2c259332f0c77ab22cf94dfff7130", size = 550003, upload-time = "2025-10-14T06:46:17.791Z" }, + { url = "https://files.pythonhosted.org/packages/b4/03/f0ea4adfedc1717623be6460b3710fcb725ca38082c14274369803f727e1/blake3-1.0.8-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a33b9a1fb6d1d559a8e0d04b041e99419a6bb771311c774f6ff57ed7119c70ed", size = 553857, upload-time = "2025-10-14T06:46:19.088Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6f/e5410d2e2a30c8aba8389ffc1c0061356916bf5ecd0a210344e7b69b62ab/blake3-1.0.8-cp313-cp313-win32.whl", hash = "sha256:e171b169cb7ea618e362a4dddb7a4d4c173bbc08b9ba41ea3086dd1265530d4f", size = 228315, upload-time = "2025-10-14T06:46:20.391Z" }, + { url = "https://files.pythonhosted.org/packages/79/ef/d9c297956dfecd893f29f59e7b22445aba5b47b7f6815d9ba5dcd73fcae6/blake3-1.0.8-cp313-cp313-win_amd64.whl", hash = "sha256:3168c457255b5d2a2fc356ba696996fcaff5d38284f968210d54376312107662", size = 215477, upload-time = "2025-10-14T06:46:21.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/ba/eaa7723d66dd8ab762a3e85e139bb9c46167b751df6e950ad287adb8fb61/blake3-1.0.8-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4d672c24dc15ec617d212a338a4ca14b449829b6072d09c96c63b6e6b621aed", size = 347289, upload-time = "2025-10-14T06:46:22.772Z" }, + { url = "https://files.pythonhosted.org/packages/47/b3/6957f6ee27f0d5b8c4efdfda68a1298926a88c099f4dd89c711049d16526/blake3-1.0.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1af0e5a29aa56d4fba904452ae784740997440afd477a15e583c38338e641f41", size = 324444, upload-time = "2025-10-14T06:46:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/13/da/722cebca11238f3b24d3cefd2361c9c9ea47cfa0ad9288eeb4d1e0b7cf93/blake3-1.0.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef153c5860d5bf1cc71aece69b28097d2a392913eb323d6b52555c875d0439fc", size = 370441, upload-time = "2025-10-14T06:46:26.29Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d5/2f7440c8e41c0af995bad3a159e042af0f4ed1994710af5b4766ca918f65/blake3-1.0.8-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ae3689f0c7bfa6ce6ae45cab110e4c3442125c4c23b28f1f097856de26e4d1", size = 374312, upload-time = "2025-10-14T06:46:27.451Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6c/fb6a7812e60ce3e110bcbbb11f167caf3e975c589572c41e1271f35f2c41/blake3-1.0.8-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb83532f7456ddeb68dae1b36e1f7c52f9cb72852ac01159bbcb1a12b0f8be0", size = 447007, upload-time = "2025-10-14T06:46:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/3b/c99b43fae5047276ea9d944077c190fc1e5f22f57528b9794e21f7adedc6/blake3-1.0.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae7754c7d96e92a70a52e07c732d594cf9924d780f49fffd3a1e9235e0f5ba7", size = 507323, upload-time = "2025-10-14T06:46:30.661Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/ba90eddd592f8c074a0694cb0a744b6bd76bfe67a14c2b490c8bdfca3119/blake3-1.0.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bacaae75e98dee3b7da6c5ee3b81ee21a3352dd2477d6f1d1dbfd38cdbf158a", size = 393449, upload-time = "2025-10-14T06:46:31.805Z" }, + { url = "https://files.pythonhosted.org/packages/25/ed/58a2acd0b9e14459cdaef4344db414d4a36e329b9720921b442a454dd443/blake3-1.0.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9456c829601d72852d8ba0af8dae0610f7def1d59f5942efde1e2ef93e8a8b57", size = 386844, upload-time = "2025-10-14T06:46:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/4a/04/fed09845b18d90862100c8e48308261e2f663aab25d3c71a6a0bdda6618b/blake3-1.0.8-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:497ef8096ec4ac1ffba9a66152cee3992337cebf8ea434331d8fd9ce5423d227", size = 549550, upload-time = "2025-10-14T06:46:35.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/1859fddfabc1cc72548c2269d988819aad96d854e25eae00531517925901/blake3-1.0.8-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:511133bab85ff60ed143424ce484d08c60894ff7323f685d7a6095f43f0c85c3", size = 553805, upload-time = "2025-10-14T06:46:36.532Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c7/2969352017f62378e388bb07bb2191bc9a953f818dc1cd6b9dd5c24916e1/blake3-1.0.8-cp313-cp313t-win32.whl", hash = "sha256:9c9fbdacfdeb68f7ca53bb5a7a5a593ec996eaf21155ad5b08d35e6f97e60877", size = 228068, upload-time = "2025-10-14T06:46:37.826Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fc/923e25ac9cadfff1cd20038bcc0854d0f98061eb6bc78e42c43615f5982d/blake3-1.0.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3cec94ed5676821cf371e9c9d25a41b4f3ebdb5724719b31b2749653b7cc1dfa", size = 215369, upload-time = "2025-10-14T06:46:39.054Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2a/9f13ea01b03b1b4751a1cc2b6c1ef4b782e19433a59cf35b59cafb2a2696/blake3-1.0.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2c33dac2c6112bc23f961a7ca305c7e34702c8177040eb98d0389d13a347b9e1", size = 347016, upload-time = "2025-10-14T06:46:40.318Z" }, + { url = "https://files.pythonhosted.org/packages/06/8e/8458c4285fbc5de76414f243e4e0fcab795d71a8b75324e14959aee699da/blake3-1.0.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c445eff665d21c3b3b44f864f849a2225b1164c08654beb23224a02f087b7ff1", size = 324496, upload-time = "2025-10-14T06:46:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/b913eb9cc4af708c03e01e6b88a8bb3a74833ba4ae4b16b87e2829198e06/blake3-1.0.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47939f04b89c5c6ff1e51e883e5efab1ea1bf01a02f4d208d216dddd63d0dd8", size = 370654, upload-time = "2025-10-14T06:46:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/245e0800c33b99c8f2b570d9a7199b51803694913ee4897f339648502933/blake3-1.0.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73e0b4fa25f6e3078526a592fb38fca85ef204fd02eced6731e1cdd9396552d4", size = 374693, upload-time = "2025-10-14T06:46:45.186Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a6/8cb182c8e482071dbdfcc6ec0048271fd48bcb78782d346119ff54993700/blake3-1.0.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0543c57eb9d6dac9d4bced63e9f7f7b546886ac04cec8da3c3d9c8f30cbbb7", size = 447673, upload-time = "2025-10-14T06:46:46.358Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/1cbbb5574d2a9436d1b15e7eb5b9d82e178adcaca71a97b0fddaca4bfe3a/blake3-1.0.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed972ebd553c0c25363459e9fc71a38c045d8419e365b59acd8cd791eff13981", size = 507233, upload-time = "2025-10-14T06:46:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/9c/45/b55825d90af353b3e26c653bab278da9d6563afcf66736677f9397e465be/blake3-1.0.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bafdec95dfffa3f6571e529644744e280337df15ddd9728f224ba70c5779b23", size = 393852, upload-time = "2025-10-14T06:46:49.511Z" }, + { url = "https://files.pythonhosted.org/packages/34/73/9058a1a457dd20491d1b37de53d6876eff125e1520d9b2dd7d0acbc88de2/blake3-1.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d78f06f3fb838b34c330e2987090376145cbe5944d8608a0c4779c779618f7b", size = 386442, upload-time = "2025-10-14T06:46:51.205Z" }, + { url = "https://files.pythonhosted.org/packages/30/6d/561d537ffc17985e276e08bf4513f1c106f1fdbef571e782604dc4e44070/blake3-1.0.8-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:dd03ff08d1b6e4fdda1cd03826f971ae8966ef6f683a8c68aa27fb21904b5aa9", size = 549929, upload-time = "2025-10-14T06:46:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/03/2f/dbe20d2c57f1a67c63be4ba310bcebc707b945c902a0bde075d2a8f5cd5c/blake3-1.0.8-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:4e02a3c499e35bf51fc15b2738aca1a76410804c877bcd914752cac4f71f052a", size = 553750, upload-time = "2025-10-14T06:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/6b/da/c6cb712663c869b2814870c2798e57289c4268c5ac5fb12d467fce244860/blake3-1.0.8-cp314-cp314-win32.whl", hash = "sha256:a585357d5d8774aad9ffc12435de457f9e35cde55e0dc8bc43ab590a6929e59f", size = 228404, upload-time = "2025-10-14T06:46:56.807Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/c7dcd8bc3094bba1c4274e432f9e77a7df703532ca000eaa550bd066b870/blake3-1.0.8-cp314-cp314-win_amd64.whl", hash = "sha256:9ab5998e2abd9754819753bc2f1cf3edf82d95402bff46aeef45ed392a5468bf", size = 215460, upload-time = "2025-10-14T06:46:58.15Z" }, + { url = "https://files.pythonhosted.org/packages/75/3c/6c8afd856c353176836daa5cc33a7989e8f54569e9d53eb1c53fc8f80c34/blake3-1.0.8-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e2df12f295f95a804338bd300e8fad4a6f54fd49bd4d9c5893855a230b5188a8", size = 347482, upload-time = "2025-10-14T06:47:00.189Z" }, + { url = "https://files.pythonhosted.org/packages/6a/35/92cd5501ce8e1f5cabdc0c3ac62d69fdb13ff0b60b62abbb2b6d0a53a790/blake3-1.0.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:63379be58438878eeb76ebe4f0efbeaabf42b79f2cff23b6126b7991588ced67", size = 324376, upload-time = "2025-10-14T06:47:01.413Z" }, + { url = "https://files.pythonhosted.org/packages/11/33/503b37220a3e2e31917ef13722efd00055af51c5e88ae30974c733d7ece6/blake3-1.0.8-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88d527c247f9609dc1d45a08fd243e39f0d5300d54c57e048de24d4fa9240ebb", size = 370220, upload-time = "2025-10-14T06:47:02.573Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/fe817843adf59516c04d44387bd643b422a3b0400ea95c6ede6a49920737/blake3-1.0.8-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506a47897a11ebe8f3cdeb52f1365d6a2f83959e98ccb0c830f8f73277d4d358", size = 373454, upload-time = "2025-10-14T06:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/90a2a623575373dfc9b683f1bad1bf017feafa5a6d65d94fb09543050740/blake3-1.0.8-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5122a61b3b004bbbd979bdf83a3aaab432da3e2a842d7ddf1c273f2503b4884", size = 447102, upload-time = "2025-10-14T06:47:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/93/ff/4e8ce314f60115c4c657b1fdbe9225b991da4f5bcc5d1c1f1d151e2f39d6/blake3-1.0.8-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0171e85d56dec1219abdae5f49a0ed12cb3f86a454c29160a64fd8a8166bba37", size = 506791, upload-time = "2025-10-14T06:47:06.82Z" }, + { url = "https://files.pythonhosted.org/packages/44/88/2963a1f18aab52bdcf35379b2b48c34bbc462320c37e76960636b8602c36/blake3-1.0.8-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:003f61e8c41dd9931edddf1cc6a1bb680fb2ac0ad15493ef4a1df9adc59ce9df", size = 393717, upload-time = "2025-10-14T06:47:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/45/d1/a848ed8e8d4e236b9b16381768c9ae99d92890c24886bb4505aa9c3d2033/blake3-1.0.8-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c3151955efb09ba58cd3e1263521e15e9e3866a40d6bd3556d86fc968e8f95", size = 386150, upload-time = "2025-10-14T06:47:10.363Z" }, + { url = "https://files.pythonhosted.org/packages/96/09/e3eb5d60f97c01de23d9f434e6e1fc117efb466eaa1f6ddbbbcb62580d6e/blake3-1.0.8-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:5eb25bca3cee2e0dd746a214784fb36be6a43640c01c55b6b4e26196e72d076c", size = 549120, upload-time = "2025-10-14T06:47:11.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/ad/3d9661c710febb8957dd685fdb3e5a861aa0ac918eda3031365ce45789e2/blake3-1.0.8-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ab4e1dea4fa857944944db78e8f20d99ee2e16b2dea5a14f514fb0607753ac83", size = 553264, upload-time = "2025-10-14T06:47:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/11/55/e332a5b49edf377d0690e95951cca21a00c568f6e37315f9749efee52617/blake3-1.0.8-cp314-cp314t-win32.whl", hash = "sha256:67f1bc11bf59464ef092488c707b13dd4e872db36e25c453dfb6e0c7498df9f1", size = 228116, upload-time = "2025-10-14T06:47:14.516Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5c/dbd00727a3dd165d7e0e8af40e630cd7e45d77b525a3218afaff8a87358e/blake3-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:421b99cdf1ff2d1bf703bc56c454f4b286fce68454dd8711abbcb5a0df90c19a", size = 215133, upload-time = "2025-10-14T06:47:16.069Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.68" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ae/60c642aa5413e560b671da825329f510b29a77274ed0f580bde77562294d/boto3-1.42.68.tar.gz", hash = "sha256:3f349f967ab38c23425626d130962bcb363e75f042734fe856ea8c5a00eef03c", size = 112761, upload-time = "2026-03-13T19:32:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/f6/dc6e993479dbb597d68223fbf61cb026511737696b15bd7d2a33e9b2c24f/boto3-1.42.68-py3-none-any.whl", hash = "sha256:dbff353eb7dc93cbddd7926ed24793e0174c04adbe88860dfa639568442e4962", size = 140556, upload-time = "2026-03-13T19:32:14.951Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.68" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/22/87502d5fbbfa8189406a617b30b1e2a3dc0ab2669f7268e91b385c1c1c7a/botocore-1.42.68.tar.gz", hash = "sha256:3951c69e12ac871dda245f48dac5c7dd88ea1bfdd74a8879ec356cf2874b806a", size = 14994514, upload-time = "2026-03-13T19:32:03.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/2a/1428f6594799780fe6ee845d8e6aeffafe026cd16a70c878684e2dcbbfc8/botocore-1.42.68-py3-none-any.whl", hash = "sha256:9df7da26374601f890e2f115bfa573d65bf15b25fe136bb3aac809f6145f52ab", size = 14668816, upload-time = "2026-03-13T19:31:58.572Z" }, +] + [[package]] name = "certifi" version = "2025.6.15" @@ -220,6 +497,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, ] +[[package]] +name = "cftime" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/dc/470ffebac2eb8c54151eb893055024fe81b1606e7c6ff8449a588e9cd17f/cftime-1.6.5.tar.gz", hash = "sha256:8225fed6b9b43fb87683ebab52130450fc1730011150d3092096a90e54d1e81e", size = 326605, upload-time = "2025-10-13T18:56:26.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f6/9da7aba9548ede62d25936b8b448acd7e53e5dcc710896f66863dcc9a318/cftime-1.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:474e728f5a387299418f8d7cb9c52248dcd5d977b2a01de7ec06bba572e26b02", size = 512733, upload-time = "2025-10-13T18:56:00.189Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d5/d86ad95fc1fd89947c34b495ff6487b6d361cf77500217423b4ebcb1f0c2/cftime-1.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab9e80d4de815cac2e2d88a2335231254980e545d0196eb34ee8f7ed612645f1", size = 492946, upload-time = "2025-10-13T18:56:01.262Z" }, + { url = "https://files.pythonhosted.org/packages/4f/93/d7e8dd76b03a9d5be41a3b3185feffc7ea5359228bdffe7aa43ac772a75b/cftime-1.6.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ad24a563784e4795cb3d04bd985895b5db49ace2cbb71fcf1321fd80141f9a52", size = 1689856, upload-time = "2025-10-13T19:39:12.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8d/86586c0d75110f774e46e2bd6d134e2d1cca1dedc9bb08c388fa3df76acd/cftime-1.6.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3cda6fd12c7fb25eff40a6a857a2bf4d03e8cc71f80485d8ddc65ccbd80f16a", size = 1718573, upload-time = "2025-10-13T18:56:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/7956914cfc135992e89098ebbc67d683c51ace5366ba4b114fef1de89b21/cftime-1.6.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:28cda78d685397ba23d06273b9c916c3938d8d9e6872a537e76b8408a321369b", size = 1788563, upload-time = "2025-10-13T18:56:04.075Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c7/6669708fcfe1bb7b2a7ce693b8cc67165eac00d3ac5a5e8f6ce1be551ff9/cftime-1.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:93ead088e3a216bdeb9368733a0ef89a7451dfc1d2de310c1c0366a56ad60dc8", size = 473631, upload-time = "2025-10-13T18:56:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/82/c5/d70cb1ab533ca790d7c9b69f98215fa4fead17f05547e928c8f2b8f96e54/cftime-1.6.5-cp311-cp311-win_arm64.whl", hash = "sha256:3384d69a0a7f3d45bded21a8cbcce66c8ba06c13498eac26c2de41b1b9b6e890", size = 459383, upload-time = "2026-01-02T21:16:47.317Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c1/e8cb7f78a3f87295450e7300ebaecf83076d96a99a76190593d4e1d2be40/cftime-1.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:eef25caed5ebd003a38719bd3ff8847cd52ef2ea56c3ebdb2c9345ba131fc7c5", size = 504175, upload-time = "2025-10-13T18:56:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/50/1a/86e1072b09b2f9049bb7378869f64b6747f96a4f3008142afed8955b52a4/cftime-1.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87d2f3b949e45463e559233c69e6a9cf691b2b378c1f7556166adfabbd1c6b0", size = 485980, upload-time = "2025-10-13T18:56:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/35/28/d3177b60da3f308b60dee2aef2eb69997acfab1e863f0bf0d2a418396ce5/cftime-1.6.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:82cb413973cc51b55642b3a1ca5b28db5b93a294edbef7dc049c074b478b4647", size = 1591166, upload-time = "2025-10-13T19:39:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fd/a7266970312df65e68b5641b86e0540a739182f5e9c62eec6dbd29f18055/cftime-1.6.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85ba8e7356d239cfe56ef7707ac30feaf67964642ac760a82e507ee3c5db4ac4", size = 1642614, upload-time = "2025-10-13T18:56:09.815Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/f0035a4bc2df8885bb7bd5fe63659686ea1ec7d0cc74b4e3d50e447402e5/cftime-1.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:456039af7907a3146689bb80bfd8edabd074c7f3b4eca61f91b9c2670addd7ad", size = 1688090, upload-time = "2025-10-13T18:56:11.442Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/8856a0ab76708553ff597dd2e617b088c734ba87dc3fd395e2b2f3efffe8/cftime-1.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:da84534c43699960dc980a9a765c33433c5de1a719a4916748c2d0e97a071e44", size = 464840, upload-time = "2025-10-13T18:56:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/3a/85/451009a986d9273d2208fc0898aa00262275b5773259bf3f942f6716a9e7/cftime-1.6.5-cp312-cp312-win_arm64.whl", hash = "sha256:c62cd8db9ea40131eea7d4523691c5d806d3265d31279e4a58574a42c28acd77", size = 450534, upload-time = "2026-01-02T21:16:48.784Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/74ea344b3b003fada346ed98a6899085d6fd4c777df608992d90c458fda6/cftime-1.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4aba66fd6497711a47c656f3a732c2d1755ad15f80e323c44a8716ebde39ddd5", size = 502453, upload-time = "2025-10-13T18:56:13.545Z" }, + { url = "https://files.pythonhosted.org/packages/1e/14/adb293ac6127079b49ff11c05cf3d5ce5c1f17d097f326dc02d74ddfcb6e/cftime-1.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89e7cba699242366e67d6fb5aee579440e791063f92a93853610c91647167c0d", size = 484541, upload-time = "2025-10-13T18:56:14.612Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/bb8a4566af8d0ef3f045d56c462a9115da4f04b07c7fbbf2b4875223eebd/cftime-1.6.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f1eb43d7a7b919ec99aee709fb62ef87ef1cf0679829ef93d37cc1c725781e9", size = 1591014, upload-time = "2025-10-13T19:39:15.346Z" }, + { url = "https://files.pythonhosted.org/packages/ba/08/52f06ff2f04d376f9cd2c211aefcf2b37f1978e43289341f362fc99f6a0e/cftime-1.6.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e02a1d80ffc33fe469c7db68aa24c4a87f01da0c0c621373e5edadc92964900b", size = 1633625, upload-time = "2025-10-13T18:56:15.745Z" }, + { url = "https://files.pythonhosted.org/packages/cf/33/03e0b23d58ea8fab94ecb4f7c5b721e844a0800c13694876149d98830a73/cftime-1.6.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18ab754805233cdd889614b2b3b86a642f6d51a57a1ec327c48053f3414f87d8", size = 1684269, upload-time = "2025-10-13T18:56:17.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/60/a0cfba63847b43599ef1cdbbf682e61894994c22b9a79fd9e1e8c7e9de41/cftime-1.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:6c27add8f907f4a4cd400e89438f2ea33e2eb5072541a157a4d013b7dbe93f9c", size = 465364, upload-time = "2025-10-13T18:56:18.05Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e8/ec32f2aef22c15604e6fda39ff8d581a00b5469349f8fba61640d5358d2c/cftime-1.6.5-cp313-cp313-win_arm64.whl", hash = "sha256:31d1ff8f6bbd4ca209099d24459ec16dea4fb4c9ab740fbb66dd057ccbd9b1b9", size = 450468, upload-time = "2026-01-02T21:16:50.193Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6c/a9618f589688358e279720f5c0fe67ef0077fba07334ce26895403ebc260/cftime-1.6.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c69ce3bdae6a322cbb44e9ebc20770d47748002fb9d68846a1e934f1bd5daf0b", size = 502725, upload-time = "2025-10-13T18:56:19.424Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e3/da3c36398bfb730b96248d006cabaceed87e401ff56edafb2a978293e228/cftime-1.6.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e62e9f2943e014c5ef583245bf2e878398af131c97e64f8cd47c1d7baef5c4e2", size = 485445, upload-time = "2025-10-13T18:56:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/32/93/b05939e5abd14bd1ab69538bbe374b4ee2a15467b189ff895e9a8cdaddf6/cftime-1.6.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da5fdaa4360d8cb89b71b8ded9314f2246aa34581e8105c94ad58d6102d9e4f", size = 1584434, upload-time = "2025-10-13T19:39:17.084Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/648397f9936e0b330999c4e776ebf296ec3c6a65f9901687dbca4ab820da/cftime-1.6.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bff865b4ea4304f2744a1ad2b8149b8328b321dd7a2b9746ef926d229bd7cd49", size = 1609812, upload-time = "2025-10-13T18:56:21.971Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0f/901b4835aa67ad3e915605d4e01d0af80a44b114eefab74ae33de6d36933/cftime-1.6.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e552c5d1c8a58f25af7521e49237db7ca52ed2953e974fe9f7c4491e95fdd36c", size = 1669768, upload-time = "2025-10-13T18:56:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/22/d5/e605e4b28363e7a9ae98ed12cabbda5b155b6009270e6a231d8f10182a17/cftime-1.6.5-cp314-cp314-win_amd64.whl", hash = "sha256:e645b095dc50a38ac454b7e7f0742f639e7d7f6b108ad329358544a6ff8c9ba2", size = 463818, upload-time = "2025-10-13T18:56:25.376Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/a8f85ae697ff10206ec401c2621f5ca9f327554f586d62f244739ceeb347/cftime-1.6.5-cp314-cp314-win_arm64.whl", hash = "sha256:b9044d7ac82d3d8af189df1032fdc871bbd3f3dd41a6ec79edceb5029b71e6e0", size = 459862, upload-time = "2026-01-02T20:45:02.625Z" }, + { url = "https://files.pythonhosted.org/packages/ab/05/7410e12fd03a0c52717e74e6a1b49958810807dda212e23b65d43ea99676/cftime-1.6.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ef56460cb0576e1a9161e1428c9e1a633f809a23fa9d598f313748c1ae5064e", size = 533781, upload-time = "2026-01-02T20:45:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/44/ba/10e3546426d3ed9f9cc82e4a99836bb6fac1642c7830f7bdd0ac1c3f0805/cftime-1.6.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f4873d38b10032f9f3111c547a1d485519ae64eee6a7a2d091f1f8b08e1ba50", size = 515218, upload-time = "2026-01-02T20:45:06.788Z" }, + { url = "https://files.pythonhosted.org/packages/bd/68/efa11eae867749e921bfec6a865afdba8166e96188112dde70bb8bb49254/cftime-1.6.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccce0f4c9d3f38dd948a117e578b50d0e0db11e2ca9435fb358fd524813e4b61", size = 1579932, upload-time = "2026-01-02T20:45:11.194Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6c/0971e602c1390a423e6621dfbad9f1d375186bdaf9c9c7f75e06f1fbf355/cftime-1.6.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19cbfc5152fb0b34ce03acf9668229af388d7baa63a78f936239cb011ccbe6b1", size = 1555894, upload-time = "2026-01-02T20:45:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fc/8475a15b7c3209a4a68b563dfc5e01ce74f2d8b9822372c3d30c68ab7f39/cftime-1.6.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4470cd5ef3c2514566f53efbcbb64dd924fa0584637d90285b2f983bd4ee7d97", size = 513027, upload-time = "2026-01-02T20:45:20.023Z" }, + { url = "https://files.pythonhosted.org/packages/f7/80/4ecbda8318fbf40ad4e005a4a93aebba69e81382e5b4c6086251cd5d0ee8/cftime-1.6.5-cp314-cp314t-win_arm64.whl", hash = "sha256:034c15a67144a0a5590ef150c99f844897618b148b87131ed34fda7072614662", size = 469065, upload-time = "2026-01-02T20:45:23.398Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -268,6 +590,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] +[[package]] +name = "chemfiles" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/51/35538663b6384add778945735478da66b7c3095649654325d001922f30f8/chemfiles-0.10.4.tar.gz", hash = "sha256:f9e5ece3fcc8b63fdc2708d4ecc2ba5862ae2ab6790447bffc10c1b34ef2f445", size = 3575412, upload-time = "2023-05-23T10:49:17.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0d/e5a214dddec845c425cda2cb2273a95b2c5f77be9404d02c4f48b4e6992b/chemfiles-0.10.4-1-py2.py3-none-win_amd64.whl", hash = "sha256:5c1b50a7fd56d014f930e38a838c92098bd047a3e989ba4b89ff657c6d16e38a", size = 1129225, upload-time = "2023-05-24T15:02:46.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/0e/409d1fe39dc24f3ac47dd384e78462fc4eb0435a169afe5b488cf6ded39b/chemfiles-0.10.4-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:10a4e641605db56321316310f620746db350691d7c9edc433fe2a65984e2278b", size = 1497588, upload-time = "2023-05-23T10:49:04.561Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/d7d7347db0d1a92577aa27d9412adea002295263d52cca57ff14c92cde56/chemfiles-0.10.4-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:626725b0ea907d995cbbba99df1d19c474f8ebecdea8d0d390b7f3eaf2c91039", size = 1350827, upload-time = "2023-05-23T10:49:07.125Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d5/beb71f372e650ba75e3eac246a17daa09a08aeed46580b62af35234d01f2/chemfiles-0.10.4-py2.py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4dbf6fa7ad5b2a1ad1415fbca905ce3a02c71cc2aa7fbce18a2b7d13c01a3664", size = 1751189, upload-time = "2023-05-23T10:49:10.237Z" }, + { url = "https://files.pythonhosted.org/packages/50/4c/380de5755146e27236cdecf02b7fe5da4c1f3786716baee5b3a245026acb/chemfiles-0.10.4-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef8f2b9fa65885658088180bb33971d1337bc8542220c710d1f6f3c1a6d661d4", size = 1632279, upload-time = "2023-05-23T10:49:12.365Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -355,6 +693,139 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, ] +[[package]] +name = "cryptography" +version = "45.0.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'win32'", +] +dependencies = [ + { name = "cffi", marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, + { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/99/4e/49199a4c82946938a3e05d2e8ad9482484ba48bbc1e809e3d506c686d051/cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde", size = 3584634, upload-time = "2025-09-01T11:14:50.593Z" }, + { url = "https://files.pythonhosted.org/packages/16/ce/5f6ff59ea9c7779dba51b84871c19962529bdcc12e1a6ea172664916c550/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34", size = 4149533, upload-time = "2025-09-01T11:14:52.091Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/b3cfbd257ac96da4b88b46372e662009b7a16833bfc5da33bb97dd5631ae/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9", size = 4385557, upload-time = "2025-09-01T11:14:53.551Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c5/8c59d6b7c7b439ba4fc8d0cab868027fd095f215031bc123c3a070962912/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae", size = 4149023, upload-time = "2025-09-01T11:14:55.022Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/05385c86d6ca9ab0b4d5bb442d2e3d85e727939a11f3e163fc776ce5eb40/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b", size = 4385722, upload-time = "2025-09-01T11:14:57.319Z" }, + { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908, upload-time = "2025-09-01T11:14:58.78Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and platform_machine != 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", +] +dependencies = [ + { name = "cffi", marker = "python_full_version < '3.14' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/ee/04cd4314db26ffc951c1ea90bde30dd226880ab9343759d7abbecef377ee/cryptography-46.0.0.tar.gz", hash = "sha256:99f64a6d15f19f3afd78720ad2978f6d8d4c68cd4eb600fab82ab1a7c2071dca", size = 749158, upload-time = "2025-09-16T21:07:49.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/bd/3e935ca6e87dc4969683f5dd9e49adaf2cb5734253d93317b6b346e0bd33/cryptography-46.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:c9c4121f9a41cc3d02164541d986f59be31548ad355a5c96ac50703003c50fb7", size = 7285468, upload-time = "2025-09-16T21:05:52.026Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ee/dd17f412ce64b347871d7752657c5084940d42af4d9c25b1b91c7ee53362/cryptography-46.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4f70cbade61a16f5e238c4b0eb4e258d177a2fcb59aa0aae1236594f7b0ae338", size = 4308218, upload-time = "2025-09-16T21:05:55.653Z" }, + { url = "https://files.pythonhosted.org/packages/2f/53/f0b865a971e4e8b3e90e648b6f828950dea4c221bb699421e82ef45f0ef9/cryptography-46.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1eccae15d5c28c74b2bea228775c63ac5b6c36eedb574e002440c0bc28750d3", size = 4571982, upload-time = "2025-09-16T21:05:57.322Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c8/035be5fd63a98284fd74df9e04156f9fed7aa45cef41feceb0d06cbdadd0/cryptography-46.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1b4fba84166d906a22027f0d958e42f3a4dbbb19c28ea71f0fb7812380b04e3c", size = 4307996, upload-time = "2025-09-16T21:05:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/dbb6d7d0a48b95984e2d4caf0a4c7d6606cea5d30241d984c0c02b47f1b6/cryptography-46.0.0-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:523153480d7575a169933f083eb47b1edd5fef45d87b026737de74ffeb300f69", size = 4015692, upload-time = "2025-09-16T21:06:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/65/48/aafcffdde716f6061864e56a0a5908f08dcb8523dab436228957c8ebd5df/cryptography-46.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f09a3a108223e319168b7557810596631a8cb864657b0c16ed7a6017f0be9433", size = 4982192, upload-time = "2025-09-16T21:06:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ab/1e73cfc181afc3054a09e5e8f7753a8fba254592ff50b735d7456d197353/cryptography-46.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c1f6ccd6f2eef3b2eb52837f0463e853501e45a916b3fc42e5d93cf244a4b97b", size = 4603944, upload-time = "2025-09-16T21:06:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/3a/02/d71dac90b77c606c90c366571edf264dc8bd37cf836e7f902253cbf5aa77/cryptography-46.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:80a548a5862d6912a45557a101092cd6c64ae1475b82cef50ee305d14a75f598", size = 4308149, upload-time = "2025-09-16T21:06:07.006Z" }, + { url = "https://files.pythonhosted.org/packages/29/e6/4dcb67fdc6addf4e319a99c4bed25776cb691f3aa6e0c4646474748816c6/cryptography-46.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6c39fd5cd9b7526afa69d64b5e5645a06e1b904f342584b3885254400b63f1b3", size = 4947449, upload-time = "2025-09-16T21:06:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/91e3fad8ee33aa87815c8f25563f176a58da676c2b14757a4d3b19f0253c/cryptography-46.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d5c0cbb2fb522f7e39b59a5482a1c9c5923b7c506cfe96a1b8e7368c31617ac0", size = 4603549, upload-time = "2025-09-16T21:06:13.268Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6e/caf4efadcc8f593cbaacfbb04778f78b6d0dac287b45cec25e5054de38b7/cryptography-46.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6d8945bc120dcd90ae39aa841afddaeafc5f2e832809dc54fb906e3db829dfdc", size = 4435976, upload-time = "2025-09-16T21:06:16.514Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c0/704710f349db25c5b91965c3662d5a758011b2511408d9451126429b6cd6/cryptography-46.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:88c09da8a94ac27798f6b62de6968ac78bb94805b5d272dbcfd5fdc8c566999f", size = 4709447, upload-time = "2025-09-16T21:06:19.246Z" }, + { url = "https://files.pythonhosted.org/packages/91/5e/ff63bfd27b75adaf75cc2398de28a0b08105f9d7f8193f3b9b071e38e8b9/cryptography-46.0.0-cp311-abi3-win32.whl", hash = "sha256:3738f50215211cee1974193a1809348d33893696ce119968932ea117bcbc9b1d", size = 3058317, upload-time = "2025-09-16T21:06:21.466Z" }, + { url = "https://files.pythonhosted.org/packages/46/47/4caf35014c4551dd0b43aa6c2e250161f7ffcb9c3918c9e075785047d5d2/cryptography-46.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bbaa5eef3c19c66613317dc61e211b48d5f550db009c45e1c28b59d5a9b7812a", size = 3523891, upload-time = "2025-09-16T21:06:23.856Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/6a0cafb3084a854acf808fccf756cbc9b835d1b99fb82c4a15e2e2ffb404/cryptography-46.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:16b5ac72a965ec9d1e34d9417dbce235d45fa04dac28634384e3ce40dfc66495", size = 2932145, upload-time = "2025-09-16T21:06:25.842Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/0cf967a1dc1419d5dde111bd0e22872038199f4e4655539ea6f4da5ad7f1/cryptography-46.0.0-cp314-abi3-macosx_10_9_universal2.whl", hash = "sha256:91585fc9e696abd7b3e48a463a20dda1a5c0eeeca4ba60fa4205a79527694390", size = 7203952, upload-time = "2025-09-16T21:06:28.21Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9e/d20925af5f0484c5049cf7254c91b79776a9b555af04493de6bdd419b495/cryptography-46.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:65e9117ebed5b16b28154ed36b164c20021f3a480e9cbb4b4a2a59b95e74c25d", size = 4293519, upload-time = "2025-09-16T21:06:30.143Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b9/07aec6b183ef0054b5f826ae43f0b4db34c50b56aff18f67babdcc2642a3/cryptography-46.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:da7f93551d39d462263b6b5c9056c49f780b9200bf9fc2656d7c88c7bdb9b363", size = 4545583, upload-time = "2025-09-16T21:06:31.914Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/7d25158be8c607e2b9ebda49be762404d675b47df335d0d2a3b979d80213/cryptography-46.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:be7479f9504bfb46628544ec7cb4637fe6af8b70445d4455fbb9c395ad9b7290", size = 4299196, upload-time = "2025-09-16T21:06:33.724Z" }, + { url = "https://files.pythonhosted.org/packages/15/3f/65c8753c0dbebe769cc9f9d87d52bce8b74e850ef2818c59bfc7e4248663/cryptography-46.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f85e6a7d42ad60024fa1347b1d4ef82c4df517a4deb7f829d301f1a92ded038c", size = 3994419, upload-time = "2025-09-16T21:06:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b4/69a271873cfc333a236443c94aa07e0233bc36b384e182da2263703b5759/cryptography-46.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:d349af4d76a93562f1dce4d983a4a34d01cb22b48635b0d2a0b8372cdb4a8136", size = 4960228, upload-time = "2025-09-16T21:06:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/af/e0/ab62ee938b8d17bd1025cff569803cfc1c62dfdf89ffc78df6e092bff35f/cryptography-46.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:35aa1a44bd3e0efc3ef09cf924b3a0e2a57eda84074556f4506af2d294076685", size = 4577257, upload-time = "2025-09-16T21:06:39.998Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/09a581c21da7189676678edd2bd37b64888c88c2d2727f2c3e0350194fba/cryptography-46.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c457ad3f151d5fb380be99425b286167b358f76d97ad18b188b68097193ed95a", size = 4299023, upload-time = "2025-09-16T21:06:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/af/28/2cb6d3d0d2c8ce8be4f19f4d83956c845c760a9e6dfe5b476cebed4f4f00/cryptography-46.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:399ef4c9be67f3902e5ca1d80e64b04498f8b56c19e1bc8d0825050ea5290410", size = 4925802, upload-time = "2025-09-16T21:06:44.31Z" }, + { url = "https://files.pythonhosted.org/packages/88/0b/1f31b6658c1dfa04e82b88de2d160e0e849ffb94353b1526dfb3a225a100/cryptography-46.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:378eff89b040cbce6169528f130ee75dceeb97eef396a801daec03b696434f06", size = 4577107, upload-time = "2025-09-16T21:06:46.324Z" }, + { url = "https://files.pythonhosted.org/packages/c2/af/507de3a1d4ded3068ddef188475d241bfc66563d99161585c8f2809fee01/cryptography-46.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c3648d6a5878fd1c9a22b1d43fa75efc069d5f54de12df95c638ae7ba88701d0", size = 4422506, upload-time = "2025-09-16T21:06:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/47/aa/08e514756504d92334cabfe7fe792d10d977f2294ef126b2056b436450eb/cryptography-46.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fc30be952dd4334801d345d134c9ef0e9ccbaa8c3e1bc18925cbc4247b3e29c", size = 4684081, upload-time = "2025-09-16T21:06:49.667Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/ffde6e334fbd4ace04a6d9ced4c5fe1ca9e6ded4ee21b077a6889b452a89/cryptography-46.0.0-cp314-cp314t-win32.whl", hash = "sha256:b8e7db4ce0b7297e88f3d02e6ee9a39382e0efaf1e8974ad353120a2b5a57ef7", size = 3029735, upload-time = "2025-09-16T21:06:51.301Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/a41aee8bc5659390806196b0ed4d388211d3b38172827e610a82a7cd7546/cryptography-46.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40ee4ce3c34acaa5bc347615ec452c74ae8ff7db973a98c97c62293120f668c6", size = 3502172, upload-time = "2025-09-16T21:06:53.328Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/7e7427c258fdeae867d236cc9cad0c5c56735bc4d2f4adf035933ab4c15f/cryptography-46.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:07a1be54f995ce14740bf8bbe1cc35f7a37760f992f73cf9f98a2a60b9b97419", size = 2912344, upload-time = "2025-09-16T21:06:56.808Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/80e7256a4677c2e9eb762638e8200a51f6dd56d2e3de3e34d0a83c2f5f80/cryptography-46.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:1d2073313324226fd846e6b5fc340ed02d43fd7478f584741bd6b791c33c9fee", size = 7257206, upload-time = "2025-09-16T21:06:59.295Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b8/a5ed987f5c11b242713076121dddfff999d81fb492149c006a579d0e4099/cryptography-46.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83af84ebe7b6e9b6de05050c79f8cc0173c864ce747b53abce6a11e940efdc0d", size = 4301182, upload-time = "2025-09-16T21:07:01.624Z" }, + { url = "https://files.pythonhosted.org/packages/da/94/f1c1f30110c05fa5247bf460b17acfd52fa3f5c77e94ba19cff8957dc5e6/cryptography-46.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c3cd09b1490c1509bf3892bde9cef729795fae4a2fee0621f19be3321beca7e4", size = 4562561, upload-time = "2025-09-16T21:07:03.386Z" }, + { url = "https://files.pythonhosted.org/packages/5d/54/8decbf2f707350bedcd525833d3a0cc0203d8b080d926ad75d5c4de701ba/cryptography-46.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d14eaf1569d6252280516bedaffdd65267428cdbc3a8c2d6de63753cf0863d5e", size = 4301974, upload-time = "2025-09-16T21:07:04.962Z" }, + { url = "https://files.pythonhosted.org/packages/82/63/c34a2f3516c6b05801f129616a5a1c68a8c403b91f23f9db783ee1d4f700/cryptography-46.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab3a14cecc741c8c03ad0ad46dfbf18de25218551931a23bca2731d46c706d83", size = 4009462, upload-time = "2025-09-16T21:07:06.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c5/92ef920a4cf8ff35fcf9da5a09f008a6977dcb9801c709799ec1bf2873fb/cryptography-46.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8e8b222eb54e3e7d3743a7c2b1f7fa7df7a9add790307bb34327c88ec85fe087", size = 4980769, upload-time = "2025-09-16T21:07:08.269Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8f/1705f7ea3b9468c4a4fef6cce631db14feb6748499870a4772993cbeb729/cryptography-46.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7f3f88df0c9b248dcc2e76124f9140621aca187ccc396b87bc363f890acf3a30", size = 4591812, upload-time = "2025-09-16T21:07:10.288Z" }, + { url = "https://files.pythonhosted.org/packages/34/b9/2d797ce9d346b8bac9f570b43e6e14226ff0f625f7f6f2f95d9065e316e3/cryptography-46.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9aa85222f03fdb30defabc7a9e1e3d4ec76eb74ea9fe1504b2800844f9c98440", size = 4301844, upload-time = "2025-09-16T21:07:12.522Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/8efc9712997b46aea2ac8f74adc31f780ac4662e3b107ecad0d5c1a0c7f8/cryptography-46.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f9aaf2a91302e1490c068d2f3af7df4137ac2b36600f5bd26e53d9ec320412d3", size = 4943257, upload-time = "2025-09-16T21:07:14.289Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0c/bc365287a97d28aa7feef8810884831b2a38a8dc4cf0f8d6927ad1568d27/cryptography-46.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:32670ca085150ff36b438c17f2dfc54146fe4a074ebf0a76d72fb1b419a974bc", size = 4591154, upload-time = "2025-09-16T21:07:16.271Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/0b15107277b0c558c02027da615f4e78c892f22c6a04d29c6ad43fcddca6/cryptography-46.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0f58183453032727a65e6605240e7a3824fd1d6a7e75d2b537e280286ab79a52", size = 4428200, upload-time = "2025-09-16T21:07:18.118Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/814d69418247ea2cfc985eec6678239013500d745bc7a0a35a32c2e2f3be/cryptography-46.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bc257c2d5d865ed37d0bd7c500baa71f939a7952c424f28632298d80ccd5ec1", size = 4699862, upload-time = "2025-09-16T21:07:20.219Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1e/665c718e0c45281a4e22454fa8a9bd8835f1ceb667b9ffe807baa41cd681/cryptography-46.0.0-cp38-abi3-win32.whl", hash = "sha256:df932ac70388be034b2e046e34d636245d5eeb8140db24a6b4c2268cd2073270", size = 3043766, upload-time = "2025-09-16T21:07:21.969Z" }, + { url = "https://files.pythonhosted.org/packages/78/7e/12e1e13abff381c702697845d1cf372939957735f49ef66f2061f38da32f/cryptography-46.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:274f8b2eb3616709f437326185eb563eb4e5813d01ebe2029b61bfe7d9995fbb", size = 3517216, upload-time = "2025-09-16T21:07:24.024Z" }, + { url = "https://files.pythonhosted.org/packages/ad/55/009497b2ae7375db090b41f9fe7a1a7362f804ddfe17ed9e34f748fcb0e5/cryptography-46.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:249c41f2bbfa026615e7bdca47e4a66135baa81b08509ab240a2e666f6af5966", size = 2923145, upload-time = "2025-09-16T21:07:25.74Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c9/fd0ac99ac18eaa8766800bf7d087e8c011889aa6643006cff9cbd523eadd/cryptography-46.0.0-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:75d2ddde8f1766ab2db48ed7f2aa3797aeb491ea8dfe9b4c074201aec00f5c16", size = 3722472, upload-time = "2025-09-16T21:07:32.619Z" }, + { url = "https://files.pythonhosted.org/packages/f5/69/ff831514209e68a7e32fef655abfd9ef9ee4608d151636fa11eb8d7e589a/cryptography-46.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f9f85d9cf88e3ba2b2b6da3c2310d1cf75bdf04a5bc1a2e972603054f82c4dd5", size = 4249520, upload-time = "2025-09-16T21:07:34.409Z" }, + { url = "https://files.pythonhosted.org/packages/19/4a/19960010da2865f521a5bd657eaf647d6a4368568e96f6d9ec635e47ad55/cryptography-46.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:834af45296083d892e23430e3b11df77e2ac5c042caede1da29c9bf59016f4d2", size = 4528031, upload-time = "2025-09-16T21:07:36.721Z" }, + { url = "https://files.pythonhosted.org/packages/79/92/88970c2b5b270d232213a971e74afa6d0e82d8aeee0964765a78ee1f55c8/cryptography-46.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:c39f0947d50f74b1b3523cec3931315072646286fb462995eb998f8136779319", size = 4249072, upload-time = "2025-09-16T21:07:38.382Z" }, + { url = "https://files.pythonhosted.org/packages/63/50/b0b90a269d64b479602d948f40ef6131f3704546ce003baa11405aa4093b/cryptography-46.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6460866a92143a24e3ed68eaeb6e98d0cedd85d7d9a8ab1fc293ec91850b1b38", size = 4527173, upload-time = "2025-09-16T21:07:40.742Z" }, + { url = "https://files.pythonhosted.org/packages/37/e1/826091488f6402c904e831ccbde41cf1a08672644ee5107e2447ea76a903/cryptography-46.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bf1961037309ee0bdf874ccba9820b1c2f720c2016895c44d8eb2316226c1ad5", size = 3448199, upload-time = "2025-09-16T21:07:42.639Z" }, +] + +[[package]] +name = "custodian" +version = "2025.12.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "monty" }, + { name = "psutil" }, + { name = "ruamel-yaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/ef/c3bb44e0c6bf17d4e803dc624786157ca272c022027e018dc255fad0e75a/custodian-2025.12.14.tar.gz", hash = "sha256:a2b1c3e6750b84371cc6c4f64eb9cd994ce31dc0bbf2d0026a54939ea0d22dd0", size = 112746, upload-time = "2025-12-14T15:48:04.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/98/824866c5f28feddd8a477ee8440f12ba507b7be3d1699138e4ee32a2aea5/custodian-2025.12.14-py3-none-any.whl", hash = "sha256:a028c71746eefb1f5e72ed73805149465d8bd8ca835ed7abd2d3ec92b5ca3be2", size = 125617, upload-time = "2025-12-14T15:48:02.699Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -427,6 +898,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7a/cef76fd8438a42f96db64ddaa85280485a9c395e7df3db8158cfec1eee34/dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7", size = 116252, upload-time = "2024-01-27T23:42:14.239Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "e3nn" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opt-einsum-fx" }, + { name = "scipy" }, + { name = "sympy" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/fc/0e199f2c8adf0119b0de336099fb429f17e8aa302cd4e316e9d5c5310ae8/e3nn-0.6.0.tar.gz", hash = "sha256:5c2b414e9527aad27d755ae9546596e3f7706f45a21dae71c7deedb6c048e4de", size = 438744, upload-time = "2026-02-13T22:17:28.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/aa/35de114ddcd91183b724e64198e6eb760ad868de7eac2365301f891e9429/e3nn-0.6.0-py3-none-any.whl", hash = "sha256:24243cf77578f1872a5240a956563ba873d3de0c73459ac7dc3d09061c513dc3", size = 450697, upload-time = "2026-02-13T22:17:27.241Z" }, +] + +[[package]] +name = "emmet-core" +version = "0.86.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blake3" }, + { name = "monty" }, + { name = "pybtex" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pymatgen" }, + { name = "pymatgen-io-validation" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/07/9979ae29d4b2bc643e870aca427a546ea2e8cf3819cdfe39179e2e983b5e/emmet_core-0.86.3.tar.gz", hash = "sha256:f3b30560f4461db7073e846c7c484713176b7fc01d1450ddb01451979ed5fb9c", size = 308021, upload-time = "2026-01-30T18:11:55.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/c7/a2b98de578ec870cd40d6c4b944465daeda48062f2ee086a0ee429fcde5c/emmet_core-0.86.3-py3-none-any.whl", hash = "sha256:00689d9102f1621d8889a50707a55e8cfcf16467ec74789350d36daa9e2c7884", size = 308806, upload-time = "2026-01-30T18:11:53.039Z" }, +] + [[package]] name = "executing" version = "2.2.0" @@ -445,6 +959,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "fireworks" +version = "2.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "flask-paginate" }, + { name = "gunicorn" }, + { name = "jinja2" }, + { name = "monty" }, + { name = "pymongo" }, + { name = "python-dateutil" }, + { name = "ruamel-yaml" }, + { name = "tabulate" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/c4/03cbead1b558e81024845ca1c614d213123aa74ad1e9e42e63d55a2acd3e/fireworks-2.0.9.tar.gz", hash = "sha256:62c57f6fa417e77ca3169784727392fe16b05db0ef33a08b28af0d3d6cc360f5", size = 5027304, upload-time = "2026-03-05T01:00:07.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/3a/91f78b7f70a2e42fd76435037a324622b0c907b165c1f5e005dd324fee3c/fireworks-2.0.9-py3-none-any.whl", hash = "sha256:760a08e75fe51c4802e5c8dd5645c6125b7889cf6a4d710e426d8e14749f39de", size = 465262, upload-time = "2026-03-05T01:00:05.889Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-paginate" +version = "2024.4.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/d0/aca9153b109f0062eaadb497448f5e596f87cc89474d77347a1d931c8842/flask-paginate-2024.4.12.tar.gz", hash = "sha256:2de04606b061736f0fc8fbe73d9d4d6fc03664638eca70a57728b03b3e2c9577", size = 9727, upload-time = "2024-04-12T02:17:46.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/8d3948fc5b01d4c65bfadc794cfec6f75aeab375b4245b809607458d60b8/flask_paginate-2024.4.12-py2.py3-none-any.whl", hash = "sha256:2c5a19c851b07456b2bb354d2f5dc9d59fbad0b7241599f3450c86c2427e4da1", size = 7598, upload-time = "2024-04-12T02:17:43.675Z" }, +] + [[package]] name = "fonttools" version = "4.58.4" @@ -592,6 +1156,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/5d/b645a1e7c71ba562cf31987ee7499f603b6b49f67ccab521b3b600f53a1e/gemmi-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:402a71c935cab167ac6a7a29045e47a972388ef6f62fa3f477d8b0241fe53d4e", size = 1928436, upload-time = "2025-03-24T19:20:03.183Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "graph2mat" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ase" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "sisl", extra = ["viz"] }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/37/bf1deadade49d409d17c549f50b76f3f8de0c810817b49005dfc966c9f89/graph2mat-0.0.13.tar.gz", hash = "sha256:23f251ec044e0cc79c126c3cc687ada17708f316265d69f75d3ab76a14591a03", size = 1251793, upload-time = "2025-10-14T11:43:29.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/8b/7ebe6acdbd2bd8623a7014d411d4b447d8d6fa3994bfa16fae2b9fa39787/graph2mat-0.0.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfb9c25cb2aea6edd8f365355c81589558bcd7a6f734b626842995a6449ebd0e", size = 363873, upload-time = "2025-10-14T11:43:19.374Z" }, + { url = "https://files.pythonhosted.org/packages/54/69/d0916760e124f23ecd407c58428b3b9f00709897008cfdf5602b1525f9bd/graph2mat-0.0.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:350bbd488c75ffdf4821ad98cbbc1f05db1b0fd80f67dc0aa75028355e501ad7", size = 450680, upload-time = "2025-10-14T11:43:20.272Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ec/6766f2b92138563a73678ce96c61a5decf2e12cd5cedbe0f390c43a682b2/graph2mat-0.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da7d7bd08d65506957c1d369b2640d84c37894af7ed8ba78a1bcd29d07671549", size = 365265, upload-time = "2025-10-14T11:43:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/e1/86/a990e6340b06f366180007bdfbeadb3868b935834c159c2e9878f70a80a3/graph2mat-0.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a5eb39e897a39f53cc510e992afb1becdbe52a23cf9c487ebdc1164163ab752", size = 444182, upload-time = "2025-10-14T11:43:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/5e/51/a28401d4be00822f81557d47c14b8f2c344a866865081b336d24d2bc5c4f/graph2mat-0.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e010c75262b4fc5ff7dd1bf90b22d67c4ea4bc0d83a8a3c3507d4a8d2e3e79e0", size = 363479, upload-time = "2025-10-14T11:43:23.791Z" }, + { url = "https://files.pythonhosted.org/packages/ee/07/9c857c0d3ca21a553b4e80869f1145469f5ffa0b34d775a095a3fec81d21/graph2mat-0.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12f7697491a7b526485e4c136b0b5634f96b755495aae77b468a930dfbaac239", size = 445299, upload-time = "2025-10-14T11:43:24.91Z" }, +] + +[[package]] +name = "gunicorn" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" }, +] + +[[package]] +name = "h5py" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663, upload-time = "2026-03-06T13:47:49.599Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630, upload-time = "2026-03-06T13:47:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472, upload-time = "2026-03-06T13:47:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150, upload-time = "2026-03-06T13:47:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544, upload-time = "2026-03-06T13:47:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013, upload-time = "2026-03-06T13:47:59.01Z" }, + { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673, upload-time = "2026-03-06T13:48:00.626Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834, upload-time = "2026-03-06T13:48:02.579Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604, upload-time = "2026-03-06T13:48:04.198Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940, upload-time = "2026-03-06T13:48:05.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852, upload-time = "2026-03-06T13:48:07.482Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250, upload-time = "2026-03-06T13:48:09.628Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108, upload-time = "2026-03-06T13:48:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216, upload-time = "2026-03-06T13:48:13.322Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868, upload-time = "2026-03-06T13:48:15.759Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" }, + { url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/7fcd9b4c9eed82e91fb15568992561019ae7a829d1f696b2c844355d95dd/h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65", size = 3678608, upload-time = "2026-03-06T13:48:35.183Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210", size = 3054773, upload-time = "2026-03-06T13:48:37.139Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/4964bc0e91e86340c2bbda83420225b2f770dcf1eb8a39464871ad769436/h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965", size = 5198886, upload-time = "2026-03-06T13:48:38.879Z" }, + { url = "https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd", size = 5404883, upload-time = "2026-03-06T13:48:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/58f34cb74af46d39f4cd18ea20909a8514960c5a3e5b92fd06a28161e0a8/h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c", size = 5192039, upload-time = "2026-03-06T13:48:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ca/934a39c24ce2e2db017268c08da0537c20fa0be7e1549be3e977313fc8f5/h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc", size = 5421526, upload-time = "2026-03-06T13:48:44.838Z" }, + { url = "https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab", size = 3183263, upload-time = "2026-03-06T13:48:47.117Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/a6faef5ed632cae0c65ac6b214a6614a0b510c3183532c521bdb0055e117/h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63", size = 2663450, upload-time = "2026-03-06T13:48:48.707Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/0c8bb8aedb62c772cf7c1d427c7d1951477e8c2835f872bc0a13d1f85f86/h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491", size = 3760693, upload-time = "2026-03-06T13:48:50.453Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1f/fcc5977d32d6387c5c9a694afee716a5e20658ac08b3ff24fdec79fb05f2/h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618", size = 3181305, upload-time = "2026-03-06T13:48:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a1/af87f64b9f986889884243643621ebbd4ac72472ba8ec8cec891ac8e2ca1/h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242", size = 5074061, upload-time = "2026-03-06T13:48:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d0/146f5eaff3dc246a9c7f6e5e4f42bd45cc613bce16693bcd4d1f7c958bf5/h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16", size = 5279216, upload-time = "2026-03-06T13:48:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/12a13424f1e604fc7df9497b73c0356fb78c2fb206abd7465ce47226e8fd/h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7", size = 5070068, upload-time = "2026-03-06T13:48:59.169Z" }, + { url = "https://files.pythonhosted.org/packages/41/8c/bbe98f813722b4873818a8db3e15aa3e625b59278566905ac439725e8070/h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725", size = 5300253, upload-time = "2026-03-06T13:49:02.033Z" }, + { url = "https://files.pythonhosted.org/packages/32/9e/87e6705b4d6890e7cecdf876e2a7d3e40654a2ae37482d79a6f1b87f7b92/h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e", size = 3381671, upload-time = "2026-03-06T13:49:04.351Z" }, + { url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" }, +] + [[package]] name = "hf-xet" version = "1.1.5" @@ -626,6 +1298,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/fb/5307bd3612eb0f0e62c3a916ae531d3a31e58fb5c82b58e3ebf7fd6f47a1/huggingface_hub-0.33.1-py3-none-any.whl", hash = "sha256:ec8d7444628210c0ba27e968e3c4c973032d44dcea59ca0d78ef3f612196f095", size = 515377, upload-time = "2025-06-25T12:02:55.611Z" }, ] +[[package]] +name = "hydra-core" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "omegaconf" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/dd/220f0e91743136725352497e98540772a01fc7c3ab96ff16c3c74424e984/hydra_core-1.3.4.tar.gz", hash = "sha256:ad0f7b05a0242255a8984d5a4ed2f6847f7b783ed727368a2c0155ec52d6c34c", size = 3263348, upload-time = "2026-07-04T16:25:38.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/cd/a568610bafe991fdd3f628fb606316b3b2be52ded019284e895d9beb3a1e/hydra_core-1.3.4-py3-none-any.whl", hash = "sha256:e58683692904a09f1fdfffa1a9b86bfd94e215b59f1ee17e7cd7d92738090d33", size = 155478, upload-time = "2026-07-04T16:25:37.291Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -635,6 +1321,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + [[package]] name = "ipykernel" version = "6.29.5" @@ -693,6 +1392,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -718,28 +1426,94 @@ wheels = [ ] [[package]] -name = "joblib" -version = "1.5.1" +name = "jmespath" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/fe/0f5a938c54105553436dbff7a61dc4fed4b1b2c98852f8833beaf4d5968f/joblib-1.5.1.tar.gz", hash = "sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444", size = 330475, upload-time = "2025-05-23T12:04:37.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/4f/1195bbac8e0c2acc5f740661631d8d750dc38d4a32b23ee5df3cde6f4e0d/joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a", size = 307746, upload-time = "2025-05-23T12:04:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] [[package]] -name = "jupyter-client" -version = "8.6.3" +name = "jobflow" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jupyter-core" }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, + { name = "maggma" }, + { name = "monty" }, + { name = "networkx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pydash" }, + { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b5/565ba75e9daf597614be84eb7450ea08e6d08f4e4925bb991fcc91e234f4/jobflow-0.3.1.tar.gz", hash = "sha256:fbfa3c84598e7d69d489c93c95e2c85b4908497131451636fb8369aaaf180b0c", size = 57521, upload-time = "2026-02-05T07:57:05.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/3f1427486c021d7dee70ceabeadffaa6680b080977bc016e42e36a9ef38c/jobflow-0.3.1-py3-none-any.whl", hash = "sha256:2afb10eddad737eccc0cceaee9884405ed6818f003a74288b3740bd9a4da4470", size = 60586, upload-time = "2026-02-05T07:57:03.865Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/fe/0f5a938c54105553436dbff7a61dc4fed4b1b2c98852f8833beaf4d5968f/joblib-1.5.1.tar.gz", hash = "sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444", size = 330475, upload-time = "2025-05-23T12:04:37.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/4f/1195bbac8e0c2acc5f740661631d8d750dc38d4a32b23ee5df3cde6f4e0d/joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a", size = 307746, upload-time = "2025-05-23T12:04:35.124Z" }, +] + +[[package]] +name = "jsonlines" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/87/bcda8e46c88d0e34cad2f09ee2d0c7f5957bccdb9791b0b934ec84d84be4/jsonlines-4.0.0.tar.gz", hash = "sha256:0c6d2c09117550c089995247f605ae4cf77dd1533041d366351f6f298822ea74", size = 11359, upload-time = "2023-09-01T12:34:44.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, ] [[package]] @@ -822,21 +1596,125 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" }, ] +[[package]] +name = "latexcodec" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/dd/4270b2c5e2ee49316c3859e62293bd2ea8e382339d63ab7bbe9f39c0ec3b/latexcodec-3.0.1.tar.gz", hash = "sha256:e78a6911cd72f9dec35031c6ec23584de6842bfbc4610a9678868d14cdfb0357", size = 31222, upload-time = "2025-06-17T18:47:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/40/23569737873cc9637fd488606347e9dd92b9fa37ba4fcda1f98ee5219a97/latexcodec-3.0.1-py3-none-any.whl", hash = "sha256:a9eb8200bff693f0437a69581f7579eb6bca25c4193515c09900ce76451e452e", size = 18532, upload-time = "2025-06-17T18:47:30.726Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + [[package]] name = "lemat-rho" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "ase" }, + { name = "atomate2" }, + { name = "basis-set-exchange" }, + { name = "chemfiles" }, + { name = "e3nn" }, + { name = "fireworks" }, + { name = "graph2mat" }, + { name = "hydra-core" }, { name = "ipykernel" }, + { name = "lightning" }, + { name = "lmdb" }, + { name = "loguru" }, + { name = "lz4" }, { name = "material-hasher" }, + { name = "metatensor" }, + { name = "mp-pyrho" }, + { name = "numpy" }, + { name = "p-tqdm" }, + { name = "pyarrow" }, + { name = "pymatgen" }, + { name = "pyscf" }, + { name = "python-dotenv" }, + { name = "rootutils" }, + { name = "scipy" }, + { name = "torch" }, + { name = "torch-ema" }, + { name = "torch-geometric" }, + { name = "wandb" }, ] [package.metadata] requires-dist = [ { name = "ase", specifier = ">=3.25.0" }, + { name = "atomate2" }, + { name = "basis-set-exchange", specifier = ">=0.9.1" }, + { name = "chemfiles", specifier = ">=0.10.4" }, + { name = "e3nn", specifier = ">=0.5.0" }, + { name = "fireworks" }, + { name = "graph2mat", specifier = ">=0.0.13" }, + { name = "hydra-core", specifier = ">=1.3.2" }, { name = "ipykernel", specifier = ">=6.29.5" }, + { name = "lightning", specifier = ">=2.5.0" }, + { name = "lmdb", specifier = ">=1.4.1" }, + { name = "loguru", specifier = ">=0.7.0" }, + { name = "lz4", specifier = ">=4.0.0" }, { name = "material-hasher", git = "https://github.com/LeMaterial/lematerial-hasher" }, + { name = "metatensor", specifier = ">=0.2.0" }, + { name = "mp-pyrho", specifier = ">=0.3.0" }, + { name = "numpy", specifier = ">=1.24" }, + { name = "p-tqdm", specifier = ">=1.4.0" }, + { name = "pyarrow", specifier = ">=14.0.0" }, + { name = "pymatgen", specifier = ">=2023.11.12" }, + { name = "pyscf", specifier = ">=2.5.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "rootutils", specifier = ">=1.0.7" }, + { name = "scipy", specifier = ">=1.10.0" }, + { name = "torch", specifier = ">=2.0" }, + { name = "torch-ema", specifier = ">=0.3" }, + { name = "torch-geometric", specifier = ">=2.5.0" }, + { name = "wandb", specifier = ">=0.16.0" }, +] + +[[package]] +name = "lightning" +version = "2.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pytorch-lightning" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/1d/83be8536bec71a0173e762a9a1fd92a24a5ad0d0f74c59550c3c4e6c103b/lightning-2.6.5.tar.gz", hash = "sha256:16a30310ed69afde3748491feb5d13508908effd70390d2bfc203dc0812a4b4a", size = 659201, upload-time = "2026-05-27T14:33:41.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c5/fca7144236b6fa3279d0fb3172b32576c5ad8b84a63b9432ad6592d24847/lightning-2.6.5-py3-none-any.whl", hash = "sha256:3702fb7ef4ab51a8c3d4a140f4674514fe72973a9673dfa05e07a078e2767389", size = 848611, upload-time = "2026-05-27T14:33:39.714Z" }, +] + +[[package]] +name = "lightning-utilities" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" }, ] [[package]] @@ -862,6 +1740,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" }, ] +[[package]] +name = "lmdb" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0b/17f271b2d2d314da9c9bc7620676ede1feca5f565f3a46045319351f7bc2/lmdb-2.3.0.tar.gz", hash = "sha256:260f443640ee2da3cfd059a84258659319ff39ca912c16bf9748c324076b9d09", size = 954381, upload-time = "2026-07-12T15:46:30.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/63/176e0b81d24685ea3c6f90400dbac6537df70d8142cafd0e944aa1e642af/lmdb-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e0565381d748bd7b1730155ed172a42c49e7d37b60f3db0ad34b8c7ffa72a7a", size = 119191, upload-time = "2026-07-12T15:45:51.571Z" }, + { url = "https://files.pythonhosted.org/packages/06/b5/67fc45eb4a92d6ef67bd2783c3071316230c86214700dae26edbff7df791/lmdb-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51432a1bbd55aa207b91962afc0e0a203c28c654b68d08ee3f240184a718b6ea", size = 119380, upload-time = "2026-07-12T15:45:52.791Z" }, + { url = "https://files.pythonhosted.org/packages/22/87/1862e0e456b0876d31a1dc7406163b8561040aa278e6c8807394ab44d27c/lmdb-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99d24ad79cdf689169de30d1f15a17b598fcc17bcf076a83ea0f50039952ee36", size = 334746, upload-time = "2026-07-12T15:45:54.115Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9d/1a80616890a1f33295054d281dda60f4c851910807aed7d6261a377b8188/lmdb-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d0019870b5b93b2160fc324a609ffee1447024b2b8ce0da3b3f2f04b0131870", size = 336974, upload-time = "2026-07-12T15:45:55.43Z" }, + { url = "https://files.pythonhosted.org/packages/96/17/c510686a75ff7c2eb25a3909d856f925437df4468e6e4b151b01ccf60768/lmdb-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:abb42e1fa437c59a8f55aeb11a86639ae14edcabfe97f1ac227a9ededd3b4e47", size = 115267, upload-time = "2026-07-12T15:45:56.894Z" }, + { url = "https://files.pythonhosted.org/packages/98/f9/d884fd81b21c8b80706b53a1a1faabcc92e7672081bc8fbec2c802372836/lmdb-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b704763ec2b1dc9b73020311ab4be196da84f0fac637b1fb2e38d95b57a1b80", size = 114415, upload-time = "2026-07-12T15:45:58.023Z" }, + { url = "https://files.pythonhosted.org/packages/60/0e/f47e1cc957df2394525bb9dd7e5a99753791cb379860f20823ce9752bf69/lmdb-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bf3c116a1887d5c4d40b2126dee5dab5407a3eb2df0eb98c0c58a922ab29dc6d", size = 119965, upload-time = "2026-07-12T15:45:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/20/7d/7b08cfb872a2df7ab2892ff4012fdbe037017e33c4c65192dfd8b143a220/lmdb-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b21f095735a79a2192d92a23a5d876b3779a33eea61bb09bec858178890c5215", size = 119657, upload-time = "2026-07-12T15:46:00.368Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c1/215c43bb15e324a08af1f1536063fbcc388f4546ab3616fbe22d4b01b001/lmdb-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:841f1a33cc00b9e6ecbe6b8264068f1e853eb3004fc810290ce82847c8a2dc8b", size = 341726, upload-time = "2026-07-12T15:46:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/4978af32c9a1da70af4dc83460ef75e85ee0cf0735b934b520b4e96d0aec/lmdb-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c53bf2c1d0a03cf9ebb8caf48531f2a33313c5580af82ba4260b1e7fa52c857", size = 344652, upload-time = "2026-07-12T15:46:03.033Z" }, + { url = "https://files.pythonhosted.org/packages/95/03/a10dbc22a54a6aa46446e5fbb825dde736200726f3746809076928754332/lmdb-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:131ea41c72e387715141cbf240648337f59b717c0a01655f6fd78315fea60afe", size = 115188, upload-time = "2026-07-12T15:46:04.138Z" }, + { url = "https://files.pythonhosted.org/packages/83/e1/aab2adeca8db524267c5a1cf798cf9edab65890ee551076db869a96ab0f6/lmdb-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:9976c945c6957866a0dd8395057af29a1e6927396c8304948c60e684b6a4f6b8", size = 114439, upload-time = "2026-07-12T15:46:05.298Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/d8594c13c81652d313e5df0cbade98a3fa47de6c14e46aec5eed02a2ca5c/lmdb-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93de120dec1ec982b80852c958d11242b78c0972a526a839cc5a65b44d22c8f7", size = 120486, upload-time = "2026-07-12T15:46:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d3/c91996eb4ffeb1537710c4bf3492249b01abd56c75ec34a5b2179de4b91b/lmdb-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d94cd8515ea767115ac2ee302ae83cde3843425901fdc3bfa9fb08980299c023", size = 120032, upload-time = "2026-07-12T15:46:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0f/536d296dd90418533ccd277a20836c753f2f51d127a6e32e53c8653fbd45/lmdb-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1633f4700664436f2d71bb029bce9bdcaedcd2294c97ba853c6870c973de0bdd", size = 344681, upload-time = "2026-07-12T15:46:09.017Z" }, + { url = "https://files.pythonhosted.org/packages/79/45/1dc1ff9c998d08051728fef5f60eb31f8f9294a7bd80a786a6ae6917f2db/lmdb-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8d815c4f1ad38d048efeee395c935b8839ed244a1d74526c83e31c05faab3ec", size = 346786, upload-time = "2026-07-12T15:46:10.535Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/1bd0b10a0d7408f2d6a4c49be9176287bdccc8c3951a96abfd8f61eb5ec1/lmdb-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:f45e10949d0fc7a0cc4bc9b3bfe34d824b5a71330312289e43dc2638c26a9f13", size = 115087, upload-time = "2026-07-12T15:46:11.779Z" }, + { url = "https://files.pythonhosted.org/packages/80/f9/a4d82dedaf2a9090bb44c5ab300158a22f4c26a30355c7ffb40f52503bc6/lmdb-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:de099ca35b010fd0c5eed957e7ffda0aee32639fb7d12c0dadbb850be9c62dee", size = 114437, upload-time = "2026-07-12T15:46:12.867Z" }, + { url = "https://files.pythonhosted.org/packages/94/6c/0c582a5c1333836ca990e81449494cea45f0a87946e05ca6291284a48eeb/lmdb-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6369469befaf66ac8d599d0627e5190147eea2d81b735c6c5e64e8b629b1f10f", size = 120656, upload-time = "2026-07-12T15:46:14.075Z" }, + { url = "https://files.pythonhosted.org/packages/d4/02/4d9332c1c0914578de46b0b0e7e29b0f5a551b6221d7333add33f20aa843/lmdb-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:42c616d283be4c370d0cf89c83f0b6da77667f9edda62741f4b8bad95c9ba056", size = 120040, upload-time = "2026-07-12T15:46:15.24Z" }, + { url = "https://files.pythonhosted.org/packages/32/ff/0f6b3ac56e1ddcf2bad89b76608d6c1200b9e1c432739a9b6155965e3e3e/lmdb-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90b96fec0ef2d6ecfd225b9b10020b819699bf0e214ebe635f224d5cced20cf7", size = 344674, upload-time = "2026-07-12T15:46:16.441Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/81df0bb4297a549d2cfc0c658f321d15a47f460f4f9c0331b5ead2fcb366/lmdb-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cbf2f5eaf3db866345cb1139e0c6fef873a3c0758f01915ba6ed6ba12b24fe0", size = 346381, upload-time = "2026-07-12T15:46:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/3d/96/9e5b9eb951751f50931a0a8d4bb5d3ccda7ad5e377c8743a0846c14ce11b/lmdb-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:83225e119c799c911295d5a6f83bf25e52746609fd6907fc95bb8ae9d0f5a6d0", size = 116721, upload-time = "2026-07-12T15:46:19.057Z" }, + { url = "https://files.pythonhosted.org/packages/69/72/35ae2ebdf91e084857ec7f162397f2fd10feccac9b770e3f7d83c005d9be/lmdb-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:40ebeea45a92dfd5b67bf47fc66b5b026cf19e365ac9bab3a3140b6b1b9766f6", size = 116812, upload-time = "2026-07-12T15:46:20.132Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -875,6 +1785,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + +[[package]] +name = "maggma" +version = "0.72.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aioitertools" }, + { name = "boto3" }, + { name = "dnspython" }, + { name = "jsonlines" }, + { name = "jsonschema" }, + { name = "mongomock" }, + { name = "monty" }, + { name = "msgpack" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "pandas" }, + { name = "paramiko" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pydash" }, + { name = "pymongo" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "ruamel-yaml" }, + { name = "sshtunnel" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/b3/7281ee15ab1df6cae32bcd6698f3647d5026ef1fe54390e42ae6939fdeb2/maggma-0.72.1.tar.gz", hash = "sha256:929ea18ae92e1b91480541af84a36bca2c817e36483c6eae874438a2d523bd38", size = 224414, upload-time = "2026-02-11T18:52:46.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/df/e6ed9ae87af6941300f111b7cb1b69cdc5f605bb86e7815f5cc3d4043d22/maggma-0.72.1-py3-none-any.whl", hash = "sha256:5aa894a3a2c0cef6629bb122b8025125af2099d09b5b284c9adfd75d9b56dfb1", size = 123654, upload-time = "2026-02-11T18:52:44.788Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -993,6 +1995,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "metatensor" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "metatensor-core" }, + { name = "metatensor-learn" }, + { name = "metatensor-operations" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/58/172e96ccdca4d8d572579adc69b593dad79b74497c116ed86979257a5cbd/metatensor-0.2.0.tar.gz", hash = "sha256:ce3f8a34796d2aaa7e74b2d1392f64a05e85d1ca3e3878c1e9259e6a6a7a8138", size = 5373, upload-time = "2024-01-26T17:27:15.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/28/fd3f02ccb23764af794e953262127a7f2aed35073f460da6f279fe1c2b15/metatensor-0.2.0-py3-none-any.whl", hash = "sha256:60008fee73f49b349350d9d93dec63ea4e1cf30beceae17d543561d69a7ac393", size = 3702, upload-time = "2024-01-26T17:26:59.518Z" }, +] + +[[package]] +name = "metatensor-core" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/d5/18f05f73a0af0517dbbf441e673abf88bccfec6a92a1beeebbc9df9d5ed9/metatensor_core-0.2.0.tar.gz", hash = "sha256:30200451eb70e635fdef5dfd46476d0303b1757b1e34c23f9c9e568c9d188545", size = 177741, upload-time = "2026-05-13T15:45:51.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/99/4a81ad15c63b82be70e8e9ca1ae95b31b7c91d512b684c8a26fb0671a746/metatensor_core-0.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5e82760244c7233c41547d6c015f38caf7f3af589e0a7f827cad4a0c0ef0bbf", size = 549924, upload-time = "2026-05-13T15:45:08.494Z" }, + { url = "https://files.pythonhosted.org/packages/f0/11/8cd0fea97a5be6793596f573bb2fabf5dfd00a67884f9c77e6c7331c3921/metatensor_core-0.2.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:286f477f96520c046dff35dbc3a40ac3cfdef540e1c7bc071e91769f68dbb8f8", size = 582626, upload-time = "2026-05-13T15:45:18.982Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/91e7f49401597f0858087a3e603f98bb78d900895510b799fa445e1a4a8e/metatensor_core-0.2.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0529b6d3966fff6ad85e988443c2acf22d0251f52be38d4dce6fa4d617c0e81", size = 594606, upload-time = "2026-05-13T15:45:33.145Z" }, + { url = "https://files.pythonhosted.org/packages/bc/50/e090f6a2c56a6c822bac818ca5d900568a17df8ea6a2d1bf9f8d8cde9fc0/metatensor_core-0.2.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dbdf693cdb0436736e8d678e2d45dec5f8e47df18c7a4f775eb546c0106fb867", size = 634966, upload-time = "2026-05-13T15:45:39.684Z" }, + { url = "https://files.pythonhosted.org/packages/55/65/84df97b3922d50954644b06397e337e4a52da98ddd92f52a1532329d1378/metatensor_core-0.2.0-py3-none-win_amd64.whl", hash = "sha256:2b7dfc59c920b1d06dbebd2e7afa0a2395ea1ef01e437ad7a0e4d213f2034ce1", size = 533600, upload-time = "2026-05-13T15:45:44.907Z" }, +] + +[[package]] +name = "metatensor-learn" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "metatensor-core" }, + { name = "metatensor-operations" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bd/0fd1901b44635a24f40528a6244b5889143747ddeb841ae0201255c1f22e/metatensor_learn-0.5.0.tar.gz", hash = "sha256:0b1d30ed217d70de7851ed1d48421515d9c6a1be7f50d9b1b43f92a689be51d0", size = 25221, upload-time = "2026-05-13T15:45:54.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/85/f8e2061c58cf4ea22681be48f5aecf0074abd9717fcb8f05dd3ea6e370fc/metatensor_learn-0.5.0-py3-none-any.whl", hash = "sha256:ad8863dac144f03c9ca80ec625c9e35b87ceb82438a0a80c0bf14e9dcc1b607c", size = 32888, upload-time = "2026-05-13T15:45:49.25Z" }, +] + +[[package]] +name = "metatensor-operations" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "metatensor-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/98/83e132e8aca5bc05ffaffd342566ab4abd8e7bb579de6df1fde8b8602abb/metatensor_operations-0.5.0.tar.gz", hash = "sha256:e1cb0a8c358842e94ac3680fa9ec6f7a006cb519b6950ed1bb7001a209087cfc", size = 57735, upload-time = "2026-05-13T15:45:53.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/31/18d10b7d6d2ef5829a33c52cb0148730a951f0b3ad13aac5c4fae510ccfd/metatensor_operations-0.5.0-py3-none-any.whl", hash = "sha256:9536562c9e02a5c723fc118be671e8ff37e8e69caf2dc4a2bd97fca5271ec510", size = 79354, upload-time = "2026-05-13T15:45:47.855Z" }, +] + +[[package]] +name = "mongomock" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytz" }, + { name = "sentinels" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/a4/4a560a9f2a0bec43d5f63104f55bc48666d619ca74825c8ae156b08547cf/mongomock-4.3.0.tar.gz", hash = "sha256:32667b79066fabc12d4f17f16a8fd7361b5f4435208b3ba32c226e52212a8c30", size = 135862, upload-time = "2024-11-16T11:23:25.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/4d/8bea712978e3aff017a2ab50f262c620e9239cc36f348aae45e48d6a4786/mongomock-4.3.0-py2.py3-none-any.whl", hash = "sha256:5ef86bd12fc8806c6e7af32f21266c61b6c4ba96096f85129852d1c4fec1327e", size = 64891, upload-time = "2024-11-16T11:23:24.748Z" }, +] + [[package]] name = "monty" version = "2025.3.3" @@ -1022,6 +2102,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/b8/cedf1a9fa4365c24a687a7961a5e1551bebecbe709222b24cd7f566a4256/moyopy-0.4.3-cp39-abi3-win_amd64.whl", hash = "sha256:9c1cfb3bdccff84459dc96e0f99e3f632962caec0a309fa3e8b36e0d4f8480b7", size = 830520, upload-time = "2025-05-05T00:16:44.649Z" }, ] +[[package]] +name = "mp-pyrho" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymatgen" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/71/fe2a483fdb5e25f91aa65b06b8e799be06ea1ffd38799e9c0a3b6652ec4e/mp_pyrho-0.5.1.tar.gz", hash = "sha256:f77fd7383d08f07c94896885fb6183413a8ff0befa3dca9791db0bdf4dd02d58", size = 2421828, upload-time = "2025-10-13T01:32:43.953Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/26/7461a015cf35925e193f4afbe5b066a5fdb5eb76e87e80c63f50f614c3f4/mp_pyrho-0.5.1-py3-none-any.whl", hash = "sha256:934d90c6be35eeccc6b0d550771410b0801dbf4488aca96517f958b5045dd161", size = 18515, upload-time = "2025-10-13T01:32:42.619Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1031,6 +2123,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + [[package]] name = "multidict" version = "6.6.0" @@ -1138,6 +2283,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] +[[package]] +name = "netcdf4" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 'ARM64' and sys_platform == 'win32'", +] +dependencies = [ + { name = "certifi", marker = "platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "cftime", marker = "platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "numpy", marker = "platform_machine == 'ARM64' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/76/7bc801796dee752c1ce9cd6935564a6ee79d5c9d9ef9192f57b156495a35/netcdf4-1.7.3.tar.gz", hash = "sha256:83f122fc3415e92b1d4904fd6a0898468b5404c09432c34beb6b16c533884673", size = 836095, upload-time = "2025-10-13T18:38:00.76Z" } + +[[package]] +name = "netcdf4" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine != 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and platform_machine != 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", +] +dependencies = [ + { name = "certifi", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "cftime", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "numpy", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/b6/0370bb3af66a12098da06dc5843f3b349b7c83ccbdf7306e7afa6248b533/netcdf4-1.7.4.tar.gz", hash = "sha256:cdbfdc92d6f4d7192ca8506c9b3d4c1d9892969ff28d8e8e1fc97ca08bf12164", size = 838352, upload-time = "2026-01-05T02:27:38.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/de/38ed7e1956943d28e8ea74161e97c3a00fb98d6d08943b4fd21bae32c240/netcdf4-1.7.4-cp311-abi3-macosx_13_0_x86_64.whl", hash = "sha256:dec70e809cc65b04ebe95113ee9c85ba46a51c3a37c058d2b2b0cadc4d3052d8", size = 23427499, upload-time = "2026-01-05T02:27:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/2f73c133b71709c412bc81d8b721e28dc6237ba9d7dad861b7bfbb70408a/netcdf4-1.7.4-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:75cf59100f0775bc4d6b9d4aca7cbabd12e2b8cf3b9a4fb16d810b92743a315a", size = 22847667, upload-time = "2026-01-05T02:27:09.421Z" }, + { url = "https://files.pythonhosted.org/packages/77/ce/43a3c0c41a6e2e940d87feea79d29aa88302211ac122604838f8a5a48de6/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddfc7e9d261125c74708119440c85ea288b5fee41db676d2ba1ce9be11f96932", size = 10274769, upload-time = "2026-01-05T21:31:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7a/a8d32501bb95ecff342004a674720164f95ad616f269450b3bc13dc88ae3/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72c9f58767779ec14cb7451c3b56bdd8fdc027a792fac2062b14e090c5617f3", size = 10123122, upload-time = "2026-01-05T21:31:22.773Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/e89b4fa9242e59326c849c39ce0f49eb68499603c639405a8449900a4f15/netcdf4-1.7.4-cp311-abi3-win_amd64.whl", hash = "sha256:9476e1f23161ae5159cd1548c50c8a37922e77d76583e247133f256ef7b825fc", size = 21299637, upload-time = "2026-01-05T02:27:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/edd41a3607241027aa4533e7f18e0cd647e74dde10a63274c65350f59967/netcdf4-1.7.4-cp311-abi3-win_arm64.whl", hash = "sha256:876ad9d58f09c98741c066c726164c45a098a58fb90e5fac9e74de4bb8a793fd", size = 2386377, upload-time = "2026-01-05T02:27:13.808Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/1e83534ba68459bc5ae39df46fa71003984df58aabf31f7dcd6e22ecddb0/netcdf4-1.7.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56688c03444fffe0d0c7512cb45245e650389cd841c955b30e4552fa681c4cd9", size = 10519821, upload-time = "2026-01-05T02:27:15.413Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/a15d6fe97f81d6d5202b17838a9a298b5955b3e9971e20609195112829b5/netcdf4-1.7.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ecf471ba8a6ddb2200121949bedfa0095db228822f38227d5da680694a38358", size = 10371133, upload-time = "2026-01-05T02:27:17.224Z" }, + { url = "https://files.pythonhosted.org/packages/d8/2b/684b15dd4791f8be295b2f6fa97377bbc07a768478a63b7d3c4951712e36/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5841de0735e8e4875b367c668e81d334287858d64dd9f3e3e2261e808c84922", size = 10395635, upload-time = "2026-01-05T02:27:19.655Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/44d21524cf1b1c64254f92e22395a7a10f70c18f3a13a18ac9db258760f7/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fac03a8c5b250d57866e7d98918a64742e4b0de1681c5c86bac5726bab8aee", size = 10237725, upload-time = "2026-01-05T02:27:22.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9d/c3ddf54296ad8f18f02f77f23452bdb0971aece1b87e84bab9d734bf72cc/netcdf4-1.7.4-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:ad083d260301b5add74b1669c75ab0df03bdf986decfcc092cb45eec2615b5f1", size = 23515258, upload-time = "2026-01-05T02:27:24.837Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/bc0346e995d436d03fab682b7fbd2a9adcf0db6a05790b8f24853bf08170/netcdf4-1.7.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f22014092cc9da3f056b0368e2e38c42afd5725c87ad4843eb2f467e16dd4f6", size = 22910171, upload-time = "2026-01-05T02:27:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/6b/f9bc3f43c55e2dac72ee9f98d77860789bdd5d50c29adf164a6bdb303078/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224a15434c165a5e0225e5831f591edf62533044b1ce62fdfee815195bbd077d", size = 10567579, upload-time = "2026-01-05T02:27:29.382Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/e7685c66b7f011c73cd746127f986358a26c642a4e4a1aa5ab51481b6586/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31a2318305de6831a18df25ad0df9f03b6d68666af0356d4f6057d66c02ffeb6", size = 10255032, upload-time = "2026-01-05T02:27:31.744Z" }, + { url = "https://files.pythonhosted.org/packages/a6/14/7506738bb6c8bc373b01e5af8f3b727f83f4f496c6b108490ea2609dc2cf/netcdf4-1.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:6c4a0aa9446c3a616ef3be015b629dc6173643f8b09546de26a4e40e272cd1ed", size = 22289653, upload-time = "2026-01-05T02:27:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/2e/39d5e9179c543f2e6e149a65908f83afd9b6d64379a90789b323111761db/netcdf4-1.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:034220887d48da032cb2db5958f69759dbb04eb33e279ec6390571d4aea734fe", size = 2531682, upload-time = "2026-01-05T02:27:37.062Z" }, +] + [[package]] name = "networkx" version = "3.5" @@ -1147,6 +2348,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, ] +[[package]] +name = "nodify" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b4/d1a3da7364b94ea658aa257a248e817296019273d99c3773eb88768162b9/nodify-0.0.12.tar.gz", hash = "sha256:0905e42279f5958ed76cc67ced1c5e1cbc6c3e3e88763b0c838f7b7e0fba828a", size = 6538789, upload-time = "2025-10-09T23:24:57.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/de/c682cbbd8886eda756364be9e4e156a9906711a7b535a6691346e2a69061/nodify-0.0.12-py3-none-any.whl", hash = "sha256:8fae737a644a300fea9b68d4e296375da6cfb74b75dff84ea17aa197888473e6", size = 6610258, upload-time = "2025-10-09T23:24:56.437Z" }, +] + [[package]] name = "numba" version = "0.61.2" @@ -1355,6 +2565,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/4e/0d0c945463719429b7bd21dece907ad0bde437a2ff12b9b12fee94722ab0/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6574241a3ec5fdc9334353ab8c479fe75841dbe8f4532a8fc97ce63503330ba1", size = 89265, upload-time = "2024-10-01T17:00:38.172Z" }, ] +[[package]] +name = "omegaconf" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a", size = 3298472, upload-time = "2026-06-11T05:05:12.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/0e/152509871bf30df6fc38569f52a2db9b55dd41aae957adae50a053ac7778/omegaconf-2.3.1-py3-none-any.whl", hash = "sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0", size = 79502, upload-time = "2026-06-11T05:05:09.954Z" }, +] + +[[package]] +name = "opt-einsum" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/b9/2ac072041e899a52f20cf9510850ff58295003aa75525e58343591b0cbfb/opt_einsum-3.4.0.tar.gz", hash = "sha256:96ca72f1b886d148241348783498194c577fa30a8faac108586b14f1ba4473ac", size = 63004, upload-time = "2024-09-26T14:33:24.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl", hash = "sha256:69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd", size = 71932, upload-time = "2024-09-26T14:33:23.039Z" }, +] + +[[package]] +name = "opt-einsum-fx" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opt-einsum" }, + { name = "packaging" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/de/856dab99be0360c7275fee075eb0450a2ec82a54c4c33689606f62e9615b/opt_einsum_fx-0.1.4.tar.gz", hash = "sha256:7eeb7f91ecb70be65e6179c106ea7f64fc1db6319e3d1289a4518b384f81e74f", size = 12969, upload-time = "2021-11-07T20:49:33.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/4c/e0370709aaf9d7ceb68f975cac559751e75954429a77e83202e680606560/opt_einsum_fx-0.1.4-py3-none-any.whl", hash = "sha256:85f489f4c7c31fd88d5faf9669c09e61ec37a30098809fdcfe2a08a9e42f23c9", size = 13213, upload-time = "2021-11-07T20:49:32.395Z" }, +] + [[package]] name = "orjson" version = "3.10.18" @@ -1408,6 +2654,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186, upload-time = "2025-04-29T23:29:41.922Z" }, ] +[[package]] +name = "p-tqdm" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathos" }, + { name = "six" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/70/d4539ed4e2b5e17f170c1c971a98459cbe95eb9ed7e475298ededb56af22/p_tqdm-1.4.2.tar.gz", hash = "sha256:0f860c5facd0b0059da39998e55cfc035563f92d85d2f4895ba88a675c3c7529", size = 6018, upload-time = "2024-08-08T09:33:41.951Z" } + [[package]] name = "packaging" version = "25.0" @@ -1467,6 +2724,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/c2/646d2e93e0af70f4e5359d870a63584dacbc324b54d73e6b3267920ff117/pandas-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bb3be958022198531eb7ec2008cfc78c5b1eed51af8600c6c5d9160d89d8d249", size = 13231847, upload-time = "2025-06-05T03:27:51.465Z" }, ] +[[package]] +name = "paramiko" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography", version = "45.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, + { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14' or platform_python_implementation == 'PyPy'" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size = 1566110, upload-time = "2025-02-04T02:37:59.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298, upload-time = "2025-02-04T02:37:57.672Z" }, +] + [[package]] name = "parso" version = "0.8.4" @@ -1476,6 +2748,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, ] +[[package]] +name = "pathos" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, + { name = "multiprocess" }, + { name = "pox" }, + { name = "ppft" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/99/7fcb91495e40735958a576b9bde930cc402d594e9ad5277bdc9b6326e1c8/pathos-0.3.2.tar.gz", hash = "sha256:4f2a42bc1e10ccf0fe71961e7145fc1437018b6b21bd93b2446abc3983e49a7a", size = 166506, upload-time = "2024-01-28T19:11:27.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7f/cea34872c000d17972dad998575d14656d7c6bcf1a08a8d66d73c1ef2cca/pathos-0.3.2-py3-none-any.whl", hash = "sha256:d669275e6eb4b3fbcd2846d7a6d1bba315fe23add0c614445ba1408d8b38bafe", size = 82075, upload-time = "2024-01-28T19:11:25.56Z" }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -1579,20 +2866,38 @@ wheels = [ ] [[package]] -name = "prompt-toolkit" -version = "3.0.51" +name = "pox" +version = "0.3.7" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/58/4385741dea1d74fe9dfed7ff42975266634ef8000f2c8e96717079c916b1/pox-0.3.7.tar.gz", hash = "sha256:0652f6f2103fe6d4ba638beb6fa8d3e8a68fd44bcb63315c614118515bcc3afb", size = 119442, upload-time = "2026-01-19T02:09:12.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ac/4d5f104edf2aae2fec85567ec1d1969010de8124c5c45514f25e14900b65/pox-0.3.7-py3-none-any.whl", hash = "sha256:82a495249d13371314c1a5b5626a115e067ef5215d49530bf5efa37fbc25b56a", size = 29402, upload-time = "2026-01-19T02:09:11.024Z" }, ] [[package]] -name = "propcache" -version = "0.3.2" +name = "ppft" +version = "1.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/d2/281aa3466e948283d51b83238fb456f65e14f8ade5f8627822578cd2708f/ppft-1.7.8.tar.gz", hash = "sha256:5f696d4f397ae9b0af39b1faffb31957c51dfbc5a3815856472d4f4e872937ee", size = 136349, upload-time = "2026-01-19T03:03:13.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/e1/d1b380af6443e7c33aeb40617ebdc17c39dc30095235643cc518e3908203/ppft-1.7.8-py3-none-any.whl", hash = "sha256:d3e0e395215b14afc3dd5adfc032ccecfda2d4ed50dc7ded076cd1d215442843", size = 56759, upload-time = "2026-01-19T03:03:11.896Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } wheels = [ @@ -1663,6 +2968,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "protobuf" +version = "7.35.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, + { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, + { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, + { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, +] + [[package]] name = "psutil" version = "7.0.0" @@ -1740,6 +3060,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/40/ad395740cd641869a13bcf60851296c89624662575621968dcfafabaa7f6/pyarrow-20.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:82f1ee5133bd8f49d31be1299dc07f585136679666b502540db854968576faf9", size = 25944982, upload-time = "2025-04-27T12:33:04.72Z" }, ] +[[package]] +name = "pybtex" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "latexcodec" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/bc/c2be05ca72f8c103670e983df8be26d1e288bc6556f487fa8cccaa27779f/pybtex-0.25.1.tar.gz", hash = "sha256:9eaf90267c7e83e225af89fea65c370afbf65f458220d3946a9e3049e1eca491", size = 406157, upload-time = "2025-06-26T13:27:41.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/68/ceb5d6679baa326261f5d3e5113d9cfed6efef2810afd9f18bffb8ed312b/pybtex-0.25.1-py2.py3-none-any.whl", hash = "sha256:9053b0d619409a0a83f38abad5d9921de5f7b3ede00742beafcd9f10ad0d8c5c", size = 127437, upload-time = "2025-06-26T13:27:43.585Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -1749,6 +3082,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pydash" +version = "8.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/c1/1c55272f49d761cec38ddb80be9817935b9c91ebd6a8988e10f532868d56/pydash-8.0.6.tar.gz", hash = "sha256:b2821547e9723f69cf3a986be4db64de41730be149b2641947ecd12e1e11025a", size = 164338, upload-time = "2026-01-17T16:42:56.576Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/b7/cc5e7974699db40014d58c7dd7c4ad4ffc244d36930dc9ec7d06ee67d7a9/pydash-8.0.6-py3-none-any.whl", hash = "sha256:ee70a81a5b292c007f28f03a4ee8e75c1f5d7576df5457b836ec7ab2839cc5d0", size = 101561, upload-time = "2026-01-17T16:42:55.448Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1803,6 +3274,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/59a5db27226837c5692243e325b58d24931872ce8b7d700a6c2defc10b7f/pymatgen-2025.6.14-cp313-cp313-win_amd64.whl", hash = "sha256:de9c66bcb7c5c5f7dc0a2819106a4c08ceadb0a0517aa7c688096606175d0ae4", size = 3663370, upload-time = "2025-06-14T22:21:15.936Z" }, ] +[[package]] +name = "pymatgen-io-validation" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pymatgen" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/20/9a5b96d413f7678ef4fd0b689ae20aa406b512013531eec7c7fa7128f544/pymatgen_io_validation-0.1.2.tar.gz", hash = "sha256:76632878ab2269356092dab5bae08c8f42418c3e15d4610f4eb304f0102e7f24", size = 51515, upload-time = "2025-09-16T16:52:43.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fa/30b0dc764abc321693d16621482d02aa13ef569ba193c443c9411ac818a8/pymatgen_io_validation-0.1.2-py3-none-any.whl", hash = "sha256:765cb7f5f98422193d46518792be76ea5683c7093b444088f21dd6d5d217dc65", size = 46381, upload-time = "2025-09-16T16:52:42.391Z" }, +] + +[[package]] +name = "pymongo" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/35/b62a3139f908c68b69aac6a6a3f8cc146869de0a7929b994600e2c587c77/pymongo-4.10.1.tar.gz", hash = "sha256:a9de02be53b6bb98efe0b9eda84ffa1ec027fcb23a2de62c4f941d9a2f2f3330", size = 1903902, upload-time = "2024-10-01T23:07:58.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/a3/d6403ec53fa2fe922b4a5c86388ea5fada01dd51d803e17bb2a7c9cda839/pymongo-4.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:57ee6becae534e6d47848c97f6a6dff69e3cce7c70648d6049bd586764febe59", size = 889238, upload-time = "2024-10-01T23:06:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/29/a2/9643450424bcf241e80bb713497ec2e3273c183d548b4eca357f75d71885/pymongo-4.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6f437a612f4d4f7aca1812311b1e84477145e950fdafe3285b687ab8c52541f3", size = 889504, upload-time = "2024-10-01T23:06:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/ec/40/4759984f34415509e9111be8ee863034611affdc1e0b41016c9d53b2f1b3/pymongo-4.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a970fd3117ab40a4001c3dad333bbf3c43687d90f35287a6237149b5ccae61d", size = 1649069, upload-time = "2024-10-01T23:06:38.553Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/b6e917478a3ada81e768475516cd544982cc42cbb7d3be325182768139e1/pymongo-4.10.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c4d0e7cd08ef9f8fbf2d15ba281ed55604368a32752e476250724c3ce36c72e", size = 1714927, upload-time = "2024-10-01T23:06:40.292Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/4237d94dfa19ebdf9a92b1071e2139c91f48908c5782e592c571c33b67ab/pymongo-4.10.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca6f700cff6833de4872a4e738f43123db34400173558b558ae079b5535857a4", size = 1683454, upload-time = "2024-10-01T23:06:42.257Z" }, + { url = "https://files.pythonhosted.org/packages/9a/16/dbffca9d4ad66f2a325c280f1177912fa23235987f7b9033e283da889b7a/pymongo-4.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cec237c305fcbeef75c0bcbe9d223d1e22a6e3ba1b53b2f0b79d3d29c742b45b", size = 1653840, upload-time = "2024-10-01T23:06:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4d/21df934ef5cf8f0e587bac922a129e13d4c0346c54e9bf2371b90dd31112/pymongo-4.10.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3337804ea0394a06e916add4e5fac1c89902f1b6f33936074a12505cab4ff05", size = 1613233, upload-time = "2024-10-01T23:06:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/24/07/dd9c3db30e754680606295d5574521956898005db0629411a89163cc6eee/pymongo-4.10.1-cp311-cp311-win32.whl", hash = "sha256:778ac646ce6ac1e469664062dfe9ae1f5c9961f7790682809f5ec3b8fda29d65", size = 857331, upload-time = "2024-10-01T23:06:47.812Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/b71c4106d03eef2482eade440c6f5737c2a4a42f6155726009f80ea38d06/pymongo-4.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:9df4ab5594fdd208dcba81be815fa8a8a5d8dedaf3b346cbf8b61c7296246a7a", size = 876473, upload-time = "2024-10-01T23:06:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/10/d1/60ad99fe3f64d45e6c71ac0e3078e88d9b64112b1bae571fc3707344d6d1/pymongo-4.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fbedc4617faa0edf423621bb0b3b8707836687161210d470e69a4184be9ca011", size = 943356, upload-time = "2024-10-01T23:06:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9b/21d4c6b4ee9c1fa9691c68dc2a52565e0acb644b9e95148569b4736a4ebd/pymongo-4.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7bd26b2aec8ceeb95a5d948d5cc0f62b0eb6d66f3f4230705c1e3d3d2c04ec76", size = 943142, upload-time = "2024-10-01T23:06:52.146Z" }, + { url = "https://files.pythonhosted.org/packages/07/af/691b7454e219a8eb2d1641aecedd607e3a94b93650c2011ad8a8fd74ef9f/pymongo-4.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb104c3c2a78d9d85571c8ac90ec4f95bca9b297c6eee5ada71fabf1129e1674", size = 1909129, upload-time = "2024-10-01T23:06:53.551Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/fd75d5ad4181d6e71ce0fca32404fb71b5046ac84d9a1a2f0862262dd032/pymongo-4.10.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4924355245a9c79f77b5cda2db36e0f75ece5faf9f84d16014c0a297f6d66786", size = 1987763, upload-time = "2024-10-01T23:06:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/8a/56/6d3d0ef63c6d8cb98c7c653a3a2e617675f77a95f3853851d17a7664876a/pymongo-4.10.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11280809e5dacaef4971113f0b4ff4696ee94cfdb720019ff4fa4f9635138252", size = 1950821, upload-time = "2024-10-01T23:06:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/70/ed/1603fa0c0e51444752c3fa91f16c3a97e6d92eb9fe5e553dae4f18df16f6/pymongo-4.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5d55f2a82e5eb23795f724991cac2bffbb1c0f219c0ba3bf73a835f97f1bb2e", size = 1912247, upload-time = "2024-10-01T23:06:59.023Z" }, + { url = "https://files.pythonhosted.org/packages/c1/66/e98b2308971d45667cb8179d4d66deca47336c90663a7e0527589f1038b7/pymongo-4.10.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e974ab16a60be71a8dfad4e5afccf8dd05d41c758060f5d5bda9a758605d9a5d", size = 1862230, upload-time = "2024-10-01T23:07:01.407Z" }, + { url = "https://files.pythonhosted.org/packages/6c/80/ba9b7ed212a5f8cf8ad7037ed5bbebc1c587fc09242108f153776e4a338b/pymongo-4.10.1-cp312-cp312-win32.whl", hash = "sha256:544890085d9641f271d4f7a47684450ed4a7344d6b72d5968bfae32203b1bb7c", size = 903045, upload-time = "2024-10-01T23:07:02.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/8b/5afce891d78159912c43726fab32641e3f9718f14be40f978c148ea8db48/pymongo-4.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:dcc07b1277e8b4bf4d7382ca133850e323b7ab048b8353af496d050671c7ac52", size = 926686, upload-time = "2024-10-01T23:07:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/83/76/df0fd0622a85b652ad0f91ec8a0ebfd0cb86af6caec8999a22a1f7481203/pymongo-4.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:90bc6912948dfc8c363f4ead54d54a02a15a7fee6cfafb36dc450fc8962d2cb7", size = 996981, upload-time = "2024-10-01T23:07:06.001Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/fa50531de8d1d8af8c253caeed20c18ccbf1de5d970119c4a42c89f2bd09/pymongo-4.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:594dd721b81f301f33e843453638e02d92f63c198358e5a0fa8b8d0b1218dabc", size = 996769, upload-time = "2024-10-01T23:07:07.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/50/6936612c1b2e32d95c30e860552d3bc9e55cfa79a4f73b73225fa05a028c/pymongo-4.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0783e0c8e95397c84e9cf8ab092ab1e5dd7c769aec0ef3a5838ae7173b98dea0", size = 2169159, upload-time = "2024-10-01T23:07:09.963Z" }, + { url = "https://files.pythonhosted.org/packages/78/8c/45cb23096e66c7b1da62bb8d9c7ac2280e7c1071e13841e7fb71bd44fd9f/pymongo-4.10.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fb6a72e88df46d1c1040fd32cd2d2c5e58722e5d3e31060a0393f04ad3283de", size = 2260569, upload-time = "2024-10-01T23:07:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/e5ec697087e527a6a15c5f8daa5bcbd641edb8813487345aaf963d3537dc/pymongo-4.10.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e3a593333e20c87415420a4fb76c00b7aae49b6361d2e2205b6fece0563bf40", size = 2218142, upload-time = "2024-10-01T23:07:13.61Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8a/c0b45bee0f0c57732c5c36da5122c1796efd5a62d585fbc504e2f1401244/pymongo-4.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72e2ace7456167c71cfeca7dcb47bd5dceda7db2231265b80fc625c5e8073186", size = 2170623, upload-time = "2024-10-01T23:07:15.319Z" }, + { url = "https://files.pythonhosted.org/packages/3b/26/6c0a5360a571df24c9bfbd51b1dae279f4f0c511bdbc0906f6df6d1543fa/pymongo-4.10.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ad05eb9c97e4f589ed9e74a00fcaac0d443ccd14f38d1258eb4c39a35dd722b", size = 2111112, upload-time = "2024-10-01T23:07:16.859Z" }, + { url = "https://files.pythonhosted.org/packages/38/bc/5b91b728e1cf505d931f04e24cbac71ae519523785570ed046cdc31e6efc/pymongo-4.10.1-cp313-cp313-win32.whl", hash = "sha256:ee4c86d8e6872a61f7888fc96577b0ea165eb3bdb0d841962b444fa36001e2bb", size = 948727, upload-time = "2024-10-01T23:07:18.275Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/7c24a6144eaa06d18ed52822ea2b0f119fd9267cd1abbb75dae4d89a3803/pymongo-4.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:45ee87a4e12337353242bc758accc7fb47a2f2d9ecc0382a61e64c8f01e86708", size = 976873, upload-time = "2024-10-01T23:07:19.721Z" }, +] + +[[package]] +name = "pynacl" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" }, + { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" }, +] + [[package]] name = "pyparsing" version = "3.2.3" @@ -1812,6 +3357,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, ] +[[package]] +name = "pyscf" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h5py" }, + { name = "numpy" }, + { name = "psutil", marker = "sys_platform == 'win32'" }, + { name = "scipy" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/76/4b4f33877f4ee460733bd8b9af64cb9c13be9d5111fb8f6357d227c2c908/pyscf-2.14.0.tar.gz", hash = "sha256:9b6ea3c2470baac9d0492d93b335542c04ab96277e79f1abcbe1cea0eb063c5f", size = 11565773, upload-time = "2026-07-18T22:50:28.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/6e/16109bef2ed8b276c954e5a951e14eaa501d8f79ee928b64bc6fd0d2b440/pyscf-2.14.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:2f4964d64c5cbb7eaa363ed8a5d1b7cc8541f2008b9c3c81c22494d789fbcd33", size = 37156346, upload-time = "2026-07-18T22:27:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/48/ed/bb4aeb728505b4ce7edd55e9b2e35bcbd99ad0b0cae5c45b59e48b9ed62d/pyscf-2.14.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4bdc6f4155e258d78c90b4de0c88ab9260a45801264d6c471c3beec83a4da4c", size = 36492544, upload-time = "2026-07-18T22:24:50.025Z" }, + { url = "https://files.pythonhosted.org/packages/89/f2/24c58dad0c33ab417c5a090695a6788292d5d4edceae59bad055e5377e92/pyscf-2.14.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2c01234a5480e545e5e156d1fabe9b97264bf87ee274c62d14478ab9fe644100", size = 46031836, upload-time = "2026-07-19T00:57:07.889Z" }, + { url = "https://files.pythonhosted.org/packages/88/30/8e48717aff32f11fd78f981192d0b567971eb285ea048414b7dac2a211cf/pyscf-2.14.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b0bccc55450311a55318cd643e851353331ddeab4fc0c0065e83c905e41502", size = 52848663, upload-time = "2026-07-18T22:49:58.707Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1824,6 +3388,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pytorch-lightning" +version = "2.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/2c/8e73a3929b4c4bd600cafd38a97aaf7242a8cf518fb9f33d27c274ec898f/pytorch_lightning-2.6.5.tar.gz", hash = "sha256:1c32cefa76a1a9c4c5250338272d961d1e48b180e68396849efe128538ddb28e", size = 661673, upload-time = "2026-05-27T14:33:41.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/4d/5740c27110b83634d8491c3b5facf0111b3e554c3164f4fb953be9bddaf6/pytorch_lightning-2.6.5-py3-none-any.whl", hash = "sha256:62d9c8549b2278fedc3364f0a5607a56c6063d18635008f8cf3fae8d802b0d76", size = 852407, upload-time = "2026-05-27T14:33:39.856Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -1929,6 +3521,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/8a/4a3764a68abc02e2fbb0668d225b6fda5cd39586dd099cee8b2ed6ab0452/pyzmq-27.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9df43a2459cd3a3563404c1456b2c4c69564daa7dbaf15724c09821a3329ce46", size = 544726, upload-time = "2025-06-13T14:08:49.903Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + [[package]] name = "requests" version = "2.32.4" @@ -1944,6 +3654,139 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rootutils" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/9e/62489bd92acc537ed902fc3ebc0e055e8a96c6ca3071dfdecd9c513176ec/rootutils-1.0.7.tar.gz", hash = "sha256:7e2444cdbf4a73a907875fb109d55a38b4ee21313cef305bf59fadbf540440bc", size = 5730, upload-time = "2023-05-19T13:05:21.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/17/1e8ba0be5b52aa7a18f0df7bd6ab31345a3a4f515d6beac6a765fcacaaa3/rootutils-1.0.7-py3-none-any.whl", hash = "sha256:89f1994e6d0f499db7aca7f90edc146801cb33c8565a15c7cee4a4e4fc8c6eef", size = 6403, upload-time = "2023-05-19T13:05:19.416Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + [[package]] name = "ruamel-yaml" version = "0.18.14" @@ -1991,6 +3834,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.5.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/16/8a407688b607f86f81f8c649bf0d68a2a6d67375f18c2d660aba20f5b648/scikit_image-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0", size = 12355510, upload-time = "2025-12-20T17:10:31.628Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f9/7efc088ececb6f6868fd4475e16cfafc11f242ce9ab5fc3557d78b5da0d4/scikit_image-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd", size = 12056334, upload-time = "2025-12-20T17:10:34.559Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/bc7fb91fb5ff65ef42346c8b7ee8b09b04eabf89235ab7dbfdfd96cbd1ea/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953", size = 13297768, upload-time = "2025-12-20T17:10:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af", size = 13711217, upload-time = "2025-12-20T17:10:40.935Z" }, + { url = "https://files.pythonhosted.org/packages/d4/59/9637ee12c23726266b91296791465218973ce1ad3e4c56fc81e4d8e7d6e1/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d", size = 14337782, upload-time = "2025-12-20T17:10:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5c/a3e1e0860f9294663f540c117e4bf83d55e5b47c281d475cc06227e88411/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d", size = 14805997, upload-time = "2025-12-20T17:10:45.93Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c6/2eeacf173da041a9e388975f54e5c49df750757fcfc3ee293cdbbae1ea0a/scikit_image-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7", size = 11878486, upload-time = "2025-12-20T17:10:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a4/a852c4949b9058d585e762a66bf7e9a2cd3be4795cd940413dfbfbb0ce79/scikit_image-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581", size = 11346518, upload-time = "2025-12-20T17:10:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, + { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + [[package]] name = "scikit-learn" version = "1.7.0" @@ -2071,6 +3993,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/c4/231cac7a8385394ebbbb4f1ca662203e9d8c332825ab4f36ffc3ead09a42/scipy-1.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f56296fefca67ba605fd74d12f7bd23636267731a72cb3947963e76b8c0a25db", size = 38515076, upload-time = "2025-06-22T16:21:45.694Z" }, ] +[[package]] +name = "sentinels" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/9b/07195878aa25fe6ed209ec74bc55ae3e3d263b60a489c6e73fdca3c8fe05/sentinels-1.1.1.tar.gz", hash = "sha256:3c2f64f754187c19e0a1a029b148b74cf58dd12ec27b4e19c0e5d6e22b5a9a86", size = 4393, upload-time = "2025-08-12T07:57:50.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/65/dea992c6a97074f6d8ff9eab34741298cac2ce23e2b6c74fb7d08afdf85c/sentinels-1.1.1-py3-none-any.whl", hash = "sha256:835d3b28f3b47f5284afa4bf2db6e00f2dc5f80f9923d4b7e7aeeeccf6146a11", size = 3744, upload-time = "2025-08-12T07:57:48.858Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -2080,6 +4024,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sisl" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pyparsing" }, + { name = "scipy" }, + { name = "xarray" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/8a/ce69ddd9495b8cd52a99eb631a3176a5818fd5bfcbfde941c9efe1a5c876/sisl-0.16.4.tar.gz", hash = "sha256:bba5fd45a6286d20eabd1232ea83d830d63f343c6212021034c31d53dee928a3", size = 3177153, upload-time = "2026-03-19T08:50:53.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/7a/9007c5afa91664b5f345f02568deec141c3c8a6e2cfeae2eefc4d3d88d66/sisl-0.16.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cb7f48cb60b3debd53395485066048ff081d182fdf29d8697c62e95feba0df28", size = 4891948, upload-time = "2026-03-19T08:50:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a4/bb196b01aa330c04566cc299e556d07440e4d781ddf0080c3c09e4da9994/sisl-0.16.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c112e5dd7a0d6736a1b851fb5c1f703dee6ca1e76790c09f2858b56cc1f3808f", size = 5680212, upload-time = "2026-03-19T08:50:26.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/76c8dca17a7298867c05ee3bf787f8c8e90dea04b990ebd5abbfef533094/sisl-0.16.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b29fee46cc73f02c79f7419cf4b998ef5cddd904a28550384fa2ed2c991fd3ca", size = 6091340, upload-time = "2026-03-19T08:50:27.826Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2b/a1d6f7f540f409675a3be73932a7f711fc2a29c5d01dde1f38a17cce7b90/sisl-0.16.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:777533765f992f1cf2b1bd391cba2358826f1689ce3ca0fd93ebfb493b17491e", size = 4744733, upload-time = "2026-03-19T08:50:29.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/ec019ead6f34c26a586999b3a565548acce170fa4acb711026f30c42831b/sisl-0.16.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a99fcd7af36162e24b9acf33f39c01c192a5910a2504c7bb8991cce87b182d50", size = 5555109, upload-time = "2026-03-19T08:50:31.194Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/9683d84d0fe7f0dc83a8d1d42e81e9b117f006ab4e140b42cdbe7578cf3b/sisl-0.16.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94bb737f35ed9a64aaaa1fbec45f42e32ac884980c57552efcca9c3ef6534e39", size = 5986217, upload-time = "2026-03-19T08:50:32.644Z" }, + { url = "https://files.pythonhosted.org/packages/8a/67/acb7224f88ad16686c9cb58122063389a200298b2ee74f2dfa218ed76ce7/sisl-0.16.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b2b259e6c65446446004afe8a2704f71a88f25333a26e8bec2a272b0790e0dca", size = 4871758, upload-time = "2026-03-19T08:50:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/17c235535c6ee253c4e8a25c2995a45a0aea28b81fbc26be402a15a0ce6e/sisl-0.16.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:423cf217a3d139d9d6000a694851f9eb5b16a92dbd33cff6490b7f6a90ffe796", size = 5546346, upload-time = "2026-03-19T08:50:35.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/68/6d908b2590ad0f925a5724ec4827c42b36e72cf2ad733de851d054d98937/sisl-0.16.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:464264f96f39c186e8a71b054983884dae94d18c92725a69fb8caecbbb831acd", size = 5973087, upload-time = "2026-03-19T08:50:37.637Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4e/f03ca37ad48ef969b564444bf0e364d1362ee4fb037350ff5777baa62a2e/sisl-0.16.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:8bcded1e5b015155d03d756aa87f4748c049f37c2b9913e7ea3006b3b931539a", size = 4541276, upload-time = "2026-03-19T08:50:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/9b/967ea173e01c4700430147f5f325b64720f8ab334483db691fbc0fa7a2a8/sisl-0.16.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bb18d3cf141eb75559c99ea3613563a4041757630684375b59a8cb0510ca8ab", size = 5299450, upload-time = "2026-03-19T08:50:40.833Z" }, + { url = "https://files.pythonhosted.org/packages/78/3c/c11255084e02f2100702247eaf8ab3a92ea8a6e5b64a4aa5a792322b6809/sisl-0.16.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b5d9b5b44577cb14e82a1321dd121498e80d1350bcbbadd700eb68e8a9ca2f41", size = 5708911, upload-time = "2026-03-19T08:50:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/c649f133a60379950a61b09c2ebbc4406b6eeb4c9ae8243bafec5ef7f1c5/sisl-0.16.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1a5fd37ada238b5e84e0c03de14e3de8983e0cdf0a67de5b5d9cbfb5c3000c32", size = 4900289, upload-time = "2026-03-19T08:50:44.137Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a8/3807ebe875a7422eedcfab00878fac4f59c7723dd82878dfd706d7ad6ba5/sisl-0.16.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70cc5b25a50a9709fcda9d1001c2a451c24c06aba282dccb2f35756f79025395", size = 5570979, upload-time = "2026-03-19T08:50:45.602Z" }, + { url = "https://files.pythonhosted.org/packages/a9/71/35d856ee9285baab216b20113d9f24ca5a77d0dfbb07195d41e3ebc71d53/sisl-0.16.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1d9115c6444ad628f575f190eb15a1bf0e8ff248d48d3ba8da31bfa299f648ec", size = 5980274, upload-time = "2026-03-19T08:50:47.57Z" }, + { url = "https://files.pythonhosted.org/packages/6a/73/dce43b5920137836fa0f428456f357c6282307f23e2aa2022d111bde7e86/sisl-0.16.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:676d53a450f2133ebf9342b6c694c9e4cdebb7f6086d7e09e09001e02560b89e", size = 4561158, upload-time = "2026-03-19T08:50:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/8ca7cc4f11641d23bcc8febd73a150c0a48da1dfbaaa1726ea853c9b6dde/sisl-0.16.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ea22bbc84c2d2416ddc503578a271523ef00e978ae9fce0092fad37549910c9", size = 5302034, upload-time = "2026-03-19T08:50:50.409Z" }, + { url = "https://files.pythonhosted.org/packages/be/9f/4644f89d1121b98c0fd478ad2c7391faab1c1f6a616157e5379b099baddc/sisl-0.16.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:44206fe83252785b43e84800ffe36cc7eb536ab9f1d7e8b537405168703f7469", size = 5714058, upload-time = "2026-03-19T08:50:52.193Z" }, +] + +[package.optional-dependencies] +viz = [ + { name = "ase" }, + { name = "dill" }, + { name = "matplotlib" }, + { name = "netcdf4", version = "1.7.3", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "netcdf4", version = "1.7.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "nodify" }, + { name = "pathos" }, + { name = "plotly" }, + { name = "scikit-image" }, +] + [[package]] name = "six" version = "1.17.0" @@ -2089,6 +4087,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "spglib" version = "2.6.0" @@ -2116,6 +4123,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/56/a31e8d3c9e8d21100b83bbe1c1f3f7c94db317393a229e193461e5e6d2a4/spglib-2.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:ff1632524f6ac0031423474e48d6b69f4932ecb7eb4446a501f59619e2b5cbc9", size = 561092, upload-time = "2025-03-10T05:58:51.732Z" }, ] +[[package]] +name = "sshtunnel" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paramiko" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/ad/4c587adf79865be268ee0b6bd52cfaa7a75d827a23ced072dc5ab554b4af/sshtunnel-0.4.0.tar.gz", hash = "sha256:e7cb0ea774db81bf91844db22de72a40aae8f7b0f9bb9ba0f666d474ef6bf9fc", size = 62716, upload-time = "2021-01-11T13:26:32.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/13/8476c4328dcadfe26f8bd7f3a1a03bf9ddb890a7e7b692f54a179bc525bf/sshtunnel-0.4.0-py2.py3-none-any.whl", hash = "sha256:98e54c26f726ab8bd42b47a3a21fca5c3e60f58956f0f70de2fb8ab0046d0606", size = 24729, upload-time = "2021-01-11T13:26:29.969Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -2175,6 +4194,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tifffile" +version = "2026.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.5.15" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'ARM64' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.12' and platform_machine != 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/66/0aef917d525767a40edebe088f8ed6a4417e6eb489c58f6805bfa872636b/tifffile-2026.5.15.tar.gz", hash = "sha256:ee4f3e07ee0d8ff4745a8c735ac2b72caa3173c7d6059b00fdc3ff492a0b635b", size = 429998, upload-time = "2026-05-15T20:04:55.896Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/6e/7d8850ff112f8f80d394ca45e89b975a3a43559d47af3137b767669b3294/tifffile-2026.5.15-py3-none-any.whl", hash = "sha256:6715515a53cabc0cefc5c9f13a0ae2c250e63e2ca784ce02d0b6c333810c2a17", size = 266665, upload-time = "2026-05-15T20:04:54.227Z" }, +] + [[package]] name = "torch" version = "2.7.1" @@ -2222,6 +4281,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/29/beb45cdf5c4fc3ebe282bf5eafc8dfd925ead7299b3c97491900fe5ed844/torch-2.7.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:988b0cbc4333618a1056d2ebad9eb10089637b659eb645434d0809d8d937b946", size = 68645708, upload-time = "2025-06-04T17:34:39.852Z" }, ] +[[package]] +name = "torch-ema" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/af/db7d0c8b26a13062d9b85bdcf8d977acd8a51057fb6edca9eb30613ef5ef/torch_ema-0.3.tar.gz", hash = "sha256:5a3595405fa311995f01291a1d4a9242d6be08a0fc9db29152ec6cfd586ea414", size = 5486, upload-time = "2021-11-17T20:59:16.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/f1/a47c831436595cffbfd69a19ea129a23627120b13f4886499e58775329d1/torch_ema-0.3-py3-none-any.whl", hash = "sha256:823ad8da5c10bc1eebcec739cc3f521aa9573229fe04e5673c304d29f1433279", size = 5475, upload-time = "2021-11-17T20:59:14.73Z" }, +] + +[[package]] +name = "torch-geometric" +version = "2.8.0.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "numpy" }, + { name = "psutil" }, + { name = "pyparsing" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/2c/bfcb404c448f7c347cea7b956d3c7e92d53e36c8db2010eb1bdc1b937bfb/torch_geometric-2.8.0.post1.tar.gz", hash = "sha256:2c0c81666ec10f2132f6b4e0b6c57fc813f2c9f6b57599d7b7a799c614c22fbd", size = 928666, upload-time = "2026-07-20T20:44:40.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/5c/edf74a71249ad19aa0390fb97ff021e2a5b04ce23252730014e1feac957e/torch_geometric-2.8.0.post1-py3-none-any.whl", hash = "sha256:5d9841cbfa64eadc425e445767127d103dd52fdc5890548c6364986c9bc78029", size = 1325397, upload-time = "2026-07-20T20:44:38.789Z" }, +] + +[[package]] +name = "torchmetrics" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightning-utilities" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/a2/c7f6ebf546f8f644edf0f999aa98ece106986a77a7b922316bf6414ff825/torchmetrics-1.9.0-py3-none-any.whl", hash = "sha256:bfdcbff3dd1d96b3374bb2496eb39f23c4b28b8a845b6a18c313688e0d2d9ca1", size = 983384, upload-time = "2026-03-09T17:41:19.756Z" }, +] + [[package]] name = "tornado" version = "6.5.1" @@ -2276,13 +4382,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/71/bd20ffcb7a64c753dc2463489a61bf69d531f308e390ad06390268c4ea04/triton-3.3.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3198adb9d78b77818a5388bff89fa72ff36f9da0bc689db2f0a651a67ce6a42", size = 155735832, upload-time = "2025-05-29T23:40:10.522Z" }, ] +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -2303,6 +4436,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl", hash = "sha256:313353900d8f88b283c9bad81e7d2b2d3d4bcc330cbace35403faaed7e78890a", size = 60118, upload-time = "2025-04-21T19:58:26.864Z" }, ] +[[package]] +name = "unidecode" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -2312,6 +4454,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "wandb" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "gitpython" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/31/fe53d06b75ef0a7f2f0ee5931a89f7aedc27d233840b1839616860fed256/wandb-0.27.0.tar.gz", hash = "sha256:579e75300173059f9334e1f513a79ef15f6d9ea5c74e20d695633648cdd02031", size = 41090732, upload-time = "2026-05-14T03:44:08.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/5e/2c199e70e636ecfd217cde0bc7469f4511e1d03d0685eb92bfdfce391430/wandb-0.27.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:c156be4851485f3c4160cb6eb2e8991b4cdeffbccefc5636d33cf5e254847365", size = 24886476, upload-time = "2026-05-14T03:43:27.569Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/a617c871cd304a9804e56a7ec2ec2c65685bf0091a2b9f91910175a149e2/wandb-0.27.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:20179f38afb0158859a4141d29ac650d3fdbd0cf801a74ce25565c934f03776c", size = 26045779, upload-time = "2026-05-14T03:43:31.999Z" }, + { url = "https://files.pythonhosted.org/packages/10/0a/d3f159a201530b84b72ca5f98c68d1f351c2d9a1864558ed76c811407fae/wandb-0.27.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:626497d7975fa898d0a4a239da7a510483495ca3514510dbe75004a25963af4d", size = 25480764, upload-time = "2026-05-14T03:43:35.922Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6a/8721fcdf71d42639191040a77a585d2982402b1754700cb2ecfc2ca1470a/wandb-0.27.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f772da7005cc26a2a32b729a16982a583dc68b3d493df6a09d0aa5c5ca5a2060", size = 27256204, upload-time = "2026-05-14T03:43:39.765Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/279d167ba79fb7a8a43401c9f25efd0f6663ee9bd1eaf5a8578530198888/wandb-0.27.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:63acfc5b994e4a90e4a2fbdee6d45e664da3dd865bb1419942c8995c06c41cf1", size = 25647469, upload-time = "2026-05-14T03:43:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/a69ac59300e3c813939d0764348959ed2a21e14c668cb1cebcb04010da6a/wandb-0.27.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:17aae6e4a88cd05c00ea8f546220918e3ebb6f8c1c36b70ef04a5ac75f0d7160", size = 27599005, upload-time = "2026-05-14T03:43:50.926Z" }, + { url = "https://files.pythonhosted.org/packages/5f/40/bf510c8758727df020f83b717ebc1fcc1739ed7f6ae1796ebef60bf6f592/wandb-0.27.0-py3-none-win32.whl", hash = "sha256:0bd5659417e386bf6538b5e2ffe6885774c6197f0e4853bfed517d5b0db457f1", size = 25036164, upload-time = "2026-05-14T03:43:54.839Z" }, + { url = "https://files.pythonhosted.org/packages/54/ff/69f88e7d90c22b79bcb911143c13e59742ee192080b21015ff83a5a1f60a/wandb-0.27.0-py3-none-win_amd64.whl", hash = "sha256:89d584b73166eecee96fb446f18d0e45b1aa45aba6a3696296f3f06d7454516b", size = 25036170, upload-time = "2026-05-14T03:43:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/f6/38/f7efd7a87297a55c7e9a331a1dbb5b19e54aeacc11fe6f43f8636a73987c/wandb-0.27.0-py3-none-win_arm64.whl", hash = "sha256:a6c129c311edf210a2b4f2f4acc557eff522628125f5f28ed27df19c16c07079", size = 22972710, upload-time = "2026-05-14T03:44:03.275Z" }, +] + [[package]] name = "wcwidth" version = "0.2.13" @@ -2321,6 +4492,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, +] + [[package]] name = "win32-setctime" version = "1.2.0" @@ -2330,6 +4513,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] +[[package]] +name = "xarray" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/a6/6fe936a798a3a38a79c7422d1a31afd2e9a14690fcb0ccff96bc01f04bf2/xarray-2026.4.0.tar.gz", hash = "sha256:c4ac9a01a945d90d5b1628e2af045099a9d4943536d4f2ee3ae963c3b222d15b", size = 3132311, upload-time = "2026-04-13T19:45:36.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl", hash = "sha256:d43751d9fb4a90f9249c30431684f00c41bc874f1edccd862631a40cbc0edf08", size = 1414326, upload-time = "2026-04-13T19:45:34.659Z" }, +] + [[package]] name = "xxhash" version = "3.5.0"