From 11a8a1c3c3f37fc5f908f1d3a73837d739cb38e5 Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 12:54:01 -0400 Subject: [PATCH 01/11] pyproject: +distributed Now that PSC_PLOT_DASK_SCHEDULER=distributed is supported in cli.py, distributed should be a real dependency, not pip-install-it-yourself. The import in cli.py stays lazy so the threads-only default doesn't pay the tornado/zict import cost on every startup. Co-Authored-By: Claude --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 214b218..c1f2cfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "xrft>=1.0", "pandas>=2.0", "dask>=2024.0", + "distributed>=2024.0", "h5py>=3.0", "tables>=3.10", "scipy>=1.10", From 538f86ec4b8d9bd0007cedddec1a6575627c785b Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 08:36:01 -0400 Subject: [PATCH 02/11] field_bp: parallel open --- src/lib/data/loaders/field_bp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/data/loaders/field_bp.py b/src/lib/data/loaders/field_bp.py index 3b9765d..703e43d 100644 --- a/src/lib/data/loaders/field_bp.py +++ b/src/lib/data/loaders/field_bp.py @@ -35,6 +35,7 @@ def get_data(self) -> Field: combine="nested", concat_dim="t", preprocess=lambda ds: pscpy.decode_psc(ds, ["e", "i"]), + parallel=True, ) if self.active_key is not None: derive_field_variable(ds, self.active_key, self.prefix) From 6a5aa47f2ea1533ae7f2ad7e72afabc146ed0b34 Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 09:20:27 -0400 Subject: [PATCH 03/11] field_bp: +_decode_psc Extract the preprocess lambda to a module-level function so it survives pickling for dask's processes scheduler. --- src/lib/data/loaders/field_bp.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/data/loaders/field_bp.py b/src/lib/data/loaders/field_bp.py index 703e43d..2f1a383 100644 --- a/src/lib/data/loaders/field_bp.py +++ b/src/lib/data/loaders/field_bp.py @@ -18,6 +18,10 @@ def _get_path(prefix: str, step: int) -> Path: return CONFIG.data_dir / f"{prefix}.{step:09}.bp" +def _decode_psc(ds): + return pscpy.decode_psc(ds, ["e", "i"]) + + @loader class FieldLoaderBp(Loader): @classmethod @@ -34,7 +38,7 @@ def get_data(self) -> Field: paths=[_get_path(self.prefix, step) for step in self.steps], combine="nested", concat_dim="t", - preprocess=lambda ds: pscpy.decode_psc(ds, ["e", "i"]), + preprocess=_decode_psc, parallel=True, ) if self.active_key is not None: From 6a863cfbc2919bb1bb5b2dec2cddb19677c43f18 Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 09:20:37 -0400 Subject: [PATCH 04/11] config: +dask_scheduler New PSC_PLOT_DASK_SCHEDULER env var, threaded through CONFIG so cli.py can call dask.config.set(scheduler=...) when set. Empty/unset leaves dask's default in place. Co-Authored-By: Claude --- src/lib/config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/config.py b/src/lib/config.py index 388a468..c0e1071 100644 --- a/src/lib/config.py +++ b/src/lib/config.py @@ -9,6 +9,7 @@ _FFMPEG_BIN_KEY = "PSC_PLOT_FFMPEG_BIN" _DASK_NUM_WORKERS_KEY = "PSC_PLOT_DASK_NUM_WORKERS" _DASK_CHUNK_SIZE_KEY = "PSC_PLOT_DASK_CHUNK_SIZE" +_DASK_SCHEDULER_KEY = "PSC_PLOT_DASK_SCHEDULER" def parse_optional[T](s: str | None, parser: Callable[[str], T]) -> T | None: @@ -23,6 +24,7 @@ class PscPlotConfig: ffmpeg_bin: Path | None dask_num_workers: int dask_chunk_size: int + dask_scheduler: str | None @classmethod def _load(cls) -> Self: @@ -43,7 +45,9 @@ def _load(cls) -> Self: if not dask_chunk_size: dask_chunk_size = 1_000_000 - return cls(data_dir, ffmpeg_bin, dask_num_workers, dask_chunk_size) + dask_scheduler = os.environ.get(_DASK_SCHEDULER_KEY) or None + + return cls(data_dir, ffmpeg_bin, dask_num_workers, dask_chunk_size, dask_scheduler) CONFIG = PscPlotConfig._load() From ff64f0399dc6763848ef0bff74a83257cbdcfd26 Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 09:20:41 -0400 Subject: [PATCH 05/11] cli: +scheduler set Co-Authored-By: Claude --- src/lib/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/cli.py b/src/lib/cli.py index a821ed9..9481b3a 100644 --- a/src/lib/cli.py +++ b/src/lib/cli.py @@ -37,6 +37,8 @@ def _resolve_save_format(args: Args) -> SaveFormat | None: def main(): dask.config.set(num_workers=CONFIG.dask_num_workers) + if CONFIG.dask_scheduler: + dask.config.set(scheduler=CONFIG.dask_scheduler) args = parsing.get_parsed_args() # resolve format BEFORE applying pipeline in order to fail early From 39411633bb56f5da828378c6a5cc57a9c88ca162 Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 10:35:51 -0400 Subject: [PATCH 06/11] cli: +distributed scheduler When PSC_PLOT_DASK_SCHEDULER=distributed, spin up a LocalCluster with n_workers=num_workers, one thread per worker, real processes. Workers are persistent across the run so the per-task spawn cost amortizes. Requires 'distributed' to be pip-installed; lazy import so the import error only fires when the scheduler is explicitly requested. Co-Authored-By: Claude --- src/lib/cli.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/cli.py b/src/lib/cli.py index 9481b3a..926fa93 100644 --- a/src/lib/cli.py +++ b/src/lib/cli.py @@ -37,7 +37,12 @@ def _resolve_save_format(args: Args) -> SaveFormat | None: def main(): dask.config.set(num_workers=CONFIG.dask_num_workers) - if CONFIG.dask_scheduler: + if CONFIG.dask_scheduler == "distributed": + from dask.distributed import Client, LocalCluster + + cluster = LocalCluster(n_workers=CONFIG.dask_num_workers, threads_per_worker=1, processes=True) + Client(cluster) + elif CONFIG.dask_scheduler: dask.config.set(scheduler=CONFIG.dask_scheduler) args = parsing.get_parsed_args() From ece4e31eef4e1b46d1363edaada6628718ace4f4 Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 11:09:49 -0400 Subject: [PATCH 07/11] config; *: default dask_num_workers -> cpu_count prt-bin-time benches show ~2.5x wall speedup from 1 -> cpu_count threads on the particle binning workload (the case that's actually CPU-bound). No regression on field scenarios. Drops the nag-warning since most users want the parallel default. Co-Authored-By: Claude --- CLAUDE.md | 2 +- src/lib/config.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3eb91b6..3999479 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Common flags: Required environment (see `src/lib/config.py`): - `PSC_PLOT_DATA_DIR` — directory containing the data files (must be set; `set_data_dir.sh ` is a convenience script that exports it) - `PSC_PLOT_FFMPEG_BIN` — optional, falls back to `which ffmpeg`; needed for saving animations -- `PSC_PLOT_DASK_NUM_WORKERS` — optional, defaults to 1 +- `PSC_PLOT_DASK_NUM_WORKERS` — optional, defaults to `os.cpu_count()` - `PSC_PLOT_DASK_CHUNK_SIZE` — optional, rows per dask partition for particle loads (default 1_000_000); reduce to bound peak memory on large files `PSC_PLOT_DATA_DIR` is read at module-import time (`CONFIG = PscPlotConfig._load()` in `src/lib/config.py`), so it must be set in the environment before any `lib.*` import. In tests, `tests/conftest.py` sets it before importing `lib`. diff --git a/src/lib/config.py b/src/lib/config.py index c0e1071..820c664 100644 --- a/src/lib/config.py +++ b/src/lib/config.py @@ -1,6 +1,5 @@ import os import shutil -import warnings from dataclasses import dataclass from pathlib import Path from typing import Callable, Self @@ -37,9 +36,7 @@ def _load(cls) -> Self: dask_num_workers = parse_optional(os.environ.get(_DASK_NUM_WORKERS_KEY), int) if not dask_num_workers: - dask_num_workers = 1 - message = f"Number of dask workers not specified; defaulting to {dask_num_workers}. Set {_DASK_NUM_WORKERS_KEY} to specify." - warnings.warn(message) + dask_num_workers = os.cpu_count() or 1 dask_chunk_size = parse_optional(os.environ.get(_DASK_CHUNK_SIZE_KEY), int) if not dask_chunk_size: From 364cf0986ac5dfe44530e4aba1ca663e4abe2cbf Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 12:49:25 -0400 Subject: [PATCH 08/11] CLAUDE: document PSC_PLOT_DASK_SCHEDULER Co-Authored-By: Claude --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3999479..3b390ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,8 @@ Required environment (see `src/lib/config.py`): - `PSC_PLOT_DATA_DIR` — directory containing the data files (must be set; `set_data_dir.sh ` is a convenience script that exports it) - `PSC_PLOT_FFMPEG_BIN` — optional, falls back to `which ffmpeg`; needed for saving animations - `PSC_PLOT_DASK_NUM_WORKERS` — optional, defaults to `os.cpu_count()` -- `PSC_PLOT_DASK_CHUNK_SIZE` — optional, rows per dask partition for particle loads (default 1_000_000); reduce to bound peak memory on large files +- `PSC_PLOT_DASK_CHUNK_SIZE` — optional, rows per dask partition for particle loads (default 1_000_000); reduce to bound peak memory on large files +- `PSC_PLOT_DASK_SCHEDULER` — optional; if set to `"processes"`, uses dask's processes scheduler; if `"distributed"`, spins up a `dask.distributed.LocalCluster` with `n_workers=dask_num_workers, threads_per_worker=1, processes=True`. Unset = dask default (threads). The `"distributed"` value requires `pip install distributed`. `PSC_PLOT_DATA_DIR` is read at module-import time (`CONFIG = PscPlotConfig._load()` in `src/lib/config.py`), so it must be set in the environment before any `lib.*` import. In tests, `tests/conftest.py` sets it before importing `lib`. From 8a4a75d777a7f1387c0ea0d7245ae1ecce8746cb Mon Sep 17 00:00:00 2001 From: James McClung Date: Thu, 14 May 2026 14:32:42 -0400 Subject: [PATCH 09/11] claude: fix scheduler --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3b390ed..667be1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ Required environment (see `src/lib/config.py`): - `PSC_PLOT_FFMPEG_BIN` — optional, falls back to `which ffmpeg`; needed for saving animations - `PSC_PLOT_DASK_NUM_WORKERS` — optional, defaults to `os.cpu_count()` - `PSC_PLOT_DASK_CHUNK_SIZE` — optional, rows per dask partition for particle loads (default 1_000_000); reduce to bound peak memory on large files -- `PSC_PLOT_DASK_SCHEDULER` — optional; if set to `"processes"`, uses dask's processes scheduler; if `"distributed"`, spins up a `dask.distributed.LocalCluster` with `n_workers=dask_num_workers, threads_per_worker=1, processes=True`. Unset = dask default (threads). The `"distributed"` value requires `pip install distributed`. +- `PSC_PLOT_DASK_SCHEDULER` — optional; if set to `"processes"`, uses dask's processes scheduler; if `"distributed"`, spins up a `dask.distributed.LocalCluster` with `n_workers=dask_num_workers, threads_per_worker=1, processes=True`. Unset = dask default (threads). `PSC_PLOT_DATA_DIR` is read at module-import time (`CONFIG = PscPlotConfig._load()` in `src/lib/config.py`), so it must be set in the environment before any `lib.*` import. In tests, `tests/conftest.py` sets it before importing `lib`. From e1c7e6699f87c4aecddd7734170c53c03db7fdb2 Mon Sep 17 00:00:00 2001 From: James McClung Date: Wed, 20 May 2026 15:01:06 -0400 Subject: [PATCH 10/11] derived_particle_variable: register var infos --- .../derived_particle_variables/derived_particle_variable.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/derived_particle_variables/derived_particle_variable.py b/src/lib/derived_particle_variables/derived_particle_variable.py index f400ef7..060b455 100644 --- a/src/lib/derived_particle_variables/derived_particle_variable.py +++ b/src/lib/derived_particle_variables/derived_particle_variable.py @@ -3,6 +3,7 @@ import pandas as pd +from lib import var_info_registry from lib.data.data_with_attrs import List __all__ = ["derived_particle_variable", "derive_particle_variable", "DERIVED_PARTICLE_VARIABLES"] @@ -25,7 +26,10 @@ def __init__( def assign_to(self, data: List) -> List: df = data.data - return data.assign_data(df.assign(**{self.name: self.derive(*(df[base_var_name] for base_var_name in self.base_var_names))})) + + info = var_info_registry.lookup("prt", self.name) + new_var_infos = {**data.metadata.var_infos, self.name: info} + return data.assign_data(df.assign(**{self.name: self.derive(*(df[base_var_name] for base_var_name in self.base_var_names))})).assign_metadata(var_infos=new_var_infos) def __repr__(self) -> str: return f"{self.__class__.__name__}(({', '.join(self.base_var_names)}) -> {self.name}: {self.derive!r})" From 1721ef6389cd7670b16ac0609f789ae9d9dd23e2 Mon Sep 17 00:00:00 2001 From: James McClung Date: Wed, 20 May 2026 15:01:12 -0400 Subject: [PATCH 11/11] test_plots: +hamscan --- tests/baseline/test_hamscan.png | Bin 0 -> 17715 bytes tests/test_plots.py | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 tests/baseline/test_hamscan.png diff --git a/tests/baseline/test_hamscan.png b/tests/baseline/test_hamscan.png new file mode 100644 index 0000000000000000000000000000000000000000..4a2607ee58e59a0c2d9189da8160d10bebc95c05 GIT binary patch literal 17715 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