From 2a4047f63beb333f84c99b590c4363d63eb54f76 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 07:10:46 -0400 Subject: [PATCH 01/45] PscPlotConfig; *: -> data_root --- src/lib/config.py | 4 ++-- src/lib/data/adaptors/with_.py | 2 +- src/lib/data/compile.py | 2 +- src/lib/data/loaders/field_bp.py | 2 +- src/lib/data/loaders/particle_bp.py | 6 +++--- src/lib/data/loaders/particle_h5.py | 8 ++++---- tests/conftest.py | 6 +++--- tests/test_dask_graph.py | 2 +- tests/test_h5_species_discovery.py | 12 ++++++------ tests/test_memory.py | 2 +- tests/test_particle_bp_perf.py | 4 ++-- 11 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/lib/config.py b/src/lib/config.py index ae3e691..8eacccf 100644 --- a/src/lib/config.py +++ b/src/lib/config.py @@ -20,7 +20,7 @@ def parse_optional[T](s: str | None, parser: Callable[[str], T]) -> T | None: @dataclass class PscPlotConfig: _: KW_ONLY - data_dir: Path = field(default_factory=Path.cwd) + data_root: Path = field(default_factory=Path.cwd) ffmpeg_bin: Path | None = None dask_num_workers: int = 1 dask_chunk_size: int = 1_000_000 @@ -30,7 +30,7 @@ class PscPlotConfig: def from_env(cls) -> Self: config = cls() - config.data_dir = parse_optional(os.environ.get(_DATA_DIR_KEY), Path) or config.data_dir + config.data_root = parse_optional(os.environ.get(_DATA_DIR_KEY), Path) or config.data_root config.ffmpeg_bin = parse_optional(os.environ.get(_FFMPEG_BIN_KEY, shutil.which("ffmpeg")), Path) or config.ffmpeg_bin config.dask_num_workers = parse_optional(os.environ.get(_DASK_NUM_WORKERS_KEY), int) or os.cpu_count() or config.dask_num_workers config.dask_chunk_size = parse_optional(os.environ.get(_DASK_CHUNK_SIZE_KEY), int) or config.dask_chunk_size diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 01d5e9b..90d555e 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -22,7 +22,7 @@ def apply_world(self, world): if prefix in world.datas: return world.with_active_data(world.active_data.assign_metadata(active_key=key), prefix) - loader = get_loader(world.config.data_dir, prefix, key) + loader = get_loader(world.config.data_root, prefix, key) return loader.apply_world(world) def get_name_fragments(self) -> list[str]: diff --git a/src/lib/data/compile.py b/src/lib/data/compile.py index a5cc820..daa3078 100644 --- a/src/lib/data/compile.py +++ b/src/lib/data/compile.py @@ -21,7 +21,7 @@ def _with_versus(adaptors: list[Adaptor]) -> list[Adaptor]: def compile_data_node(args: Args, config: PscPlotConfig): node = RootNode(config) - node = AdaptorNode(node, get_loader(config.data_dir, args.prefix, args.variable)) + node = AdaptorNode(node, get_loader(config.data_root, args.prefix, args.variable)) for adaptor in _with_versus(args.adaptors): node = AdaptorNode(node, adaptor) diff --git a/src/lib/data/loaders/field_bp.py b/src/lib/data/loaders/field_bp.py index c6b6117..edb30e6 100644 --- a/src/lib/data/loaders/field_bp.py +++ b/src/lib/data/loaders/field_bp.py @@ -36,7 +36,7 @@ def suffix(cls): def get_data(self, config: PscPlotConfig) -> Field: ds = xr.open_mfdataset( - paths=[_get_path(config.data_dir, self.prefix, step) for step in file_util.get_available_steps(config.data_dir, self.prefix + ".", ".bp")], + paths=[_get_path(config.data_root, self.prefix, step) for step in file_util.get_available_steps(config.data_root, self.prefix + ".", ".bp")], combine="nested", concat_dim="t", preprocess=_decode_psc, diff --git a/src/lib/data/loaders/particle_bp.py b/src/lib/data/loaders/particle_bp.py index 1908e41..84519ea 100644 --- a/src/lib/data/loaders/particle_bp.py +++ b/src/lib/data/loaders/particle_bp.py @@ -92,8 +92,8 @@ def __init__(self, prefix: str, active_key: str | None = None): self.species_key = prefix.split(".", 1)[1] def get_data(self, config: PscPlotConfig) -> LazyList: - steps = file_util.get_available_steps(config.data_dir, self.prefix + ".", ".bp") - step_attrs = [_read_attrs(_get_path(config.data_dir, self.prefix, step)) for step in steps] + steps = file_util.get_available_steps(config.data_root, self.prefix + ".", ".bp") + step_attrs = [_read_attrs(_get_path(config.data_root, self.prefix, step)) for step in steps] times = np.array([float(a["time"]) for a in step_attrs]) head = step_attrs[0] @@ -126,7 +126,7 @@ def get_data(self, config: PscPlotConfig) -> LazyList: partition_ranges = [] offset = 0 for step, time in zip(steps, times): - path = _get_path(config.data_dir, self.prefix, step) + path = _get_path(config.data_root, self.prefix, step) particle_dim, n = _peek_size(path) n_chunks = max(1, (n + chunk_size - 1) // chunk_size) partition_ranges.append((offset, offset + n_chunks)) diff --git a/src/lib/data/loaders/particle_h5.py b/src/lib/data/loaders/particle_h5.py index b89b478..5df4657 100644 --- a/src/lib/data/loaders/particle_h5.py +++ b/src/lib/data/loaders/particle_h5.py @@ -178,13 +178,13 @@ def suffix(cls): return "h5" def get_data(self, config: PscPlotConfig) -> LazyList: - steps = file_util.get_available_steps(config.data_dir, self.prefix + ".", ".h5") - species_dict = _build_species_dict(_discover_species_qm(config.data_dir, self.prefix, steps)) + steps = file_util.get_available_steps(config.data_root, self.prefix + ".", ".h5") + species_dict = _build_species_dict(_discover_species_qm(config.data_root, self.prefix, steps)) - attrss = [_load_attrs_at_step(config.data_dir, self.prefix, step) for step in steps] + attrss = [_load_attrs_at_step(config.data_root, self.prefix, step) for step in steps] times = np.array([attrs["time"] for attrs in attrss]) - data_paths = [_get_path_at_step(config.data_dir, self.prefix, step) for step in steps] + data_paths = [_get_path_at_step(config.data_root, self.prefix, step) for step in steps] dfs_of_steps = [] for time, data_path in zip(times, data_paths): df_of_step: dd.DataFrame = dd.read_hdf(data_path, key=PRT_PARTICLES_KEY, chunksize=config.dask_chunk_size, lock=True) diff --git a/tests/conftest.py b/tests/conftest.py index 3c73e9f..d5f1d53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ _TESTS_DIR = Path(__file__).parent _DATA_DIR = _TESTS_DIR / "data" -CONFIG_2D = PscPlotConfig(data_dir=_DATA_DIR / "test-2d") +CONFIG_2D = PscPlotConfig(data_root=_DATA_DIR / "test-2d") matplotlib.use("Agg") @@ -19,7 +19,7 @@ def make_plot(args_list: list[str], data_dir: str = "test-2d"): """Parse CLI args, run the full pipeline, and return the initialized figure.""" args = parse_args(args_list) - plot = compile_plot_node(args, PscPlotConfig(data_dir=_DATA_DIR / data_dir)).pull() + plot = compile_plot_node(args, PscPlotConfig(data_root=_DATA_DIR / data_dir)).pull() plot._initialize() return plot.fig @@ -27,7 +27,7 @@ def make_plot(args_list: list[str], data_dir: str = "test-2d"): def make_save(args_list: list[str], save_dir: Path, format: SaveFormat, data_dir: str = "test-2d"): """Parse CLI args, run the full pipeline, and save to save_dir. Returns the output file path.""" args = parse_args(args_list) - node = compile_plot_node(args, PscPlotConfig(data_dir=_DATA_DIR / data_dir)) + node = compile_plot_node(args, PscPlotConfig(data_root=_DATA_DIR / data_dir)) plot = node.pull() save_dir.mkdir(exist_ok=True) path = save_dir / f"{node.get_save_file_stem()}.{format}" diff --git a/tests/test_dask_graph.py b/tests/test_dask_graph.py index 18da57f..13d7d5d 100644 --- a/tests/test_dask_graph.py +++ b/tests/test_dask_graph.py @@ -19,7 +19,7 @@ def _read_keys_for_columns(args_list: list[str], data_dir: str = "test-2d") -> list[str]: """Optimize each dask collection produced by `args_list` and return the set of per-column file-read task key strings in the optimized graph.""" - config = PscPlotConfig(data_dir=_DATA_DIR / data_dir) + config = PscPlotConfig(data_root=_DATA_DIR / data_dir) args = parse_args(args_list) node = compile_plot_node(args, config) data = node.input_node.pull().active_data diff --git a/tests/test_h5_species_discovery.py b/tests/test_h5_species_discovery.py index 317b362..4cc5a5c 100644 --- a/tests/test_h5_species_discovery.py +++ b/tests/test_h5_species_discovery.py @@ -14,7 +14,7 @@ def test_h5_species_discovery_standard(tmp_path: Path): write_step(tmp_path / "prt.000000000.h5", time=0.0, species=[(-1.0, 1.0, 10), (1.0, 100.0, 10)], seed=0) loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_dir=tmp_path)) + data = loader.get_data(PscPlotConfig(data_root=tmp_path)) assert set(data.metadata.species.keys()) == {"e", "i"} e = data.metadata.species["e"] i = data.metadata.species["i"] @@ -30,7 +30,7 @@ def test_h5_species_discovery_multiple_ion_masses(tmp_path: Path): seed=0, ) loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_dir=tmp_path)) + data = loader.get_data(PscPlotConfig(data_root=tmp_path)) assert set(data.metadata.species.keys()) == {"e", "i25", "i100"} assert data.metadata.species["i25"].m == 25.0 assert data.metadata.species["i100"].m == 100.0 @@ -44,7 +44,7 @@ def test_h5_species_discovery_multiple_ion_charges(tmp_path: Path): seed=0, ) loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_dir=tmp_path)) + data = loader.get_data(PscPlotConfig(data_root=tmp_path)) assert set(data.metadata.species.keys()) == {"e", "i+", "i++"} assert data.metadata.species["i+"].q == 1.0 assert data.metadata.species["i++"].q == 2.0 @@ -58,7 +58,7 @@ def test_h5_species_discovery_multiple_ion_everything(tmp_path: Path): seed=0, ) loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_dir=tmp_path)) + data = loader.get_data(PscPlotConfig(data_root=tmp_path)) assert set(data.metadata.species.keys()) == {"e", "i+25", "i+100", "i++100"} assert data.metadata.species["i+25"].q == 1.0 assert data.metadata.species["i+25"].m == 25.0 @@ -77,7 +77,7 @@ def test_h5_species_discovery_electron_merge_warns(tmp_path: Path): ) loader = ParticleLoaderH5("prt", active_key=None) with pytest.warns(UserWarning, match="merging"): - data = loader.get_data(PscPlotConfig(data_dir=tmp_path)) + data = loader.get_data(PscPlotConfig(data_root=tmp_path)) assert set(data.metadata.species.keys()) == {"e"} @@ -86,5 +86,5 @@ def test_h5_species_discovery_species_at_different_times(tmp_path: Path): write_step(tmp_path / "prt.000000000.h5", time=0.0, species=[(-1.0, 1.0, 10), (1.0, 1.0, 0)], seed=0) write_step(tmp_path / "prt.000000001.h5", time=1.0, species=[(-1.0, 1.0, 0), (1.0, 1.0, 10)], seed=1) loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_dir=tmp_path)) + data = loader.get_data(PscPlotConfig(data_root=tmp_path)) assert set(data.metadata.species.keys()) == {"e", "i"} diff --git a/tests/test_memory.py b/tests/test_memory.py index 9d28333..ad4e417 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -27,7 +27,7 @@ def _run_pipeline(data_dir: pathlib.Path, chunksize: int, result_queue: mp.Queue from lib.parsing.parse import parse_args args = parse_args("prt --species i --bin y py=16 -v y py".split()) - plot = compile_plot_node(args, PscPlotConfig(data_dir=data_dir, dask_chunk_size=chunksize)).pull() + plot = compile_plot_node(args, PscPlotConfig(data_root=data_dir, dask_chunk_size=chunksize)).pull() plot._initialize() peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss diff --git a/tests/test_particle_bp_perf.py b/tests/test_particle_bp_perf.py index aaba829..6a47f73 100644 --- a/tests/test_particle_bp_perf.py +++ b/tests/test_particle_bp_perf.py @@ -27,7 +27,7 @@ def _run_h5_pipeline(data_dir: pathlib.Path, result_queue: mp.Queue) -> None: from lib.parsing.parse import parse_args args = parse_args("prt --species i --bin y py -v y py".split()) - plot = compile_plot_node(args, PscPlotConfig(data_dir=data_dir)).pull() + plot = compile_plot_node(args, PscPlotConfig(data_root=data_dir)).pull() t0 = time.perf_counter() plot._initialize() elapsed = time.perf_counter() - t0 @@ -42,7 +42,7 @@ def _run_bp_pipeline(data_dir: pathlib.Path, result_queue: mp.Queue) -> None: from lib.parsing.parse import parse_args args = parse_args("prt.i --bin y py -v y py".split()) - plot = compile_plot_node(args, PscPlotConfig(data_dir=data_dir)).pull() + plot = compile_plot_node(args, PscPlotConfig(data_root=data_dir)).pull() t0 = time.perf_counter() plot._initialize() elapsed = time.perf_counter() - t0 From 3976790b717772444948a956da5edf4d4bffd398 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 09:10:57 -0400 Subject: [PATCH 02/45] file_util: +Prepath --- src/lib/file_util.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/file_util.py b/src/lib/file_util.py index 177f028..2b330f1 100644 --- a/src/lib/file_util.py +++ b/src/lib/file_util.py @@ -1,5 +1,8 @@ from pathlib import Path +type Prepath = str +"""Path from data root directory to the data-containing directory plus the data prefix, e.g. `"run5/pfd"`.""" + def get_available_steps(data_dir: Path, before_step: str, after_step: str) -> list[int]: files = data_dir.glob(f"{before_step}*{after_step}") @@ -10,3 +13,10 @@ def get_available_steps(data_dir: Path, before_step: str, after_step: str) -> li steps.sort() return steps + + +def split_prepath(prepath: Prepath) -> tuple[Path, str]: + components = prepath.rsplit("/", maxsplit=1) + if len(components) == 1: + return (Path("."), components[0]) + return Path(components[0]), components[1] From 3c344cfedfd4fdb5484ceb8d7a238ddfb7e4efbd Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 07:25:48 -0400 Subject: [PATCH 03/45] data_world; *: ->prepath --- src/lib/data/adaptors/versus.py | 4 ++-- src/lib/data/adaptors/with_.py | 2 +- src/lib/data/data_world.py | 23 ++++++++++++----------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/lib/data/adaptors/versus.py b/src/lib/data/adaptors/versus.py index b269031..af2eb43 100644 --- a/src/lib/data/adaptors/versus.py +++ b/src/lib/data/adaptors/versus.py @@ -72,7 +72,7 @@ def _get_color_dim(self, data: DataWithAttrs) -> str | None: def apply_world(self, world): data = self.apply(world.active_data) new_plot_target = PlotTarget( - world.active_key, + world.active_prepath, spatial_dims=self._get_spatial_dims(data), color_dim=self._get_color_dim(data), time_dim=self._get_time_dim(data), @@ -81,7 +81,7 @@ def apply_world(self, world): return replace( world, plot_targets=world.plot_targets + [new_plot_target], - datas=world.datas | {world.active_key: data}, + datas=world.datas | {world.active_prepath: data}, ) def apply_field(self, data: Field) -> Field: diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 90d555e..e53b0df 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -11,7 +11,7 @@ def __init__(self, prefix_or_key: str, key: str | None = None): def apply_world(self, world): # case 1: prefix_or_key is a key within the active prefix - if not self.key and world.active_key and self.prefix_or_key in world.active_data.metadata.var_infos: + if not self.key and world.active_prepath and self.prefix_or_key in world.active_data.metadata.var_infos: key = self.prefix_or_key return world.with_active_data(world.active_data.assign_metadata(active_key=key)) diff --git a/src/lib/data/data_world.py b/src/lib/data/data_world.py index 6850703..1c395ee 100644 --- a/src/lib/data/data_world.py +++ b/src/lib/data/data_world.py @@ -5,37 +5,38 @@ from lib.config import PscPlotConfig from lib.data.data_with_attrs import DataWithAttrs from lib.data.plot_target import PlotTarget +from lib.file_util import Prepath @dataclass(frozen=True) class DataWorld: # TODO python 3.15: make frozendict - datas: dict[str, DataWithAttrs] = field(default_factory=dict) - active_key: str | None = None + datas: dict[Prepath, DataWithAttrs] = field(default_factory=dict) + active_prepath: Prepath | None = None _: KW_ONLY plot_targets: list[PlotTarget] = field(default_factory=list) config: PscPlotConfig = field(default_factory=PscPlotConfig.from_env) def __post_init__(self): - assert self.active_key is None or self.active_key in self.datas + assert self.active_prepath is None or self.active_prepath in self.datas @property def active_data(self) -> DataWithAttrs | None: - if self.active_key is None: + if self.active_prepath is None: return None - return self.datas[self.active_key] + return self.datas[self.active_prepath] def with_active_data( self, active_data: DataWithAttrs | None = None, - active_key: str | None = None, + active_prepath: Prepath | None = None, ) -> DataWorld: if active_data is None: - return replace(self, active_key=active_key) + return replace(self, active_prepath=active_prepath) - active_key = active_key or self.active_key - assert active_key is not None + active_prepath = active_prepath or self.active_prepath + assert active_prepath is not None new_datas = self.datas.copy() - new_datas[active_key] = active_data - return replace(self, datas=new_datas, active_key=active_key) + new_datas[active_prepath] = active_data + return replace(self, datas=new_datas, active_prepath=active_prepath) From aa6eb62024136103cfb9fccf27f9de02a1c68534 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 09:09:55 -0400 Subject: [PATCH 04/45] parse_util: fail_format return type is NoReturn --- src/lib/parsing/parse_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/parsing/parse_util.py b/src/lib/parsing/parse_util.py index 6a241d3..b0a7d5f 100644 --- a/src/lib/parsing/parse_util.py +++ b/src/lib/parsing/parse_util.py @@ -7,7 +7,7 @@ def _is_identifier(val: str) -> bool: return all(re.match(r"^\w[\d\w]*$", v) for v in val.split(".")) -def fail_format(arg: str, format: str): +def fail_format(arg: str, format: str) -> typing.NoReturn: raise argparse.ArgumentTypeError(f"Expected value of form '{format}'; got '{arg}'") From 4d12451e8b54bdb8f512998ae2bcec5b19326783 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 09:23:41 -0400 Subject: [PATCH 05/45] with: always take 2 args; first can be prepath --- src/lib/data/adaptors/with_.py | 41 ++++++++++++++++------------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index e53b0df..5549a4c 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -1,55 +1,52 @@ +from lib.config import _DATA_DIR_KEY from lib.data.adaptor import WorldAdaptor from lib.data.loader import get_loader +from lib.file_util import Prepath from lib.parsing import parse_util from lib.parsing.args_registry import arg_parser class With(WorldAdaptor): - def __init__(self, prefix_or_key: str, key: str | None = None): - self.prefix_or_key = prefix_or_key + def __init__(self, prepath: Prepath | None, key: str | None = None): + self.prepath = prepath self.key = key def apply_world(self, world): - # case 1: prefix_or_key is a key within the active prefix - if not self.key and world.active_prepath and self.prefix_or_key in world.active_data.metadata.var_infos: - key = self.prefix_or_key - return world.with_active_data(world.active_data.assign_metadata(active_key=key)) + if not self.prepath: + return world.with_active_data(world.active_data.assign_metadata(active_key=self.key)) - # case 2: prefix_or_key is a prefix - prefix = self.prefix_or_key - key = self.key + if self.prepath in world.datas: + return world.with_active_data(world.datas[self.prepath].assign_metadata(active_key=self.key), self.prepath) - if prefix in world.datas: - return world.with_active_data(world.active_data.assign_metadata(active_key=key), prefix) - - loader = get_loader(world.config.data_root, prefix, key) + loader = get_loader(world.config.data_root, self.prepath, self.key) return loader.apply_world(world) def get_name_fragments(self) -> list[str]: - maybe_prefix = f"{self.prefix_or_key}{SCOPE_OP}" if self.prefix_or_key else "" - return [f"with_{maybe_prefix}{self.key or ''}"] + maybe_prepath = f"{self.prepath}{SCOPE_OP}" if self.prepath else "" + return [f"with_{maybe_prepath}{self.key or ''}"] SCOPE_OP = "::" -WITH_FORMAT = f"prefix[{SCOPE_OP}[key]] | key" +WITH_FORMAT = f"[prepath{SCOPE_OP}][var_key]" @arg_parser( dest="adaptors", flags=["--with", "-w"], metavar=WITH_FORMAT, - help="switch to a different prefix and/or variable", + help=f"Switch to a different prepath (e.g. `run1/pfd`, relative to {_DATA_DIR_KEY}) and/or variable (e.g. `ey_ec`).", nargs="just one", ) def parse_with(arg: str) -> With: split_arg = arg.split(SCOPE_OP) if len(split_arg) == 2: - prefix = parse_util.parse_identifier(split_arg[0], "prefix") - key = parse_util.parse_optional_identifier(split_arg[1] or None, "key") - return With(prefix, key) + [prepath, key_arg] = split_arg elif len(split_arg) == 1: - prefix_or_key = parse_util.parse_identifier(split_arg[0], "prefix | key") - return With(prefix_or_key) + prepath = None + [key_arg] = split_arg else: parse_util.fail_format(arg, WITH_FORMAT) + + key = parse_util.parse_optional_identifier(key_arg, "key") + return With(prepath, key) From aa9e5beb3efe04cb73929a22c75c7e55d720a2b2 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 09:25:12 -0400 Subject: [PATCH 06/45] *: prefix -> prepath --- src/lib/data/adaptors/derive.py | 11 ++++++----- src/lib/data/compile.py | 4 ++-- src/lib/data/data_with_attrs.py | 3 ++- src/lib/data/loader.py | 17 ++++++++++------- src/lib/data/loaders/field_bp.py | 4 ++-- src/lib/data/loaders/particle_bp.py | 6 +++--- src/lib/data/loaders/particle_h5.py | 10 +++++----- src/lib/parsing/args.py | 2 +- src/lib/parsing/parse.py | 2 +- tests/test_particle_bp_vs_h5.py | 2 +- tests/test_save_filename.py | 8 ++++---- 11 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index b576121..eb1e387 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -4,6 +4,7 @@ from lib import var_info_registry from lib.data.adaptor import WorldAdaptor from lib.data.data_with_attrs import Field, List +from lib.data.data_world import DataWorld from lib.data.loader import get_loader from lib.derived_field_variables.derived_field_variable import ( DERIVED_FIELD_VARIABLES, @@ -50,7 +51,7 @@ def _collect_scoped_prefixes(ast) -> set[str]: return prefixes -def _load_siblings(world, prefixes: set[str]) -> dict[str, Field]: +def _load_siblings(world: DataWorld, prefixes: set[str]) -> dict[str, Field]: """Resolve each scoped prefix to a loaded Field, reusing any already in the world and auto-loading the rest the same way `--with` does.""" siblings: dict[str, Field] = {} @@ -58,7 +59,7 @@ def _load_siblings(world, prefixes: set[str]) -> dict[str, Field]: if prefix in world.datas: siblings[prefix] = world.datas[prefix] else: - loaded = get_loader(world.config.data_dir, prefix, None).apply_world(world) + loaded = get_loader(world.config.data_root, prefix, None).apply_world(world) siblings[prefix] = loaded.datas[prefix] return siblings @@ -68,7 +69,7 @@ def _resolve_field_variable(field: Field, key: str) -> Field: registry when it isn't already in the dataset.""" if key in field.data: return field - prefix = field.metadata.prefix + prefix = field.metadata.prepath if prefix is None: raise ValueError(f"--derive cannot resolve '{key}': field metadata has no prefix.") if key not in DERIVED_FIELD_VARIABLES.get(prefix, {}): @@ -173,14 +174,14 @@ def exponentiation(self, toks: list): def assign_default(self, toks: list): [new_variable] = toks self._data = _resolve_field_variable(self._data, new_variable) - dim = var_info_registry.lookup(self._data.metadata.prefix, new_variable) + dim = var_info_registry.lookup(self._data.metadata.prepath, new_variable) new_var_infos = {**self._data.metadata.var_infos, new_variable: dim} return self._data.assign_metadata(active_key=new_variable, var_infos=new_var_infos) def assignment(self, toks: list): [new_variable, val] = toks new_ds = self._data.data | {new_variable: val} - dim = var_info_registry.lookup(self._data.metadata.prefix, new_variable) + dim = var_info_registry.lookup(self._data.metadata.prepath, new_variable) new_var_infos = {**self._data.metadata.var_infos, new_variable: dim} return self._data.assign(new_ds, active_key=new_variable, var_infos=new_var_infos) diff --git a/src/lib/data/compile.py b/src/lib/data/compile.py index daa3078..7a533c5 100644 --- a/src/lib/data/compile.py +++ b/src/lib/data/compile.py @@ -3,7 +3,7 @@ from lib.config import PscPlotConfig from lib.data.adaptor import Adaptor from lib.data.adaptors.versus import Versus -from lib.data.loader import get_loader +from lib.data.adaptors.with_ import With from lib.data.node import AdaptorNode, DaskGraphNode, DataProcessingNode, PlotNode, RootNode, SavePlotNode, ShowPlotNode from lib.parsing.args import Args @@ -21,7 +21,7 @@ def _with_versus(adaptors: list[Adaptor]) -> list[Adaptor]: def compile_data_node(args: Args, config: PscPlotConfig): node = RootNode(config) - node = AdaptorNode(node, get_loader(config.data_root, args.prefix, args.variable)) + node = AdaptorNode(node, With(args.prepath, args.variable)) for adaptor in _with_versus(args.adaptors): node = AdaptorNode(node, adaptor) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index 7944b09..96b5503 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -11,6 +11,7 @@ import pandas as pd import xarray as xr +from lib.file_util import Prepath from lib.latex import Latex from lib.species import SpeciesInfo from lib.var_info import VarInfo @@ -112,7 +113,7 @@ def dask_collections(self) -> list: ... @dataclass(kw_only=True, frozen=True) class FieldMetadata(Metadata): - prefix: str | None = None + prepath: Prepath | None = None class Field(DataWithAttrs[dict[str, xr.DataArray], FieldMetadata]): diff --git a/src/lib/data/loader.py b/src/lib/data/loader.py index cd5432c..824e974 100644 --- a/src/lib/data/loader.py +++ b/src/lib/data/loader.py @@ -5,6 +5,7 @@ from lib.config import PscPlotConfig from lib.data.adaptor import WorldAdaptor from lib.data.data_with_attrs import DataWithAttrs +from lib.file_util import Prepath, split_prepath class Loader(WorldAdaptor): @@ -18,18 +19,19 @@ def discover_prefixes(cls, data_dir: Path) -> list[str]: def suffix(cls) -> str: """Return the suffix that this loader supports.""" - def __init__(self, prefix: str, active_key: str | None = None): - self.prefix = prefix + def __init__(self, prepath: Prepath, active_key: str | None = None): + self.prepath = prepath + self.subdir, self.prefix = split_prepath(prepath) self.active_key = active_key def get_name_fragments(self) -> list[str]: - fragments = [self.prefix] + fragments = [self.prepath] if self.active_key is not None: fragments.append(self.active_key) return fragments def apply_world(self, world): - return world.with_active_data(self.get_data(world.config), self.prefix) + return world.with_active_data(self.get_data(world.config), self.prepath) @abstractmethod def get_data(self, config: PscPlotConfig) -> DataWithAttrs: ... @@ -62,6 +64,7 @@ def discover_loaders(data_dir: Path) -> dict[str, type[Loader]]: return result -def get_loader(data_dir: Path, prefix: str, active_key: str | None) -> Loader: - loader_types = discover_loaders(data_dir) - return loader_types[prefix](prefix, active_key) +def get_loader(data_root: Path, prepath: Prepath, active_key: str | None) -> Loader: + subdir, prefix = split_prepath(prepath) + loader_types = discover_loaders(data_root / subdir) + return loader_types[prefix](prepath, active_key) diff --git a/src/lib/data/loaders/field_bp.py b/src/lib/data/loaders/field_bp.py index edb30e6..7a52f4d 100644 --- a/src/lib/data/loaders/field_bp.py +++ b/src/lib/data/loaders/field_bp.py @@ -36,7 +36,7 @@ def suffix(cls): def get_data(self, config: PscPlotConfig) -> Field: ds = xr.open_mfdataset( - paths=[_get_path(config.data_root, self.prefix, step) for step in file_util.get_available_steps(config.data_root, self.prefix + ".", ".bp")], + paths=[_get_path(config.data_root / self.subdir, self.prefix, step) for step in file_util.get_available_steps(config.data_root / self.subdir, self.prefix + ".", ".bp")], combine="nested", concat_dim="t", preprocess=_decode_psc, @@ -50,7 +50,7 @@ def get_data(self, config: PscPlotConfig) -> Field: data, FieldMetadata( active_key=self.active_key, - prefix=self.prefix, + prepath=self.prepath, var_infos=var_infos, ), ) diff --git a/src/lib/data/loaders/particle_bp.py b/src/lib/data/loaders/particle_bp.py index 84519ea..ec7ff96 100644 --- a/src/lib/data/loaders/particle_bp.py +++ b/src/lib/data/loaders/particle_bp.py @@ -92,8 +92,8 @@ def __init__(self, prefix: str, active_key: str | None = None): self.species_key = prefix.split(".", 1)[1] def get_data(self, config: PscPlotConfig) -> LazyList: - steps = file_util.get_available_steps(config.data_root, self.prefix + ".", ".bp") - step_attrs = [_read_attrs(_get_path(config.data_root, self.prefix, step)) for step in steps] + steps = file_util.get_available_steps(config.data_root / self.subdir, self.prefix + ".", ".bp") + step_attrs = [_read_attrs(_get_path(config.data_root / self.subdir, self.prefix, step)) for step in steps] times = np.array([float(a["time"]) for a in step_attrs]) head = step_attrs[0] @@ -126,7 +126,7 @@ def get_data(self, config: PscPlotConfig) -> LazyList: partition_ranges = [] offset = 0 for step, time in zip(steps, times): - path = _get_path(config.data_root, self.prefix, step) + path = _get_path(config.data_root / self.subdir, self.prefix, step) particle_dim, n = _peek_size(path) n_chunks = max(1, (n + chunk_size - 1) // chunk_size) partition_ranges.append((offset, offset + n_chunks)) diff --git a/src/lib/data/loaders/particle_h5.py b/src/lib/data/loaders/particle_h5.py index 5df4657..d58d2a1 100644 --- a/src/lib/data/loaders/particle_h5.py +++ b/src/lib/data/loaders/particle_h5.py @@ -178,13 +178,13 @@ def suffix(cls): return "h5" def get_data(self, config: PscPlotConfig) -> LazyList: - steps = file_util.get_available_steps(config.data_root, self.prefix + ".", ".h5") - species_dict = _build_species_dict(_discover_species_qm(config.data_root, self.prefix, steps)) + steps = file_util.get_available_steps(config.data_root / self.subdir, self.prefix + ".", ".h5") + species_dict = _build_species_dict(_discover_species_qm(config.data_root / self.subdir, self.prefix, steps)) - attrss = [_load_attrs_at_step(config.data_root, self.prefix, step) for step in steps] + attrss = [_load_attrs_at_step(config.data_root / self.subdir, self.prefix, step) for step in steps] times = np.array([attrs["time"] for attrs in attrss]) - data_paths = [_get_path_at_step(config.data_root, self.prefix, step) for step in steps] + data_paths = [_get_path_at_step(config.data_root / self.subdir, self.prefix, step) for step in steps] dfs_of_steps = [] for time, data_path in zip(times, data_paths): df_of_step: dd.DataFrame = dd.read_hdf(data_path, key=PRT_PARTICLES_KEY, chunksize=config.dask_chunk_size, lock=True) @@ -215,7 +215,7 @@ def get_data(self, config: PscPlotConfig) -> LazyList: df_with_metadata = LazyList(df, metadata) - var_infos = {key: lookup(self.prefix, key) for key in df_with_metadata.dims} + var_infos = {key: lookup(self.prepath, key) for key in df_with_metadata.dims} return df_with_metadata.assign_metadata( active_key=self.active_key, var_infos=var_infos, diff --git a/src/lib/parsing/args.py b/src/lib/parsing/args.py index 9d5bc90..13a9541 100644 --- a/src/lib/parsing/args.py +++ b/src/lib/parsing/args.py @@ -6,7 +6,7 @@ class Args(argparse.Namespace): - prefix: str + prepath: str variable: str | None adaptors: list[Adaptor] hooks: list[Hook] diff --git a/src/lib/parsing/parse.py b/src/lib/parsing/parse.py index 52464b8..e8b8e71 100644 --- a/src/lib/parsing/parse.py +++ b/src/lib/parsing/parse.py @@ -8,7 +8,7 @@ def _get_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="psc-plot") - parser.add_argument("prefix", help="initial active prefix") + parser.add_argument("prepath", help="initial active prepath") parser.add_argument("variable", nargs="?", default=None, help="initial active variable") parser.add_argument( "-s", diff --git a/tests/test_particle_bp_vs_h5.py b/tests/test_particle_bp_vs_h5.py index ccfce16..0f66615 100644 --- a/tests/test_particle_bp_vs_h5.py +++ b/tests/test_particle_bp_vs_h5.py @@ -11,7 +11,7 @@ def _load_and_filter_h5(species_key: str): - loader = ParticleLoaderH5(prefix="prt", active_key=None) + loader = ParticleLoaderH5(prepath="prt", active_key=None) data = loader.get_data(CONFIG_2D) return SpeciesFilter(species_key).apply_list(data) diff --git a/tests/test_save_filename.py b/tests/test_save_filename.py index 9b99aec..e19e560 100644 --- a/tests/test_save_filename.py +++ b/tests/test_save_filename.py @@ -8,10 +8,10 @@ @pytest.mark.parametrize( "args_list, expected_stem", [ - (["pfd", "hx_fc"], "pfd-hx_fc-v_y,z"), - (["pfd", "hx_fc", "--nan0"], "pfd-hx_fc-nan0-v_y,z"), - (["pfd", "hx_fc", "--scale", "log"], "pfd-hx_fc-scale_log-v_y,z"), - (["pfd", "hx_fc", "-v", "y", "z", "time="], "pfd-hx_fc-v_y,z;time="), + (["pfd", "hx_fc"], "pfd::hx_fc-v_y,z"), + (["pfd", "hx_fc", "--nan0"], "pfd::hx_fc-nan0-v_y,z"), + (["pfd", "hx_fc", "--scale", "log"], "pfd::hx_fc-scale_log-v_y,z"), + (["pfd", "hx_fc", "-v", "y", "z", "time="], "pfd::hx_fc-v_y,z;time="), ], ) def test_save_file_stem(args_list, expected_stem): From af7fb13f70240f7e1b58c750a73a6727e29d2c54 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 09:33:17 -0400 Subject: [PATCH 07/45] with: +include_with_in_name_fragment --- src/lib/data/adaptors/with_.py | 6 ++++-- src/lib/data/compile.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 5549a4c..3bb7646 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -7,9 +7,10 @@ class With(WorldAdaptor): - def __init__(self, prepath: Prepath | None, key: str | None = None): + def __init__(self, prepath: Prepath | None, key: str | None = None, *, include_with_in_name_fragment: bool = True): self.prepath = prepath self.key = key + self.include_with_in_name_fragment = include_with_in_name_fragment def apply_world(self, world): if not self.prepath: @@ -22,8 +23,9 @@ def apply_world(self, world): return loader.apply_world(world) def get_name_fragments(self) -> list[str]: + maybe_with = "with_" if self.include_with_in_name_fragment else "" maybe_prepath = f"{self.prepath}{SCOPE_OP}" if self.prepath else "" - return [f"with_{maybe_prepath}{self.key or ''}"] + return [f"{maybe_with}{maybe_prepath}{self.key or ''}"] SCOPE_OP = "::" diff --git a/src/lib/data/compile.py b/src/lib/data/compile.py index 7a533c5..6743e30 100644 --- a/src/lib/data/compile.py +++ b/src/lib/data/compile.py @@ -21,7 +21,7 @@ def _with_versus(adaptors: list[Adaptor]) -> list[Adaptor]: def compile_data_node(args: Args, config: PscPlotConfig): node = RootNode(config) - node = AdaptorNode(node, With(args.prepath, args.variable)) + node = AdaptorNode(node, With(args.prepath, args.variable, include_with_in_name_fragment=False)) for adaptor in _with_versus(args.adaptors): node = AdaptorNode(node, adaptor) From 667e75d612253e300990cfea8ca05076cc46b756 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 09:39:12 -0400 Subject: [PATCH 08/45] test_plots: +test_crossdir_with --- tests/baseline/test_crossdir_with.png | Bin 0 -> 31789 bytes tests/test_plots.py | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 tests/baseline/test_crossdir_with.png diff --git a/tests/baseline/test_crossdir_with.png b/tests/baseline/test_crossdir_with.png new file mode 100644 index 0000000000000000000000000000000000000000..ef93221ca7b719a9657748b991882193a8b9968d GIT binary patch literal 31789 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 Date: Mon, 27 Jul 2026 09:46:01 -0400 Subject: [PATCH 09/45] data_world: +with_data --- src/lib/data/data_world.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/data/data_world.py b/src/lib/data/data_world.py index 1c395ee..e829182 100644 --- a/src/lib/data/data_world.py +++ b/src/lib/data/data_world.py @@ -40,3 +40,10 @@ def with_active_data( new_datas = self.datas.copy() new_datas[active_prepath] = active_data return replace(self, datas=new_datas, active_prepath=active_prepath) + + def with_data( + self, + prepath: Prepath, + data: DataWithAttrs, + ) -> DataWorld: + return replace(self, datas=self.datas | {prepath: data}) From ece68a66bf2b71132a21671afc12c8a506742721 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 10:17:38 -0400 Subject: [PATCH 10/45] *: +load; ->with_data --- src/lib/data/adaptor.py | 2 +- src/lib/data/adaptors/derive.py | 21 +++++++++--------- src/lib/data/adaptors/with_.py | 12 ++++++----- src/lib/data/data_world.py | 19 +++++++++-------- src/lib/data/loader.py | 33 ++++++++++++++++++++--------- src/lib/data/loaders/field_bp.py | 9 +------- src/lib/data/loaders/particle_bp.py | 7 +++--- src/lib/data/loaders/particle_h5.py | 1 - tests/test_h5_species_discovery.py | 20 ++++++----------- tests/test_particle_bp_vs_h5.py | 4 ++-- 10 files changed, 65 insertions(+), 63 deletions(-) diff --git a/src/lib/data/adaptor.py b/src/lib/data/adaptor.py index 801575f..0134743 100644 --- a/src/lib/data/adaptor.py +++ b/src/lib/data/adaptor.py @@ -29,7 +29,7 @@ def apply_world(self, world: DataWorld) -> DataWorld: ... class Adaptor(WorldAdaptor): def apply_world(self, world: DataWorld) -> DataWorld: - return world.with_active_data(self.apply(world.active_data)) + return world.with_active(data=self.apply(world.active_data)) def apply(self, data: DataWithAttrs) -> DataWithAttrs: if isinstance(data, List): diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index eb1e387..63641b9 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -5,7 +5,7 @@ from lib.data.adaptor import WorldAdaptor from lib.data.data_with_attrs import Field, List from lib.data.data_world import DataWorld -from lib.data.loader import get_loader +from lib.data.loader import load from lib.derived_field_variables.derived_field_variable import ( DERIVED_FIELD_VARIABLES, derive_field_variable, @@ -13,6 +13,7 @@ from lib.derived_particle_variables.derived_particle_variable import ( derive_particle_variable, ) +from lib.file_util import Prepath from lib.parsing.args_registry import arg_parser @@ -28,11 +29,11 @@ def apply_world(self, world): if isinstance(active, List): if scoped_prefixes: raise ValueError("--derive: cross-prefix references (prefix::key) are not supported for particle data.") - return world.with_active_data(AssignNewVariable(active).transform(self.ast)) + return world.with_active(data=AssignNewVariable(active).transform(self.ast)) if isinstance(active, Field): siblings = _load_siblings(world, scoped_prefixes) - return world.with_active_data(AssignNewFieldVariable(active, siblings).transform(self.ast)) + return world.with_active(data=AssignNewFieldVariable(active, siblings).transform(self.ast)) raise ValueError("--derive requires an active variable to derive into; specify one as a positional argument.") @@ -51,16 +52,15 @@ def _collect_scoped_prefixes(ast) -> set[str]: return prefixes -def _load_siblings(world: DataWorld, prefixes: set[str]) -> dict[str, Field]: +def _load_siblings(world: DataWorld, prepaths: set[Prepath]) -> dict[str, Field]: """Resolve each scoped prefix to a loaded Field, reusing any already in the world and auto-loading the rest the same way `--with` does.""" - siblings: dict[str, Field] = {} - for prefix in prefixes: - if prefix in world.datas: - siblings[prefix] = world.datas[prefix] + siblings: dict[Prepath, Field] = {} + for prepath in prepaths: + if prepath in world.datas: + siblings[prepath] = world.datas[prepath] else: - loaded = get_loader(world.config.data_root, prefix, None).apply_world(world) - siblings[prefix] = loaded.datas[prefix] + siblings[prepath] = load(world.config, prepath) return siblings @@ -130,6 +130,7 @@ def assignment(self, toks: list): class AssignNewFieldVariable(Transformer_InPlace): def __init__(self, data: Field, siblings: dict[str, Field]): self._data = data + # TODO: load "siblings" into the world instead of this hack self._siblings = siblings super().__init__(visit_tokens=True) diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 3bb7646..0f58c84 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -1,6 +1,6 @@ from lib.config import _DATA_DIR_KEY from lib.data.adaptor import WorldAdaptor -from lib.data.loader import get_loader +from lib.data.loader import load from lib.file_util import Prepath from lib.parsing import parse_util from lib.parsing.args_registry import arg_parser @@ -14,13 +14,15 @@ def __init__(self, prepath: Prepath | None, key: str | None = None, *, include_w def apply_world(self, world): if not self.prepath: - return world.with_active_data(world.active_data.assign_metadata(active_key=self.key)) + return world.with_active(data=world.active_data.assign_metadata(active_key=self.key)) if self.prepath in world.datas: - return world.with_active_data(world.datas[self.prepath].assign_metadata(active_key=self.key), self.prepath) + return world.with_active(prepath=self.prepath, data=world.datas[self.prepath].assign_metadata(active_key=self.key)) - loader = get_loader(world.config.data_root, self.prepath, self.key) - return loader.apply_world(world) + return world.with_active( + prepath=self.prepath, + data=load(world.config, self.prepath, self.key), + ) def get_name_fragments(self) -> list[str]: maybe_with = "with_" if self.include_with_in_name_fragment else "" diff --git a/src/lib/data/data_world.py b/src/lib/data/data_world.py index e829182..f8595ea 100644 --- a/src/lib/data/data_world.py +++ b/src/lib/data/data_world.py @@ -26,20 +26,21 @@ def active_data(self) -> DataWithAttrs | None: return None return self.datas[self.active_prepath] - def with_active_data( + def with_active( self, - active_data: DataWithAttrs | None = None, - active_prepath: Prepath | None = None, + *, + data: DataWithAttrs | None = None, + prepath: Prepath | None = None, ) -> DataWorld: - if active_data is None: - return replace(self, active_prepath=active_prepath) + if data is None: + return replace(self, active_prepath=prepath) - active_prepath = active_prepath or self.active_prepath - assert active_prepath is not None + prepath = prepath or self.active_prepath + assert prepath is not None new_datas = self.datas.copy() - new_datas[active_prepath] = active_data - return replace(self, datas=new_datas, active_prepath=active_prepath) + new_datas[prepath] = data + return replace(self, datas=new_datas, active_prepath=prepath) def with_data( self, diff --git a/src/lib/data/loader.py b/src/lib/data/loader.py index 824e974..dbcf6e3 100644 --- a/src/lib/data/loader.py +++ b/src/lib/data/loader.py @@ -4,7 +4,9 @@ from lib.config import PscPlotConfig from lib.data.adaptor import WorldAdaptor -from lib.data.data_with_attrs import DataWithAttrs +from lib.data.data_with_attrs import DataWithAttrs, Field, List +from lib.derived_field_variables.derived_field_variable import derive_field_variable +from lib.derived_particle_variables.derived_particle_variable import derive_particle_variable from lib.file_util import Prepath, split_prepath @@ -19,19 +21,15 @@ def discover_prefixes(cls, data_dir: Path) -> list[str]: def suffix(cls) -> str: """Return the suffix that this loader supports.""" - def __init__(self, prepath: Prepath, active_key: str | None = None): + def __init__(self, prepath: Prepath): self.prepath = prepath self.subdir, self.prefix = split_prepath(prepath) - self.active_key = active_key def get_name_fragments(self) -> list[str]: - fragments = [self.prepath] - if self.active_key is not None: - fragments.append(self.active_key) - return fragments + return [self.prepath] def apply_world(self, world): - return world.with_active_data(self.get_data(world.config), self.prepath) + return world.with_data(self.prepath, self.get_data(world.config)) @abstractmethod def get_data(self, config: PscPlotConfig) -> DataWithAttrs: ... @@ -64,7 +62,22 @@ def discover_loaders(data_dir: Path) -> dict[str, type[Loader]]: return result -def get_loader(data_root: Path, prepath: Prepath, active_key: str | None) -> Loader: +def get_loader(data_root: Path, prepath: Prepath) -> Loader: subdir, prefix = split_prepath(prepath) loader_types = discover_loaders(data_root / subdir) - return loader_types[prefix](prepath, active_key) + return loader_types[prefix](prepath) + + +def load(config: PscPlotConfig, prepath: Prepath, active_key: str | None = None) -> DataWithAttrs: + loader = get_loader(config.data_root, prepath) + data = loader.get_data(config) + if active_key: + _, prefix = split_prepath(prepath) + if isinstance(data, Field): + data = derive_field_variable(data, active_key, prefix) + elif isinstance(data, List): + data = derive_particle_variable(data, active_key, prefix) + else: + raise TypeError(data.__class__) + data = data.assign_metadata(active_key=active_key) + return data diff --git a/src/lib/data/loaders/field_bp.py b/src/lib/data/loaders/field_bp.py index 7a52f4d..f915a65 100644 --- a/src/lib/data/loaders/field_bp.py +++ b/src/lib/data/loaders/field_bp.py @@ -8,7 +8,6 @@ from lib.config import PscPlotConfig from lib.data.data_with_attrs import Field, FieldMetadata from lib.data.loader import Loader, loader -from lib.derived_field_variables import derive_field_variable from lib.var_info_registry import lookup _KNOWN_PREFIXES = ("pfd", "pfd_moments", "gauss", "continuity") @@ -46,16 +45,10 @@ def get_data(self, config: PscPlotConfig) -> Field: data = {key: ds[key] for key in ds.data_vars} var_infos = {key: lookup(self.prefix, key) for key in ds.variables} - field = Field( + return Field( data, FieldMetadata( - active_key=self.active_key, prepath=self.prepath, var_infos=var_infos, ), ) - - if self.active_key is not None: - field = derive_field_variable(field, self.active_key, self.prefix) - - return field diff --git a/src/lib/data/loaders/particle_bp.py b/src/lib/data/loaders/particle_bp.py index ec7ff96..f453fc3 100644 --- a/src/lib/data/loaders/particle_bp.py +++ b/src/lib/data/loaders/particle_bp.py @@ -87,9 +87,9 @@ def discover_prefixes(cls, data_dir: pathlib.Path) -> list[str]: def suffix(cls): return "bp" - def __init__(self, prefix: str, active_key: str | None = None): - super().__init__(prefix, active_key) - self.species_key = prefix.split(".", 1)[1] + def __init__(self, prepath: file_util.Prepath): + super().__init__(prepath) + self.species_key = prepath.split(".", 1)[1] def get_data(self, config: PscPlotConfig) -> LazyList: steps = file_util.get_available_steps(config.data_root / self.subdir, self.prefix + ".", ".bp") @@ -160,6 +160,5 @@ def get_data(self, config: PscPlotConfig) -> LazyList: # species suffix when looking up per-column metadata. var_infos = {key: lookup("prt", key) for key in data.dims} return data.assign_metadata( - active_key=self.active_key, var_infos=var_infos, ) diff --git a/src/lib/data/loaders/particle_h5.py b/src/lib/data/loaders/particle_h5.py index d58d2a1..ede1649 100644 --- a/src/lib/data/loaders/particle_h5.py +++ b/src/lib/data/loaders/particle_h5.py @@ -217,7 +217,6 @@ def get_data(self, config: PscPlotConfig) -> LazyList: var_infos = {key: lookup(self.prepath, key) for key in df_with_metadata.dims} return df_with_metadata.assign_metadata( - active_key=self.active_key, var_infos=var_infos, subject=Latex(r"\text{Particles}"), ) diff --git a/tests/test_h5_species_discovery.py b/tests/test_h5_species_discovery.py index 4cc5a5c..0099b66 100644 --- a/tests/test_h5_species_discovery.py +++ b/tests/test_h5_species_discovery.py @@ -8,13 +8,12 @@ from synthetic_particles import write_step from lib.config import PscPlotConfig -from lib.data.loaders.particle_h5 import ParticleLoaderH5 +from lib.data.loader import load def test_h5_species_discovery_standard(tmp_path: Path): write_step(tmp_path / "prt.000000000.h5", time=0.0, species=[(-1.0, 1.0, 10), (1.0, 100.0, 10)], seed=0) - loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_root=tmp_path)) + data = load(PscPlotConfig(data_root=tmp_path), "prt") assert set(data.metadata.species.keys()) == {"e", "i"} e = data.metadata.species["e"] i = data.metadata.species["i"] @@ -29,8 +28,7 @@ def test_h5_species_discovery_multiple_ion_masses(tmp_path: Path): species=[(-1.0, 1.0, 10), (1.0, 25.0, 10), (1.0, 100.0, 10)], seed=0, ) - loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_root=tmp_path)) + data = load(PscPlotConfig(data_root=tmp_path), "prt") assert set(data.metadata.species.keys()) == {"e", "i25", "i100"} assert data.metadata.species["i25"].m == 25.0 assert data.metadata.species["i100"].m == 100.0 @@ -43,8 +41,7 @@ def test_h5_species_discovery_multiple_ion_charges(tmp_path: Path): species=[(-1.0, 1.0, 10), (1.0, 100.0, 10), (2.0, 100.0, 10)], seed=0, ) - loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_root=tmp_path)) + data = load(PscPlotConfig(data_root=tmp_path), "prt") assert set(data.metadata.species.keys()) == {"e", "i+", "i++"} assert data.metadata.species["i+"].q == 1.0 assert data.metadata.species["i++"].q == 2.0 @@ -57,8 +54,7 @@ def test_h5_species_discovery_multiple_ion_everything(tmp_path: Path): species=[(-1.0, 1.0, 10), (1.0, 25.0, 10), (1.0, 100.0, 10), (2.0, 100.0, 10)], seed=0, ) - loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_root=tmp_path)) + data = load(PscPlotConfig(data_root=tmp_path), "prt") assert set(data.metadata.species.keys()) == {"e", "i+25", "i+100", "i++100"} assert data.metadata.species["i+25"].q == 1.0 assert data.metadata.species["i+25"].m == 25.0 @@ -75,9 +71,8 @@ def test_h5_species_discovery_electron_merge_warns(tmp_path: Path): species=[(-1.0, 1.0, 10), (-1.0, 1.0, 10)], seed=0, ) - loader = ParticleLoaderH5("prt", active_key=None) with pytest.warns(UserWarning, match="merging"): - data = loader.get_data(PscPlotConfig(data_root=tmp_path)) + data = load(PscPlotConfig(data_root=tmp_path), "prt") assert set(data.metadata.species.keys()) == {"e"} @@ -85,6 +80,5 @@ def test_h5_species_discovery_species_at_different_times(tmp_path: Path): # step 0: only species 0 has particles; step 1: only species 1 has particles. write_step(tmp_path / "prt.000000000.h5", time=0.0, species=[(-1.0, 1.0, 10), (1.0, 1.0, 0)], seed=0) write_step(tmp_path / "prt.000000001.h5", time=1.0, species=[(-1.0, 1.0, 0), (1.0, 1.0, 10)], seed=1) - loader = ParticleLoaderH5("prt", active_key=None) - data = loader.get_data(PscPlotConfig(data_root=tmp_path)) + data = load(PscPlotConfig(data_root=tmp_path), "prt") assert set(data.metadata.species.keys()) == {"e", "i"} diff --git a/tests/test_particle_bp_vs_h5.py b/tests/test_particle_bp_vs_h5.py index 0f66615..888fde5 100644 --- a/tests/test_particle_bp_vs_h5.py +++ b/tests/test_particle_bp_vs_h5.py @@ -11,13 +11,13 @@ def _load_and_filter_h5(species_key: str): - loader = ParticleLoaderH5(prepath="prt", active_key=None) + loader = ParticleLoaderH5(prepath="prt") data = loader.get_data(CONFIG_2D) return SpeciesFilter(species_key).apply_list(data) def _load_bp(species_key: str): - loader = ParticleLoaderBp(prefix=f"prt.{species_key}", active_key=None) + loader = ParticleLoaderBp(prepath=f"prt.{species_key}") return loader.get_data(CONFIG_2D) From 890fc9bd3bf49eda06938e1ffc3e771e9d475a52 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 10:34:51 -0400 Subject: [PATCH 11/45] data_with_attrs: cleanup DataWithAttrs generics --- src/lib/data/data_with_attrs.py | 38 ++++++++------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index 96b5503..d178642 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -58,23 +58,13 @@ def assign(self, **vals: Any) -> Self: return self.__class__(**updated_vals) -@dataclass(frozen=True, init=False) -class DataWithAttrs[D: dict[str, xr.DataArray] | pd.DataFrame | dd.DataFrame, MD: Metadata](ABC): +@dataclass(frozen=True) +class DataWithAttrs[Data, MD: Metadata = Metadata](ABC): """A data wrapper to provide a uniform, typed, and reliable metadata interface.""" - # The type checker ignores type bounds when no generic argument is present, e.g. after `isinstance` (and function parameters). - # Specifying field data types like this makes their types known in such cases, but doesn't give type hints for the parameters of __init__. - # Thus, it's necessary to annotate __init__ parameters via generics and the fields themselves with concrete types. - # Unfortunately, annotating a field in a superclass requires also annotating it in each subclass that refines that field's type. - # And with all this, other methods still don't get type hints :( - data: dict[str, xr.DataArray] | pd.DataFrame | dd.DataFrame - metadata: Metadata - _caches: dict[str, dict[str, Any]] - - def __init__(self, data: D, metadata: MD): - object.__setattr__(self, "data", data) - object.__setattr__(self, "metadata", metadata) - object.__setattr__(self, "_caches", {}) + data: Data + metadata: MD + _caches: dict[str, dict[str, Any]] = field(default_factory=dict, init=False) @property @abstractmethod @@ -84,7 +74,7 @@ def coordss(self) -> dict[str, np.ndarray]: ... @abstractmethod def dims(self) -> list[str]: ... - def assign_data(self, data: D) -> Self: + def assign_data(self, data: Data) -> Self: return self.__class__(data, self.metadata) def assign_metadata(self, metadata: MD | None = None, /, **metadata_vals: Any) -> Self: @@ -92,10 +82,10 @@ def assign_metadata(self, metadata: MD | None = None, /, **metadata_vals: Any) - return self return self.__class__(self.data, (metadata or self.metadata).assign(**metadata_vals)) - def assign(self, data: D, metadata: MD | None = None, /, **metadata_vals: Any) -> Self: + def assign(self, data: Data, metadata: MD | None = None, /, **metadata_vals: Any) -> Self: return self.assign_data(data).assign_metadata(metadata, **metadata_vals) - def map_data(self, func: Callable[[D], D]) -> Self: + def map_data(self, func: Callable[[Data], Data]) -> Self: return self.assign_data(func(self.data)) @abstractmethod @@ -117,9 +107,6 @@ class FieldMetadata(Metadata): class Field(DataWithAttrs[dict[str, xr.DataArray], FieldMetadata]): - data: dict[str, xr.DataArray] - metadata: FieldMetadata - @property def active_data(self) -> xr.DataArray: if self.metadata.active_key is None: @@ -178,10 +165,7 @@ class ListMetadata(Metadata): `len(partition_ranges) == len(coordss[partition_dim])`.""" -class List[D: pd.DataFrame | dd.DataFrame](DataWithAttrs[D, ListMetadata]): - data: pd.DataFrame | dd.DataFrame - metadata: ListMetadata - +class List[Data: pd.DataFrame | dd.DataFrame = pd.DataFrame | dd.DataFrame](DataWithAttrs[Data, ListMetadata]): @property def active_data(self) -> pd.Series | dd.Series: if self.metadata.active_key is None: @@ -204,8 +188,6 @@ def dims(self) -> list[str]: class FullList(List[pd.DataFrame]): - data: pd.DataFrame - def compute(self) -> FullList: return self @@ -237,8 +219,6 @@ def dask_collections(self) -> list: class LazyList(List[dd.DataFrame]): - data: dd.DataFrame - def compute(self) -> FullList: # partition_* describe the dask layout; meaningless after compute. return FullList(self.data.compute(), self.metadata.assign(partition_dim=None, partition_ranges=None)) From 5cf86585ec2d7b2a0b50fb7c99912345553e702b Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 11:06:40 -0400 Subject: [PATCH 12/45] field_2d: cleanup --- src/lib/plotting/renderers/field_2d.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/plotting/renderers/field_2d.py b/src/lib/plotting/renderers/field_2d.py index 3a01b32..8303628 100644 --- a/src/lib/plotting/renderers/field_2d.py +++ b/src/lib/plotting/renderers/field_2d.py @@ -1,7 +1,6 @@ import xarray as xr from lib.data.data_with_attrs import Field -from lib.data.plot_target import SpatialDimsXY from lib.plotting.plot_info import ImageInfo, PlotInfo from lib.plotting.renderer import Renderer @@ -13,10 +12,6 @@ def get_extent(da: xr.DataArray, dim: str) -> tuple[float, float]: class Field2dRenderer(Renderer[Field]): - def _transpose(self, data: Field) -> Field: - spatial_dims = data.metadata.spatial_dims - return data.with_active_data(data.active_data.transpose(*reversed(spatial_dims))) - def init_plot_info(self) -> PlotInfo: full_data = self.full_data frame_data = self._get_data_at_frame(0) From 5378614614c271303a1615b6ddce14228fe2be6c Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 11:13:40 -0400 Subject: [PATCH 13/45] data_with_attrs: +Subdata, with_active --- src/lib/data/data_with_attrs.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index d178642..e16534c 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -59,7 +59,7 @@ def assign(self, **vals: Any) -> Self: @dataclass(frozen=True) -class DataWithAttrs[Data, MD: Metadata = Metadata](ABC): +class DataWithAttrs[Data, Subdata, MD: Metadata = Metadata](ABC): """A data wrapper to provide a uniform, typed, and reliable metadata interface.""" data: Data @@ -74,6 +74,9 @@ def coordss(self) -> dict[str, np.ndarray]: ... @abstractmethod def dims(self) -> list[str]: ... + @abstractmethod + def with_active(self, *, data: Subdata | None = None, key: str | None = None) -> Self: ... + def assign_data(self, data: Data) -> Self: return self.__class__(data, self.metadata) @@ -106,13 +109,21 @@ class FieldMetadata(Metadata): prepath: Prepath | None = None -class Field(DataWithAttrs[dict[str, xr.DataArray], FieldMetadata]): +class Field(DataWithAttrs[dict[str, xr.DataArray], xr.DataArray, FieldMetadata]): @property def active_data(self) -> xr.DataArray: if self.metadata.active_key is None: raise ValueError("no active variable; specify one as a positional argument") return self.data[self.metadata.active_key] + def with_active(self, *, data=None, key=None) -> Self: + if data is None: + return self.assign_metadata(active_key=key) + + key = key or self.metadata.active_key + assert key is not None + return self.assign(self.data | {key: data}, active_key=key) + def with_active_data(self, new_da: xr.DataArray) -> Self: """Returns a shallow copy with the active variable replaced by `new_da`.""" return self.assign_data(self.data | {self.metadata.active_key: new_da}) @@ -165,7 +176,7 @@ class ListMetadata(Metadata): `len(partition_ranges) == len(coordss[partition_dim])`.""" -class List[Data: pd.DataFrame | dd.DataFrame = pd.DataFrame | dd.DataFrame](DataWithAttrs[Data, ListMetadata]): +class List[Data: pd.DataFrame | dd.DataFrame = pd.DataFrame | dd.DataFrame, Subdata: pd.Series | dd.Series = pd.Series | dd.Series](DataWithAttrs[Data, Subdata, ListMetadata]): @property def active_data(self) -> pd.Series | dd.Series: if self.metadata.active_key is None: @@ -175,6 +186,14 @@ def active_data(self) -> pd.Series | dd.Series: def with_active_data(self, series: pd.Series | dd.Series) -> Self: return self.assign_data(self.data.assign(**{self.metadata.active_key: series})) + def with_active(self, *, data=None, key=None) -> Self: + if data is None: + return self.assign_metadata(active_key=key) + + key = key or self.metadata.active_key + assert key is not None + return self.assign(self.data.assign(**{self.metadata.active_key: data}), active_key=key) + @abstractmethod def compute(self) -> FullList: ... From 0c7403811ff34f2e421a7a06ee06d7f643bc4251 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 11:14:14 -0400 Subject: [PATCH 14/45] *: use with_active --- src/lib/data/adaptor.py | 4 ++-- src/lib/data/adaptors/bin.py | 2 +- src/lib/data/adaptors/compute.py | 2 +- src/lib/data/adaptors/derive.py | 4 +--- src/lib/data/adaptors/fourier.py | 2 +- src/lib/data/adaptors/idx.py | 2 +- src/lib/data/adaptors/pos.py | 2 +- src/lib/data/adaptors/transform_polar.py | 2 +- src/lib/data/adaptors/transform_spherical.py | 2 +- 9 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/lib/data/adaptor.py b/src/lib/data/adaptor.py index 0134743..6deec69 100644 --- a/src/lib/data/adaptor.py +++ b/src/lib/data/adaptor.py @@ -74,10 +74,10 @@ class BareAdaptor(MetadataAdaptor): """An adaptor that works with the raw data, no metadata required.""" def apply_field(self, data: Field) -> DataWithAttrs: - return data.with_active_data(self.apply_field_bare(data.active_data)) + return data.with_active(data=self.apply_field_bare(data.active_data)) def apply_list(self, data: List) -> DataWithAttrs: - return data.with_active_data(self.apply_list_bare(data.active_data)) + return data.with_active(data=self.apply_list_bare(data.active_data)) def apply_field_bare(self, da: xr.DataArray) -> xr.DataArray: _fail_apply_field(self.__class__) diff --git a/src/lib/data/adaptors/bin.py b/src/lib/data/adaptors/bin.py index a75b969..7c08071 100644 --- a/src/lib/data/adaptors/bin.py +++ b/src/lib/data/adaptors/bin.py @@ -93,7 +93,7 @@ def apply_field(self, data: Field) -> Field: dim_names_to_bin_size[dim_name] = bin_size - return data.with_active_data(data.active_data.coarsen(dim_names_to_bin_size, boundary="pad").mean()) + return data.with_active(data=data.active_data.coarsen(dim_names_to_bin_size, boundary="pad").mean()) def apply_list(self, data: List) -> Field: bin_edgess = _guess_bin_edgess(data, self.varname_to_nbins) diff --git a/src/lib/data/adaptors/compute.py b/src/lib/data/adaptors/compute.py index 1fc252d..370df68 100644 --- a/src/lib/data/adaptors/compute.py +++ b/src/lib/data/adaptors/compute.py @@ -13,4 +13,4 @@ def apply_list(self, data: List) -> FullList: return data.compute() def apply_field(self, data: Field) -> Field: - return data.with_active_data(data.active_data.compute()) + return data.with_active(data=data.active_data.compute()) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index 63641b9..2aab456 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -122,9 +122,7 @@ def assign_default(self, toks: list): def assignment(self, toks: list): [new_variable, val] = toks - df = self._data.data - df = df.assign(**{new_variable: val}) - return self._data.assign_data(df) + return self._data.with_active(key=new_variable, data=val) class AssignNewFieldVariable(Transformer_InPlace): diff --git a/src/lib/data/adaptors/fourier.py b/src/lib/data/adaptors/fourier.py index 60019c9..cfc22b0 100644 --- a/src/lib/data/adaptors/fourier.py +++ b/src/lib/data/adaptors/fourier.py @@ -49,7 +49,7 @@ def apply_field(self, data: Field) -> Field: new_display = f"\\mathcal{{F}}_{{{','.join(pre_dim_latexs)}}}[{old_active_info.display}]" new_var_infos[data.metadata.active_key] = old_active_info.assign(display=new_display) - return data.with_active_data(da).assign_metadata(var_infos=new_var_infos) + return data.with_active(data=da).assign_metadata(var_infos=new_var_infos) def get_name_fragments(self) -> list[str]: return [f"fourier_{','.join(self.dim_keys)}"] diff --git a/src/lib/data/adaptors/idx.py b/src/lib/data/adaptors/idx.py index 2c08705..78f593e 100644 --- a/src/lib/data/adaptors/idx.py +++ b/src/lib/data/adaptors/idx.py @@ -10,7 +10,7 @@ def __init__(self, dim_names_to_isel: dict[str, int | slice]): self.dim_names_to_isel = dim_names_to_isel def apply_field(self, data: Field) -> Field: - return data.with_active_data(data.active_data.isel(self.dim_names_to_isel)) + return data.with_active(data=data.active_data.isel(self.dim_names_to_isel)) def apply_list(self, data: List) -> List: coordss = data.coordss.copy() diff --git a/src/lib/data/adaptors/pos.py b/src/lib/data/adaptors/pos.py index cfdf204..d4c4485 100644 --- a/src/lib/data/adaptors/pos.py +++ b/src/lib/data/adaptors/pos.py @@ -33,7 +33,7 @@ def __init__( def apply_field(self, data: Field) -> Field: dim_names_to_pos = {dim_name: pos for dim_name, pos in self.dim_names_to_sel.items() if isinstance(pos, float)} dim_names_to_slice = {dim_name: s for dim_name, s in self.dim_names_to_sel.items() if isinstance(s, slice)} - return data.with_active_data(data.active_data.sel(dim_names_to_pos, method="nearest").sel(dim_names_to_slice)) + return data.with_active(data=data.active_data.sel(dim_names_to_pos, method="nearest").sel(dim_names_to_slice)) def apply_list(self, data: List) -> List: # Lazy-import Idx to avoid a circular import via lib.plotting.animated_plot. diff --git a/src/lib/data/adaptors/transform_polar.py b/src/lib/data/adaptors/transform_polar.py index 968c3a4..91ac031 100644 --- a/src/lib/data/adaptors/transform_polar.py +++ b/src/lib/data/adaptors/transform_polar.py @@ -76,7 +76,7 @@ def apply_field(self, data: Field) -> Field: new_var_infos = {k: v for k, v in data.metadata.var_infos.items() if k not in {key_x, key_y}} new_var_infos[key_r] = dim_r new_var_infos[key_theta] = dim_theta - return data.with_active_data(da).assign_metadata(var_infos=new_var_infos) + return data.with_active(data=da).assign_metadata(var_infos=new_var_infos) def apply_list(self, data: List) -> List: dim_x = data.metadata.var_infos[self.dim1_key] diff --git a/src/lib/data/adaptors/transform_spherical.py b/src/lib/data/adaptors/transform_spherical.py index e597f4d..049adcf 100644 --- a/src/lib/data/adaptors/transform_spherical.py +++ b/src/lib/data/adaptors/transform_spherical.py @@ -95,7 +95,7 @@ def apply_field(self, data: Field) -> Field: new_var_infos[key_r] = dim_r new_var_infos[key_theta] = dim_theta new_var_infos[key_phi] = dim_phi - return data.with_active_data(da).assign_metadata(var_infos=new_var_infos) + return data.with_active(data=da).assign_metadata(var_infos=new_var_infos) def apply_list(self, data: List) -> List: dim_x = data.metadata.var_infos[self.dim1_key] From 79a9791a2ccbd8e5f00a2c814246090e644cc6d8 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 11:18:29 -0400 Subject: [PATCH 15/45] data_with_attrs: -with_active_data --- src/lib/data/data_with_attrs.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index e16534c..d8eec2f 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -124,10 +124,6 @@ def with_active(self, *, data=None, key=None) -> Self: assert key is not None return self.assign(self.data | {key: data}, active_key=key) - def with_active_data(self, new_da: xr.DataArray) -> Self: - """Returns a shallow copy with the active variable replaced by `new_da`.""" - return self.assign_data(self.data | {self.metadata.active_key: new_da}) - @cached_property def coordss(self) -> dict[str, np.ndarray]: active = self.active_data @@ -183,9 +179,6 @@ def active_data(self) -> pd.Series | dd.Series: raise ValueError("no active variable; specify one as a positional argument") return self.data[self.metadata.active_key] - def with_active_data(self, series: pd.Series | dd.Series) -> Self: - return self.assign_data(self.data.assign(**{self.metadata.active_key: series})) - def with_active(self, *, data=None, key=None) -> Self: if data is None: return self.assign_metadata(active_key=key) From 4659c95ef3e2e56f6bfe3e971d32581b8c4babf2 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 11:49:31 -0400 Subject: [PATCH 16/45] data_with_attrs: with_active supports VarInfo --- src/lib/data/data_with_attrs.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index d8eec2f..75b3053 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -75,7 +75,7 @@ def coordss(self) -> dict[str, np.ndarray]: ... def dims(self) -> list[str]: ... @abstractmethod - def with_active(self, *, data: Subdata | None = None, key: str | None = None) -> Self: ... + def with_active(self, *, data: Subdata | None = None, key: str | None = None, info: VarInfo | None = None) -> Self: ... def assign_data(self, data: Data) -> Self: return self.__class__(data, self.metadata) @@ -116,13 +116,21 @@ def active_data(self) -> xr.DataArray: raise ValueError("no active variable; specify one as a positional argument") return self.data[self.metadata.active_key] - def with_active(self, *, data=None, key=None) -> Self: - if data is None: + def with_active(self, *, data=None, key=None, info=None) -> Self: + if data is None and info is None: return self.assign_metadata(active_key=key) key = key or self.metadata.active_key assert key is not None - return self.assign(self.data | {key: data}, active_key=key) + + ret = self + if data is not None: + ret = ret.assign(ret.data | {key: data}, active_key=key) + + if info is not None: + ret = ret.assign_metadata(var_infos=ret.metadata.var_infos | {key: info}, active_key=key) + + return ret @cached_property def coordss(self) -> dict[str, np.ndarray]: @@ -179,13 +187,21 @@ def active_data(self) -> pd.Series | dd.Series: raise ValueError("no active variable; specify one as a positional argument") return self.data[self.metadata.active_key] - def with_active(self, *, data=None, key=None) -> Self: - if data is None: + def with_active(self, *, data=None, key=None, info=None) -> Self: + if data is None and info is None: return self.assign_metadata(active_key=key) key = key or self.metadata.active_key assert key is not None - return self.assign(self.data.assign(**{self.metadata.active_key: data}), active_key=key) + + ret = self + if data is not None: + ret = ret.assign(ret.data.assign(**{key: data}), active_key=key) + + if info is not None: + ret = ret.assign_metadata(var_infos=ret.metadata.var_infos | {key: info}, active_key=key) + + return ret @abstractmethod def compute(self) -> FullList: ... From abd252272764c998160f20de4a236824505af32b Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 11:56:00 -0400 Subject: [PATCH 17/45] data_with_attrs: +active_*, getitem --- src/lib/data/data_with_attrs.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index 75b3053..251c069 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -74,6 +74,25 @@ def coordss(self) -> dict[str, np.ndarray]: ... @abstractmethod def dims(self) -> list[str]: ... + @property + def active_key(self) -> str | None: + return self.metadata.active_key + + @property + def active_subdata(self) -> Subdata | None: + if self.active_key is None: + return None + return self[self.active_key] + + @property + def active_info(self) -> VarInfo | None: + if self.active_key is None: + return None + return self.metadata.var_infos[self.active_key] + + @abstractmethod + def __getitem__(self, key: str) -> Subdata: ... + @abstractmethod def with_active(self, *, data: Subdata | None = None, key: str | None = None, info: VarInfo | None = None) -> Self: ... @@ -132,6 +151,9 @@ def with_active(self, *, data=None, key=None, info=None) -> Self: return ret + def __getitem__(self, key: str): + return self.data[key] + @cached_property def coordss(self) -> dict[str, np.ndarray]: active = self.active_data @@ -216,6 +238,9 @@ def dims(self) -> list[str]: class FullList(List[pd.DataFrame]): + def __getitem__(self, key: str): + return self.data[key] + def compute(self) -> FullList: return self @@ -247,6 +272,9 @@ def dask_collections(self) -> list: class LazyList(List[dd.DataFrame]): + def __getitem__(self, key: str): + return self.data[key] + def compute(self) -> FullList: # partition_* describe the dask layout; meaningless after compute. return FullList(self.data.compute(), self.metadata.assign(partition_dim=None, partition_ranges=None)) From 80d95236406f6a938872a46c816c4ad2b1879993 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 13:45:50 -0400 Subject: [PATCH 18/45] data_with_attrs: +with_info --- src/lib/data/data_with_attrs.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index 251c069..8e2e627 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -96,6 +96,9 @@ def __getitem__(self, key: str) -> Subdata: ... @abstractmethod def with_active(self, *, data: Subdata | None = None, key: str | None = None, info: VarInfo | None = None) -> Self: ... + def with_info(self, key: str, info: VarInfo) -> Self: + return self.assign_metadata(var_infos=self.metadata.var_infos | {key: info}) + def assign_data(self, data: Data) -> Self: return self.__class__(data, self.metadata) From 2e82edb00206f3b568a4135423392e42f417f739 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 14:22:26 -0400 Subject: [PATCH 19/45] data_with_attrs: -map_data --- src/lib/data/data_with_attrs.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index 8e2e627..fed3f3d 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -110,9 +110,6 @@ def assign_metadata(self, metadata: MD | None = None, /, **metadata_vals: Any) - def assign(self, data: Data, metadata: MD | None = None, /, **metadata_vals: Any) -> Self: return self.assign_data(data).assign_metadata(metadata, **metadata_vals) - def map_data(self, func: Callable[[Data], Data]) -> Self: - return self.assign_data(func(self.data)) - @abstractmethod def bounds(self, dim_name: str) -> tuple[float, float]: ... From f3277bb0132ecf18c210c178ff81e0c7d027cf73 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 14:24:11 -0400 Subject: [PATCH 20/45] *: use with_active more --- src/lib/data/adaptor.py | 9 ++---- src/lib/data/adaptors/derive.py | 17 +++++------ src/lib/data/adaptors/display.py | 28 +++++++++---------- src/lib/data/adaptors/fourier.py | 2 +- src/lib/data/adaptors/pos.py | 2 +- src/lib/data/adaptors/set_scale.py | 13 ++++----- src/lib/data/adaptors/species_filter.py | 2 +- src/lib/data/adaptors/transform_polar.py | 2 +- src/lib/data/adaptors/transform_spherical.py | 2 +- src/lib/data/adaptors/unit.py | 28 +++++++++---------- src/lib/data/adaptors/versus.py | 6 ++-- src/lib/data/adaptors/with_.py | 4 +-- src/lib/data/loader.py | 2 +- src/lib/data/loaders/particle_bp.py | 12 ++------ src/lib/data/loaders/particle_h5.py | 10 ++----- .../derived_field_variable.py | 2 +- .../derived_particle_variable.py | 5 +++- src/lib/plotting/renderer.py | 2 +- 18 files changed, 64 insertions(+), 84 deletions(-) diff --git a/src/lib/data/adaptor.py b/src/lib/data/adaptor.py index 6deec69..91b938d 100644 --- a/src/lib/data/adaptor.py +++ b/src/lib/data/adaptor.py @@ -59,15 +59,12 @@ def get_modified_unit_latex(self, metadata: Metadata) -> Latex: def apply(self, data: DataWithAttrs) -> DataWithAttrs: data = super().apply(data) - var_infos = data.metadata.var_infos - if data.metadata.active_key is not None and data.metadata.active_key in var_infos: + if info := data.active_info: display_latex = self.get_modified_display_latex(data.metadata) unit_latex = self.get_modified_unit_latex(data.metadata) - old_dim = var_infos[data.metadata.active_key] - new_dim = old_dim.assign(display=display_latex, unit=unit_latex) - var_infos = {**var_infos, data.metadata.active_key: new_dim} + data = data.with_active(info=info.assign(display=display_latex, unit=unit_latex)) - return data.assign_metadata(var_infos=var_infos) + return data class BareAdaptor(MetadataAdaptor): diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index 2aab456..ca32a41 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -171,18 +171,15 @@ def exponentiation(self, toks: list): return lhs**rhs def assign_default(self, toks: list): - [new_variable] = toks - self._data = _resolve_field_variable(self._data, new_variable) - dim = var_info_registry.lookup(self._data.metadata.prepath, new_variable) - new_var_infos = {**self._data.metadata.var_infos, new_variable: dim} - return self._data.assign_metadata(active_key=new_variable, var_infos=new_var_infos) + [key] = toks + self._data = _resolve_field_variable(self._data, key) + info = var_info_registry.lookup(self._data.metadata.prepath, key) + return self._data.with_active(key=key, info=info) def assignment(self, toks: list): - [new_variable, val] = toks - new_ds = self._data.data | {new_variable: val} - dim = var_info_registry.lookup(self._data.metadata.prepath, new_variable) - new_var_infos = {**self._data.metadata.var_infos, new_variable: dim} - return self._data.assign(new_ds, active_key=new_variable, var_infos=new_var_infos) + [key, subdata] = toks + info = var_info_registry.lookup(self._data.metadata.prepath, key) + return self._data.with_active(data=subdata, key=key, info=info) _DERIVE_GRAMMAR = r""" diff --git a/src/lib/data/adaptors/display.py b/src/lib/data/adaptors/display.py index e757e08..4a4dfb6 100644 --- a/src/lib/data/adaptors/display.py +++ b/src/lib/data/adaptors/display.py @@ -6,27 +6,25 @@ class Display(Adaptor): """Override the display-LaTeX of the active variable or of a dimension.""" - def __init__(self, target: str | None, value: str): - self.target = target - self.value = value + def __init__(self, key: str | None, display: str): + self.key = key + self.display = display def apply(self, data: DataWithAttrs) -> DataWithAttrs: metadata = data.metadata - target = self.target or metadata.active_key - if target is None: + key = self.key or metadata.active_key + if key is None: raise ValueError("--display requires a target; specify a variable as a positional argument or use --display TARGET=VALUE") - if target not in metadata.var_infos: - raise ValueError(f"--display target {target!r} is not a known key ({sorted(metadata.var_infos)})") + if key not in metadata.var_infos: + raise ValueError(f"--display target {key!r} is not a known key ({sorted(metadata.var_infos)})") - old_dim = metadata.var_infos[target] - new_dim = old_dim.assign(display=self.value) - new_var_infos = {**metadata.var_infos, target: new_dim} - return data.assign_metadata(var_infos=new_var_infos) + info = metadata.var_infos[key].assign(display=self.display) + return data.with_info(key, info) def get_name_fragments(self) -> list[str]: - return [f"display_{self.target or 'active'}={self.value}"] + return [f"display_{self.key or 'active'}={self.display}"] _DISPLAY_FORMAT = "[name=]display_latex" @@ -40,6 +38,6 @@ def get_name_fragments(self) -> list[str]: ) def parse_display(arg: str) -> Display: if "=" in arg: - name, value = arg.split("=", 1) - return Display(target=name, value=value) - return Display(target=None, value=arg) + key, display = arg.split("=", 1) + return Display(key=key, display=display) + return Display(key=None, display=arg) diff --git a/src/lib/data/adaptors/fourier.py b/src/lib/data/adaptors/fourier.py index cfc22b0..632d610 100644 --- a/src/lib/data/adaptors/fourier.py +++ b/src/lib/data/adaptors/fourier.py @@ -45,7 +45,7 @@ def apply_field(self, data: Field) -> Field: new_var_infos[f_info.key] = f_info da = toggle_fourier(da, info) - old_active_info = data.metadata.active_var_info + old_active_info = data.active_info new_display = f"\\mathcal{{F}}_{{{','.join(pre_dim_latexs)}}}[{old_active_info.display}]" new_var_infos[data.metadata.active_key] = old_active_info.assign(display=new_display) diff --git a/src/lib/data/adaptors/pos.py b/src/lib/data/adaptors/pos.py index d4c4485..a95ee00 100644 --- a/src/lib/data/adaptors/pos.py +++ b/src/lib/data/adaptors/pos.py @@ -60,7 +60,7 @@ def apply_list(self, data: List) -> List: df = df[df[dim] >= sel.start] if inc_lo else df[df[dim] > sel.start] if sel.stop is not None: df = df[df[dim] <= sel.stop] if inc_hi else df[df[dim] < sel.stop] - data = data.assign_data(df) + data = data.assign(df) return data diff --git a/src/lib/data/adaptors/set_scale.py b/src/lib/data/adaptors/set_scale.py index fffbdd1..16686c0 100644 --- a/src/lib/data/adaptors/set_scale.py +++ b/src/lib/data/adaptors/set_scale.py @@ -8,18 +8,17 @@ class SetScale(MetadataAdaptor): - def __init__(self, dim_name: str | None, scale: Scale): - self.dim_name = dim_name + def __init__(self, key: str | None, scale: Scale): + self.key = key self.scale = scale def apply(self, data: DataWithAttrs) -> DataWithAttrs: - dim_name = self.dim_name or data.metadata.active_key - new_var_infos = data.metadata.var_infos.copy() - new_var_infos[dim_name] = replace(new_var_infos[dim_name], scale=self.scale) - return data.assign_metadata(var_infos=new_var_infos) + key = self.key or data.metadata.active_key + info = replace(data.metadata.var_infos[key], scale=self.scale) + return data.with_info(key, info) def get_name_fragments(self) -> list[str]: - maybe_dim_name = f"{self.dim_name}=" if self.dim_name is not None else "" + maybe_dim_name = f"{self.key}=" if self.key is not None else "" return [f"scale_{maybe_dim_name}{self.scale.to_name_fragment_part()}"] diff --git a/src/lib/data/adaptors/species_filter.py b/src/lib/data/adaptors/species_filter.py index 7df36f8..7ce73c7 100644 --- a/src/lib/data/adaptors/species_filter.py +++ b/src/lib/data/adaptors/species_filter.py @@ -20,7 +20,7 @@ def apply_list(self, data: List) -> List: return data df = data.data df = df[(df["q"] == info.q) & (df["m"] == info.m)] - return data.assign_data(df).assign_metadata(subject=info.display) + return data.assign(df, subject=info.display) def get_name_fragments(self) -> list[str]: return [self.species_key] diff --git a/src/lib/data/adaptors/transform_polar.py b/src/lib/data/adaptors/transform_polar.py index 91ac031..9edbc35 100644 --- a/src/lib/data/adaptors/transform_polar.py +++ b/src/lib/data/adaptors/transform_polar.py @@ -93,7 +93,7 @@ def apply_list(self, data: List) -> List: new_var_infos = dict(data.metadata.var_infos) new_var_infos[key_r] = dim_r new_var_infos[key_theta] = dim_theta - return data.assign_data(df).assign_metadata(var_infos=new_var_infos) + return data.assign(df, var_infos=new_var_infos) def get_name_fragments(self) -> list[str]: return [f"polar_{self.dim1_key},{self.dim2_key}"] diff --git a/src/lib/data/adaptors/transform_spherical.py b/src/lib/data/adaptors/transform_spherical.py index 049adcf..89fe4a5 100644 --- a/src/lib/data/adaptors/transform_spherical.py +++ b/src/lib/data/adaptors/transform_spherical.py @@ -114,7 +114,7 @@ def apply_list(self, data: List) -> List: new_var_infos[key_r] = dim_r new_var_infos[key_theta] = dim_theta new_var_infos[key_phi] = dim_phi - return data.assign_data(df).assign_metadata(var_infos=new_var_infos) + return data.assign(df, var_infos=new_var_infos) def get_name_fragments(self) -> list[str]: return [f"spherical_{self.dim1_key},{self.dim2_key},{self.dim3_key}"] diff --git a/src/lib/data/adaptors/unit.py b/src/lib/data/adaptors/unit.py index 45d7e8f..6cc5833 100644 --- a/src/lib/data/adaptors/unit.py +++ b/src/lib/data/adaptors/unit.py @@ -6,27 +6,25 @@ class Unit(Adaptor): """Override the unit-LaTeX of the active variable or of a dimension.""" - def __init__(self, target: str | None, value: str): - self.target = target - self.value = value + def __init__(self, key: str | None, unit: str): + self.key = key + self.unit = unit def apply(self, data: DataWithAttrs) -> DataWithAttrs: metadata = data.metadata - target = self.target or metadata.active_key - if target is None: + key = self.key or metadata.active_key + if key is None: raise ValueError("--unit requires a target; specify a variable as a positional argument or use --unit TARGET=VALUE") - if target not in metadata.var_infos: - raise ValueError(f"--unit target {target!r} is not a known key ({sorted(metadata.var_infos)})") + if key not in metadata.var_infos: + raise ValueError(f"--unit target {key!r} is not a known key ({sorted(metadata.var_infos)})") - old_dim = metadata.var_infos[target] - new_dim = old_dim.assign(unit=self.value) - new_var_infos = {**metadata.var_infos, target: new_dim} - return data.assign_metadata(var_infos=new_var_infos) + info = metadata.var_infos[key].assign(unit=self.unit) + return data.with_info(key, info) def get_name_fragments(self) -> list[str]: - return [f"unit_{self.target or 'active'}={self.value}"] + return [f"unit_{self.key or 'active'}={self.unit}"] _UNIT_FORMAT = "[name=]unit_latex" @@ -40,6 +38,6 @@ def get_name_fragments(self) -> list[str]: ) def parse_unit(arg: str) -> Unit: if "=" in arg: - name, value = arg.split("=", 1) - return Unit(target=name, value=value) - return Unit(target=None, value=arg) + key, unit = arg.split("=", 1) + return Unit(key=key, unit=unit) + return Unit(key=None, unit=arg) diff --git a/src/lib/data/adaptors/versus.py b/src/lib/data/adaptors/versus.py index af2eb43..cd76479 100644 --- a/src/lib/data/adaptors/versus.py +++ b/src/lib/data/adaptors/versus.py @@ -96,9 +96,7 @@ def apply_field(self, data: Field) -> Field: dim = data.metadata.var_infos[dim_name] f_dim = dim.toggle_fourier() if f_dim.key in data.dims: - data = data.assign_metadata( - var_infos={**data.metadata.var_infos, f_dim.key: f_dim}, - ) + data = data.with_info(f_dim.key, f_dim) fourier = Fourier(f_dim.key) data = fourier.apply(data) continue @@ -120,7 +118,7 @@ def apply_list(self, data: List) -> List: # 2. drop unused vars keep_vars = self._get_retained_dim_keys(data) drop_vars = [active_key for active_key in data.dims if active_key not in keep_vars] - data = data.assign_data(data.data.drop(columns=drop_vars)) + data = data.assign(data.data.drop(columns=drop_vars)) spatial_dims = self.spatial_dims.copy() if len(spatial_dims) == 1 and data.metadata.active_key is not None and data.metadata.active_key not in spatial_dims: diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 0f58c84..6c52acc 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -14,10 +14,10 @@ def __init__(self, prepath: Prepath | None, key: str | None = None, *, include_w def apply_world(self, world): if not self.prepath: - return world.with_active(data=world.active_data.assign_metadata(active_key=self.key)) + return world.with_active(data=world.active_data.with_active(key=self.key)) if self.prepath in world.datas: - return world.with_active(prepath=self.prepath, data=world.datas[self.prepath].assign_metadata(active_key=self.key)) + return world.with_active(prepath=self.prepath, data=world.datas[self.prepath].with_active(key=self.key)) return world.with_active( prepath=self.prepath, diff --git a/src/lib/data/loader.py b/src/lib/data/loader.py index dbcf6e3..d7e2058 100644 --- a/src/lib/data/loader.py +++ b/src/lib/data/loader.py @@ -79,5 +79,5 @@ def load(config: PscPlotConfig, prepath: Prepath, active_key: str | None = None) data = derive_particle_variable(data, active_key, prefix) else: raise TypeError(data.__class__) - data = data.assign_metadata(active_key=active_key) + data = data.with_active(key=active_key) return data diff --git a/src/lib/data/loaders/particle_bp.py b/src/lib/data/loaders/particle_bp.py index f453fc3..81322cf 100644 --- a/src/lib/data/loaders/particle_bp.py +++ b/src/lib/data/loaders/particle_bp.py @@ -138,7 +138,7 @@ def get_data(self, config: PscPlotConfig) -> LazyList: slices.append(slice(i * chunk_size, (i + 1) * chunk_size)) meta = _build_meta(paths[0]) - df = dd.from_map(_read_chunk, paths, step_times, particle_dims, slices, meta=meta) + df: dd.DataFrame = dd.from_map(_read_chunk, paths, step_times, particle_dims, slices, meta=meta) corners = np.asarray(head["corner"]) lengths = np.asarray(head["length"]) @@ -153,12 +153,6 @@ def get_data(self, config: PscPlotConfig) -> LazyList: subject=info.display, partition_dim="t", partition_ranges=partition_ranges, + var_infos={key: lookup("prt", key) for key in df.columns}, ) - data = LazyList(df, metadata) - - # var_info registry is keyed by "prt" (not per-species), so strip the - # species suffix when looking up per-column metadata. - var_infos = {key: lookup("prt", key) for key in data.dims} - return data.assign_metadata( - var_infos=var_infos, - ) + return LazyList(df, metadata) diff --git a/src/lib/data/loaders/particle_h5.py b/src/lib/data/loaders/particle_h5.py index ede1649..639d8b4 100644 --- a/src/lib/data/loaders/particle_h5.py +++ b/src/lib/data/loaders/particle_h5.py @@ -211,12 +211,8 @@ def get_data(self, config: PscPlotConfig) -> LazyList: species=species_dict, partition_dim="t", partition_ranges=partition_ranges, - ) - - df_with_metadata = LazyList(df, metadata) - - var_infos = {key: lookup(self.prepath, key) for key in df_with_metadata.dims} - return df_with_metadata.assign_metadata( - var_infos=var_infos, + var_infos={key: lookup(self.prepath, key) for key in df.columns}, subject=Latex(r"\text{Particles}"), ) + + return LazyList(df, metadata) diff --git a/src/lib/derived_field_variables/derived_field_variable.py b/src/lib/derived_field_variables/derived_field_variable.py index 887a63e..187e7e6 100644 --- a/src/lib/derived_field_variables/derived_field_variable.py +++ b/src/lib/derived_field_variables/derived_field_variable.py @@ -30,7 +30,7 @@ def assign_to(self, field: Field) -> Field: da = self.derive(*(field.data[base_var_name] for base_var_name in self.base_var_names)) new_data = field.data | {self.name: da} new_var_infos = field.metadata.var_infos | {key: lookup(self.prefix, key) for key in (self.name, *da.dims)} - return field.assign_data(new_data).assign_metadata(var_infos=new_var_infos) + return field.assign(new_data, var_infos=new_var_infos) def __repr__(self) -> str: return f"{self.__class__.__name__}(({', '.join(self.base_var_names)}) -> {self.name}: {self.derive!r})" diff --git a/src/lib/derived_particle_variables/derived_particle_variable.py b/src/lib/derived_particle_variables/derived_particle_variable.py index 060b455..3b21b52 100644 --- a/src/lib/derived_particle_variables/derived_particle_variable.py +++ b/src/lib/derived_particle_variables/derived_particle_variable.py @@ -29,7 +29,10 @@ def assign_to(self, data: List) -> List: 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) + return data.assign( + df.assign(**{self.name: self.derive(*(df[base_var_name] for base_var_name in self.base_var_names))}), + var_infos=new_var_infos, + ) def __repr__(self) -> str: return f"{self.__class__.__name__}(({', '.join(self.base_var_names)}) -> {self.name}: {self.derive!r})" diff --git a/src/lib/plotting/renderer.py b/src/lib/plotting/renderer.py index c93ab3e..4db1184 100644 --- a/src/lib/plotting/renderer.py +++ b/src/lib/plotting/renderer.py @@ -13,7 +13,7 @@ def __init__(self, full_data: Data, plot_target: PlotTarget): self.plot_target = plot_target if isinstance(full_data, Field): - self.full_data = full_data.assign_metadata(active_key=plot_target.color_dim or plot_target.spatial_dims.y_dim) + self.full_data = full_data.with_active(key=plot_target.color_dim or plot_target.spatial_dims.y_dim) else: self.full_data = full_data From 861414e9d2422b9825ed18bf2499db2722fc9e79 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 14:34:14 -0400 Subject: [PATCH 21/45] data_with_attrs; *: -assign_data,metadata --- src/lib/data/adaptors/fourier.py | 2 +- src/lib/data/adaptors/species_filter.py | 7 +++++-- src/lib/data/adaptors/transform_polar.py | 2 +- src/lib/data/adaptors/transform_spherical.py | 2 +- src/lib/data/data_with_attrs.py | 22 +++++++------------- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/lib/data/adaptors/fourier.py b/src/lib/data/adaptors/fourier.py index 632d610..5047824 100644 --- a/src/lib/data/adaptors/fourier.py +++ b/src/lib/data/adaptors/fourier.py @@ -49,7 +49,7 @@ def apply_field(self, data: Field) -> Field: new_display = f"\\mathcal{{F}}_{{{','.join(pre_dim_latexs)}}}[{old_active_info.display}]" new_var_infos[data.metadata.active_key] = old_active_info.assign(display=new_display) - return data.with_active(data=da).assign_metadata(var_infos=new_var_infos) + return data.with_active(data=da).assign(var_infos=new_var_infos) def get_name_fragments(self) -> list[str]: return [f"fourier_{','.join(self.dim_keys)}"] diff --git a/src/lib/data/adaptors/species_filter.py b/src/lib/data/adaptors/species_filter.py index 7ce73c7..1bab816 100644 --- a/src/lib/data/adaptors/species_filter.py +++ b/src/lib/data/adaptors/species_filter.py @@ -12,15 +12,18 @@ def apply_list(self, data: List) -> List: if info is None: available = sorted(data.metadata.species.keys()) raise ValueError(f"unknown species {self.species_key!r}; available: {available}") - data = data.assign_metadata(subject=info.display) + + data = data.assign(subject=info.display) + if len(data.metadata.species) == 1: # Dataset is already single-species (e.g. BP per-species file, or H5 # with one species). Row filter is a no-op; skip to avoid requiring # q/m columns that BP data doesn't have. return data + df = data.data df = df[(df["q"] == info.q) & (df["m"] == info.m)] - return data.assign(df, subject=info.display) + return data.assign(df) def get_name_fragments(self) -> list[str]: return [self.species_key] diff --git a/src/lib/data/adaptors/transform_polar.py b/src/lib/data/adaptors/transform_polar.py index 9edbc35..57bb23c 100644 --- a/src/lib/data/adaptors/transform_polar.py +++ b/src/lib/data/adaptors/transform_polar.py @@ -76,7 +76,7 @@ def apply_field(self, data: Field) -> Field: new_var_infos = {k: v for k, v in data.metadata.var_infos.items() if k not in {key_x, key_y}} new_var_infos[key_r] = dim_r new_var_infos[key_theta] = dim_theta - return data.with_active(data=da).assign_metadata(var_infos=new_var_infos) + return data.with_active(data=da).assign(var_infos=new_var_infos) def apply_list(self, data: List) -> List: dim_x = data.metadata.var_infos[self.dim1_key] diff --git a/src/lib/data/adaptors/transform_spherical.py b/src/lib/data/adaptors/transform_spherical.py index 89fe4a5..c07e7f4 100644 --- a/src/lib/data/adaptors/transform_spherical.py +++ b/src/lib/data/adaptors/transform_spherical.py @@ -95,7 +95,7 @@ def apply_field(self, data: Field) -> Field: new_var_infos[key_r] = dim_r new_var_infos[key_theta] = dim_theta new_var_infos[key_phi] = dim_phi - return data.with_active(data=da).assign_metadata(var_infos=new_var_infos) + return data.with_active(data=da).assign(var_infos=new_var_infos) def apply_list(self, data: List) -> List: dim_x = data.metadata.var_infos[self.dim1_key] diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index fed3f3d..457c05d 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -97,18 +97,10 @@ def __getitem__(self, key: str) -> Subdata: ... def with_active(self, *, data: Subdata | None = None, key: str | None = None, info: VarInfo | None = None) -> Self: ... def with_info(self, key: str, info: VarInfo) -> Self: - return self.assign_metadata(var_infos=self.metadata.var_infos | {key: info}) + return self.assign(var_infos=self.metadata.var_infos | {key: info}) - def assign_data(self, data: Data) -> Self: - return self.__class__(data, self.metadata) - - def assign_metadata(self, metadata: MD | None = None, /, **metadata_vals: Any) -> Self: - if not (metadata or metadata_vals): - return self - return self.__class__(self.data, (metadata or self.metadata).assign(**metadata_vals)) - - def assign(self, data: Data, metadata: MD | None = None, /, **metadata_vals: Any) -> Self: - return self.assign_data(data).assign_metadata(metadata, **metadata_vals) + def assign(self, data: Data | None = None, /, **metadata_vals: Any) -> Self: + return self.__class__(self.data if data is None else data, self.metadata.assign(**metadata_vals)) @abstractmethod def bounds(self, dim_name: str) -> tuple[float, float]: ... @@ -137,7 +129,7 @@ def active_data(self) -> xr.DataArray: def with_active(self, *, data=None, key=None, info=None) -> Self: if data is None and info is None: - return self.assign_metadata(active_key=key) + return self.assign(active_key=key) key = key or self.metadata.active_key assert key is not None @@ -147,7 +139,7 @@ def with_active(self, *, data=None, key=None, info=None) -> Self: ret = ret.assign(ret.data | {key: data}, active_key=key) if info is not None: - ret = ret.assign_metadata(var_infos=ret.metadata.var_infos | {key: info}, active_key=key) + ret = ret.assign(var_infos=ret.metadata.var_infos | {key: info}, active_key=key) return ret @@ -211,7 +203,7 @@ def active_data(self) -> pd.Series | dd.Series: def with_active(self, *, data=None, key=None, info=None) -> Self: if data is None and info is None: - return self.assign_metadata(active_key=key) + return self.assign(active_key=key) key = key or self.metadata.active_key assert key is not None @@ -221,7 +213,7 @@ def with_active(self, *, data=None, key=None, info=None) -> Self: ret = ret.assign(ret.data.assign(**{key: data}), active_key=key) if info is not None: - ret = ret.assign_metadata(var_infos=ret.metadata.var_infos | {key: info}, active_key=key) + ret = ret.assign(var_infos=ret.metadata.var_infos | {key: info}, active_key=key) return ret From 3d5bfce97d25ee6a1b306818109e1453646e9c72 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 14:53:59 -0400 Subject: [PATCH 22/45] versus: don't autoFourier; simplify --- src/lib/data/adaptors/versus.py | 48 +++++++-------------------------- 1 file changed, 10 insertions(+), 38 deletions(-) diff --git a/src/lib/data/adaptors/versus.py b/src/lib/data/adaptors/versus.py index cd76479..80f49ce 100644 --- a/src/lib/data/adaptors/versus.py +++ b/src/lib/data/adaptors/versus.py @@ -2,7 +2,6 @@ from typing import Literal from lib.data.adaptor import MetadataAdaptor -from lib.data.adaptors.fourier import Fourier from lib.data.adaptors.reduce import Reduce from lib.data.data_with_attrs import DataWithAttrs, Field, List from lib.data.plot_target import PlotTarget, SpatialDims, SpatialDimsRTheta, SpatialDimsXY @@ -24,19 +23,19 @@ def __init__( self.color_dim = color_dim self.axes_idx = axes_idx - def _get_retained_dim_keys(self, data: DataWithAttrs) -> list[str]: - retained_dims = self.spatial_dims.copy() + def _get_unused_dims(self, data: DataWithAttrs) -> list[str]: + unused_dims = data.dims.copy() + + for space_dim in self.spatial_dims: + unused_dims.remove(space_dim) if time_dim := self._get_time_dim(data): - retained_dims.append(time_dim) + unused_dims.remove(time_dim) if self.color_dim: - retained_dims.append(self.color_dim) - - if isinstance(data, List) and data.metadata.active_key: - retained_dims.append(data.metadata.active_key) + unused_dims.remove(self.color_dim) - return retained_dims + return unused_dims def _get_time_dim(self, data: DataWithAttrs) -> str | None: if self.time_dim_rule != "guess": @@ -85,40 +84,13 @@ def apply_world(self, world): ) def apply_field(self, data: Field) -> Field: - # 1. apply implicit coordinate transforms, as necessary - retained_dims = self._get_retained_dim_keys(data) - for dim_name in retained_dims: - # 1a. already have the coordinate; do nothing - if dim_name in data.dims: - continue - - # 1b. need to do a Fourier transform - dim = data.metadata.var_infos[dim_name] - f_dim = dim.toggle_fourier() - if f_dim.key in data.dims: - data = data.with_info(f_dim.key, f_dim) - fourier = Fourier(f_dim.key) - data = fourier.apply(data) - continue - - # 1c. need to do a coordinate transform - # TODO - - # 2. reduce remaining dimensions via arithmetic mean - reduce_dims = [dim for dim in data.dims if dim not in retained_dims] - reduce = Reduce(reduce_dims, "mean") + reduce = Reduce(self._get_unused_dims(data), "mean") data = reduce.apply(data) return data def apply_list(self, data: List) -> List: - # 1. coordinate transform - # TODO - - # 2. drop unused vars - keep_vars = self._get_retained_dim_keys(data) - drop_vars = [active_key for active_key in data.dims if active_key not in keep_vars] - data = data.assign(data.data.drop(columns=drop_vars)) + data = data.assign(data.data.drop(columns=self._get_unused_dims(data))) spatial_dims = self.spatial_dims.copy() if len(spatial_dims) == 1 and data.metadata.active_key is not None and data.metadata.active_key not in spatial_dims: From d458a219ad0828e968453d6d0b53a8b07fd4f40c Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 15:02:56 -0400 Subject: [PATCH 23/45] versus: don't drop columns; more simple --- src/lib/data/adaptors/versus.py | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/src/lib/data/adaptors/versus.py b/src/lib/data/adaptors/versus.py index 80f49ce..ffb964d 100644 --- a/src/lib/data/adaptors/versus.py +++ b/src/lib/data/adaptors/versus.py @@ -23,20 +23,6 @@ def __init__( self.color_dim = color_dim self.axes_idx = axes_idx - def _get_unused_dims(self, data: DataWithAttrs) -> list[str]: - unused_dims = data.dims.copy() - - for space_dim in self.spatial_dims: - unused_dims.remove(space_dim) - - if time_dim := self._get_time_dim(data): - unused_dims.remove(time_dim) - - if self.color_dim: - unused_dims.remove(self.color_dim) - - return unused_dims - def _get_time_dim(self, data: DataWithAttrs) -> str | None: if self.time_dim_rule != "guess": return self.time_dim_rule @@ -84,18 +70,12 @@ def apply_world(self, world): ) def apply_field(self, data: Field) -> Field: - reduce = Reduce(self._get_unused_dims(data), "mean") - data = reduce.apply(data) - - return data + used_dims = {*self.spatial_dims, self._get_time_dim(data), self.color_dim} + reduce_dims = [dim for dim in data.dims if dim not in used_dims] # preserve order + reduce = Reduce(reduce_dims, "mean") + return reduce.apply(data) def apply_list(self, data: List) -> List: - data = data.assign(data.data.drop(columns=self._get_unused_dims(data))) - - spatial_dims = self.spatial_dims.copy() - if len(spatial_dims) == 1 and data.metadata.active_key is not None and data.metadata.active_key not in spatial_dims: - spatial_dims.append(data.metadata.active_key) - return data def get_name_fragments(self) -> list[str]: From f9f9e79fc19ad23cc0a1faaee5fd07654e08a70b Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 15:14:11 -0400 Subject: [PATCH 24/45] data_with_attrs; *: ->require_active_subdata --- src/lib/data/adaptor.py | 4 ++-- src/lib/data/adaptors/bin.py | 2 +- src/lib/data/adaptors/compute.py | 2 +- src/lib/data/adaptors/fourier.py | 2 +- src/lib/data/adaptors/idx.py | 2 +- src/lib/data/adaptors/pos.py | 2 +- src/lib/data/adaptors/scatter.py | 4 ++-- src/lib/data/adaptors/transform_polar.py | 2 +- src/lib/data/adaptors/transform_spherical.py | 2 +- src/lib/data/data_with_attrs.py | 25 +++++++------------- src/lib/plotting/hooks/show_com.py | 2 +- src/lib/plotting/renderers/field_1d.py | 4 ++-- src/lib/plotting/renderers/field_2d.py | 4 ++-- src/lib/plotting/renderers/polar_field.py | 4 ++-- 14 files changed, 27 insertions(+), 34 deletions(-) diff --git a/src/lib/data/adaptor.py b/src/lib/data/adaptor.py index 91b938d..4a7036f 100644 --- a/src/lib/data/adaptor.py +++ b/src/lib/data/adaptor.py @@ -71,10 +71,10 @@ class BareAdaptor(MetadataAdaptor): """An adaptor that works with the raw data, no metadata required.""" def apply_field(self, data: Field) -> DataWithAttrs: - return data.with_active(data=self.apply_field_bare(data.active_data)) + return data.with_active(data=self.apply_field_bare(data.require_active_subdata())) def apply_list(self, data: List) -> DataWithAttrs: - return data.with_active(data=self.apply_list_bare(data.active_data)) + return data.with_active(data=self.apply_list_bare(data.require_active_subdata())) def apply_field_bare(self, da: xr.DataArray) -> xr.DataArray: _fail_apply_field(self.__class__) diff --git a/src/lib/data/adaptors/bin.py b/src/lib/data/adaptors/bin.py index 7c08071..aafc174 100644 --- a/src/lib/data/adaptors/bin.py +++ b/src/lib/data/adaptors/bin.py @@ -93,7 +93,7 @@ def apply_field(self, data: Field) -> Field: dim_names_to_bin_size[dim_name] = bin_size - return data.with_active(data=data.active_data.coarsen(dim_names_to_bin_size, boundary="pad").mean()) + return data.with_active(data=data.require_active_subdata().coarsen(dim_names_to_bin_size, boundary="pad").mean()) def apply_list(self, data: List) -> Field: bin_edgess = _guess_bin_edgess(data, self.varname_to_nbins) diff --git a/src/lib/data/adaptors/compute.py b/src/lib/data/adaptors/compute.py index 370df68..f9a4b83 100644 --- a/src/lib/data/adaptors/compute.py +++ b/src/lib/data/adaptors/compute.py @@ -13,4 +13,4 @@ def apply_list(self, data: List) -> FullList: return data.compute() def apply_field(self, data: Field) -> Field: - return data.with_active(data=data.active_data.compute()) + return data.with_active(data=data.require_active_subdata().compute()) diff --git a/src/lib/data/adaptors/fourier.py b/src/lib/data/adaptors/fourier.py index 5047824..7121302 100644 --- a/src/lib/data/adaptors/fourier.py +++ b/src/lib/data/adaptors/fourier.py @@ -36,7 +36,7 @@ def __init__(self, dim_keys: str | list[str]): def apply_field(self, data: Field) -> Field: pre_dim_latexs = [data.metadata.var_infos[key].display.latex for key in self.dim_keys] - da = data.active_data + da = data.require_active_subdata() new_var_infos = data.metadata.var_infos.copy() for key in self.dim_keys: diff --git a/src/lib/data/adaptors/idx.py b/src/lib/data/adaptors/idx.py index 78f593e..3f35d64 100644 --- a/src/lib/data/adaptors/idx.py +++ b/src/lib/data/adaptors/idx.py @@ -10,7 +10,7 @@ def __init__(self, dim_names_to_isel: dict[str, int | slice]): self.dim_names_to_isel = dim_names_to_isel def apply_field(self, data: Field) -> Field: - return data.with_active(data=data.active_data.isel(self.dim_names_to_isel)) + return data.with_active(data=data.require_active_subdata().isel(self.dim_names_to_isel)) def apply_list(self, data: List) -> List: coordss = data.coordss.copy() diff --git a/src/lib/data/adaptors/pos.py b/src/lib/data/adaptors/pos.py index a95ee00..ce0718b 100644 --- a/src/lib/data/adaptors/pos.py +++ b/src/lib/data/adaptors/pos.py @@ -33,7 +33,7 @@ def __init__( def apply_field(self, data: Field) -> Field: dim_names_to_pos = {dim_name: pos for dim_name, pos in self.dim_names_to_sel.items() if isinstance(pos, float)} dim_names_to_slice = {dim_name: s for dim_name, s in self.dim_names_to_sel.items() if isinstance(s, slice)} - return data.with_active(data=data.active_data.sel(dim_names_to_pos, method="nearest").sel(dim_names_to_slice)) + return data.with_active(data=data.require_active_subdata().sel(dim_names_to_pos, method="nearest").sel(dim_names_to_slice)) def apply_list(self, data: List) -> List: # Lazy-import Idx to avoid a circular import via lib.plotting.animated_plot. diff --git a/src/lib/data/adaptors/scatter.py b/src/lib/data/adaptors/scatter.py index eb93e16..15af561 100644 --- a/src/lib/data/adaptors/scatter.py +++ b/src/lib/data/adaptors/scatter.py @@ -17,14 +17,14 @@ def apply_field(self, data: Field) -> LazyList: ordered_coordss = [coordss[dim] for dim in data.dims] coord_grids = np.meshgrid(*ordered_coordss, indexing="ij") - vars = {data.metadata.active_key: da.ravel(data.active_data)} | dict(zip(data.dims, (da.ravel(coord_grid) for coord_grid in coord_grids))) + vars = {data.active_key: da.ravel(data.require_active_subdata())} | dict(zip(data.dims, (da.ravel(coord_grid) for coord_grid in coord_grids))) df = dd.from_dask_array(da.vstack(vars.values()).T, columns=list(vars)) metadata = ListMetadata.create_from( data.metadata, coordss=coordss, - weight_key=data.metadata.active_key, + weight_key=data.active_key, subject=self.subject, ) diff --git a/src/lib/data/adaptors/transform_polar.py b/src/lib/data/adaptors/transform_polar.py index 57bb23c..c152e02 100644 --- a/src/lib/data/adaptors/transform_polar.py +++ b/src/lib/data/adaptors/transform_polar.py @@ -68,7 +68,7 @@ def apply_field(self, data: Field) -> Field: xgrid = xr.Variable([key_r, key_theta], xgrid) ygrid = xr.Variable([key_r, key_theta], ygrid) - da = data.active_data + da = data.require_active_subdata() da = da.interp({key_x: xgrid, key_y: ygrid}, assume_sorted=True) da = da.drop_vars([key_x, key_y]) da = da.assign_coords({key_r: rs, key_theta: thetas}) diff --git a/src/lib/data/adaptors/transform_spherical.py b/src/lib/data/adaptors/transform_spherical.py index c07e7f4..a7e6da7 100644 --- a/src/lib/data/adaptors/transform_spherical.py +++ b/src/lib/data/adaptors/transform_spherical.py @@ -86,7 +86,7 @@ def apply_field(self, data: Field) -> Field: ygrid = xr.Variable([key_r, key_theta, key_phi], ygrid) zgrid = xr.Variable([key_r, key_theta, key_phi], zgrid) - da = data.active_data + da = data.require_active_subdata() da = da.interp({key_x: xgrid, key_y: ygrid, key_z: zgrid}, assume_sorted=True) da = da.drop_vars([key_x, key_y, key_z]) da = da.assign_coords({key_r: rs, key_theta: thetas, key_phi: phis}) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index 457c05d..51cd4c1 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field, fields from functools import cached_property -from typing import Any, Callable, Self +from typing import Any, Self import dask.array import dask.dataframe as dd @@ -84,6 +84,11 @@ def active_subdata(self) -> Subdata | None: return None return self[self.active_key] + def require_active_subdata(self) -> Subdata: + if self.active_key is None: + raise ValueError("No active variable.") + return self[self.active_key] + @property def active_info(self) -> VarInfo | None: if self.active_key is None: @@ -121,12 +126,6 @@ class FieldMetadata(Metadata): class Field(DataWithAttrs[dict[str, xr.DataArray], xr.DataArray, FieldMetadata]): - @property - def active_data(self) -> xr.DataArray: - if self.metadata.active_key is None: - raise ValueError("no active variable; specify one as a positional argument") - return self.data[self.metadata.active_key] - def with_active(self, *, data=None, key=None, info=None) -> Self: if data is None and info is None: return self.assign(active_key=key) @@ -148,12 +147,12 @@ def __getitem__(self, key: str): @cached_property def coordss(self) -> dict[str, np.ndarray]: - active = self.active_data + active = self.require_active_subdata() return {dim: np.array(active.coords[dim]) for dim in active.coords.keys()} @cached_property def dims(self) -> list[str]: - return list(self.active_data.dims) + return list(self.require_active_subdata().dims) def bounds(self, dim_name): return (self.lower_bound(dim_name), self.upper_bound(dim_name)) @@ -168,7 +167,7 @@ def upper_bound(self, dim_name) -> float: @cached_property def var_bounds(self) -> tuple[float, float]: - active = self.active_data + active = self.require_active_subdata() return dask.compute(np.min(active), np.max(active)) def dask_collections(self) -> list: @@ -195,12 +194,6 @@ class ListMetadata(Metadata): class List[Data: pd.DataFrame | dd.DataFrame = pd.DataFrame | dd.DataFrame, Subdata: pd.Series | dd.Series = pd.Series | dd.Series](DataWithAttrs[Data, Subdata, ListMetadata]): - @property - def active_data(self) -> pd.Series | dd.Series: - if self.metadata.active_key is None: - raise ValueError("no active variable; specify one as a positional argument") - return self.data[self.metadata.active_key] - def with_active(self, *, data=None, key=None, info=None) -> Self: if data is None and info is None: return self.assign(active_key=key) diff --git a/src/lib/plotting/hooks/show_com.py b/src/lib/plotting/hooks/show_com.py index 3c499f5..74fa848 100644 --- a/src/lib/plotting/hooks/show_com.py +++ b/src/lib/plotting/hooks/show_com.py @@ -6,7 +6,7 @@ def _get_center(field: Field, dim: str) -> float: other_dims = set(field.dims) - {dim} - summed = field.active_data.sum(other_dims) + summed = field.require_active_subdata().sum(other_dims) return (summed * field.coordss[dim]).sum(dim) / summed.sum(dim) diff --git a/src/lib/plotting/renderers/field_1d.py b/src/lib/plotting/renderers/field_1d.py index 9226b48..aba21d2 100644 --- a/src/lib/plotting/renderers/field_1d.py +++ b/src/lib/plotting/renderers/field_1d.py @@ -13,7 +13,7 @@ def init_plot_info(self) -> PlotInfo: plot_info = LineInfo( x_data=frame_data.coordss[x_dim], - y_data=frame_data.active_data, + y_data=frame_data.require_active_subdata(), x_dim=x_dim, y_dim=y_dim, time_dim=self.plot_target.time_dim, @@ -48,5 +48,5 @@ def init_plot_info(self) -> PlotInfo: def update_plot_info(self, frame: int): frame_data = self._get_data_at_frame(frame) - self.plot_info.set("y_data", frame_data.active_data) + self.plot_info.set("y_data", frame_data.require_active_subdata()) self.plot_info.set("scalar_coord_values", {dim: coord for dim, coord in frame_data.coordss.items() if coord.shape == ()}) diff --git a/src/lib/plotting/renderers/field_2d.py b/src/lib/plotting/renderers/field_2d.py index 8303628..cfc756e 100644 --- a/src/lib/plotting/renderers/field_2d.py +++ b/src/lib/plotting/renderers/field_2d.py @@ -19,7 +19,7 @@ def init_plot_info(self) -> PlotInfo: [x_dim, y_dim] = self.plot_target.spatial_dims.unpack() color_dim = self.plot_target.color_dim - data = frame_data.active_data.transpose(y_dim, x_dim) + data = frame_data.require_active_subdata().transpose(y_dim, x_dim) plot_info = ImageInfo( data=data, @@ -63,7 +63,7 @@ def update_plot_info(self, frame: int): frame_data = self._get_data_at_frame(frame) [x_dim, y_dim] = self.plot_target.spatial_dims.unpack() - data = frame_data.active_data.transpose(y_dim, x_dim) + data = frame_data.require_active_subdata().transpose(y_dim, x_dim) self.plot_info.set("data", data) self.plot_info.set("scalar_coord_values", {dim: coord for dim, coord in frame_data.coordss.items() if coord.shape == ()}) diff --git a/src/lib/plotting/renderers/polar_field.py b/src/lib/plotting/renderers/polar_field.py index 0942504..78c9e7f 100644 --- a/src/lib/plotting/renderers/polar_field.py +++ b/src/lib/plotting/renderers/polar_field.py @@ -26,7 +26,7 @@ def init_plot_info(self) -> PlotInfo: theta_vertices -= theta_vertices[1] / 2.0 plot_info = PolarMeshInfo( - data=frame_data.active_data, + data=frame_data.require_active_subdata(), r_dim=r_dim, theta_dim=theta_dim, color_dim=color_dim, @@ -65,5 +65,5 @@ def init_plot_info(self) -> PlotInfo: def update_plot_info(self, frame: int): frame_data = self._get_data_at_frame(frame) - self.plot_info.set("data", frame_data.active_data) + self.plot_info.set("data", frame_data.require_active_subdata()) self.plot_info.set("scalar_coord_values", {dim: coord for dim, coord in frame_data.coordss.items() if coord.shape == ()}) From 2f8cd4a45f0370e8825a11d488e35281a8fd19a7 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 15:44:47 -0400 Subject: [PATCH 25/45] derive: -assign_default --- src/lib/data/adaptors/derive.py | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index ca32a41..a64533c 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -116,10 +116,6 @@ def exponentiation(self, toks: list): [lhs, rhs] = toks return lhs**rhs - def assign_default(self, toks: list): - [new_variable] = toks - return derive_particle_variable(self._data, new_variable, "prt") - def assignment(self, toks: list): [new_variable, val] = toks return self._data.with_active(key=new_variable, data=val) @@ -170,12 +166,6 @@ def exponentiation(self, toks: list): [lhs, rhs] = toks return lhs**rhs - def assign_default(self, toks: list): - [key] = toks - self._data = _resolve_field_variable(self._data, key) - info = var_info_registry.lookup(self._data.metadata.prepath, key) - return self._data.with_active(key=key, info=info) - def assignment(self, toks: list): [key, subdata] = toks info = var_info_registry.lookup(self._data.metadata.prepath, key) @@ -183,11 +173,10 @@ def assignment(self, toks: list): _DERIVE_GRAMMAR = r""" -?start : assign_default | assignment +?start : assignment -assign_default : new_variable -assignment : new_variable "=" expression -new_variable : CNAME +assignment : new_variable "=" expression +new_variable : CNAME ?expression : _expression_3 @@ -220,16 +209,14 @@ def assignment(self, toks: list): _DERIVE_PARSER = Lark(_DERIVE_GRAMMAR) - -_DERIVE_FORMAT = "new_var_key[=expression]" -_EXPRESSION_DESCRIPTION = "The expression can be any mathematical expression using the standard operators (+, -, *, /, ^), parentheses, signed floating point numbers, and existing variable names. A name may be scoped to another prefix as prefix::key (that prefix is auto-loaded)." +_DERIVE_FORMAT = "new_var_key=expression" @arg_parser( dest="adaptors", flags="--derive", metavar=_DERIVE_FORMAT, - help=f"Create a new variable with the given name. {_EXPRESSION_DESCRIPTION} If the expression is omitted, the variable is derived via the registry of derivable variables.", + help=f"Create a new variable with the given name. The expression can be any mathematical expression using the standard operators (+, -, *, /, ^), parentheses, signed floating point numbers, and other variable names. A name may be scoped to another prefix as `prepath::key` (similar to `--with`).", ) def parse_derive(arg: str) -> Derive: return Derive(arg) From fb97f52dcd3203a7ff3c97e5ca17570752cb00a8 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 15:54:40 -0400 Subject: [PATCH 26/45] +ensure_derived; loader --- src/lib/data/ensure_derived.py | 15 +++++++++++++++ src/lib/data/loader.py | 14 +++----------- 2 files changed, 18 insertions(+), 11 deletions(-) create mode 100644 src/lib/data/ensure_derived.py diff --git a/src/lib/data/ensure_derived.py b/src/lib/data/ensure_derived.py new file mode 100644 index 0000000..41e2626 --- /dev/null +++ b/src/lib/data/ensure_derived.py @@ -0,0 +1,15 @@ +from lib.data.data_with_attrs import DataWithAttrs, Field, List +from lib.derived_field_variables.derived_field_variable import derive_field_variable +from lib.derived_particle_variables.derived_particle_variable import derive_particle_variable +from lib.file_util import Prepath, split_prepath + + +def ensure_derived[D: DataWithAttrs](data: D, prepath: Prepath, key: str) -> D: + _, prefix = split_prepath(prepath) + if isinstance(data, Field): + data = derive_field_variable(data, key, prefix) + elif isinstance(data, List): + data = derive_particle_variable(data, key, prefix) + else: + raise TypeError(data.__class__) + return data.with_active(key=key) diff --git a/src/lib/data/loader.py b/src/lib/data/loader.py index d7e2058..5880fda 100644 --- a/src/lib/data/loader.py +++ b/src/lib/data/loader.py @@ -4,9 +4,8 @@ from lib.config import PscPlotConfig from lib.data.adaptor import WorldAdaptor -from lib.data.data_with_attrs import DataWithAttrs, Field, List -from lib.derived_field_variables.derived_field_variable import derive_field_variable -from lib.derived_particle_variables.derived_particle_variable import derive_particle_variable +from lib.data.data_with_attrs import DataWithAttrs +from lib.data.ensure_derived import ensure_derived from lib.file_util import Prepath, split_prepath @@ -72,12 +71,5 @@ def load(config: PscPlotConfig, prepath: Prepath, active_key: str | None = None) loader = get_loader(config.data_root, prepath) data = loader.get_data(config) if active_key: - _, prefix = split_prepath(prepath) - if isinstance(data, Field): - data = derive_field_variable(data, active_key, prefix) - elif isinstance(data, List): - data = derive_particle_variable(data, active_key, prefix) - else: - raise TypeError(data.__class__) - data = data.with_active(key=active_key) + data = ensure_derived(data, prepath, active_key) return data From 08189f1459279c8b681c4378c58d908c515a56ba Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:05:53 -0400 Subject: [PATCH 27/45] loader; with: don't derive on load --- src/lib/data/adaptors/with_.py | 9 +++++---- src/lib/data/loader.py | 8 ++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 6c52acc..6f8942f 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -1,5 +1,6 @@ from lib.config import _DATA_DIR_KEY from lib.data.adaptor import WorldAdaptor +from lib.data.ensure_derived import ensure_derived from lib.data.loader import load from lib.file_util import Prepath from lib.parsing import parse_util @@ -19,10 +20,10 @@ def apply_world(self, world): if self.prepath in world.datas: return world.with_active(prepath=self.prepath, data=world.datas[self.prepath].with_active(key=self.key)) - return world.with_active( - prepath=self.prepath, - data=load(world.config, self.prepath, self.key), - ) + data = load(world.config, self.prepath) + data = ensure_derived(data, self.prepath, self.key) + + return world.with_active(prepath=self.prepath, data=data) def get_name_fragments(self) -> list[str]: maybe_with = "with_" if self.include_with_in_name_fragment else "" diff --git a/src/lib/data/loader.py b/src/lib/data/loader.py index 5880fda..9c04f68 100644 --- a/src/lib/data/loader.py +++ b/src/lib/data/loader.py @@ -5,7 +5,6 @@ from lib.config import PscPlotConfig from lib.data.adaptor import WorldAdaptor from lib.data.data_with_attrs import DataWithAttrs -from lib.data.ensure_derived import ensure_derived from lib.file_util import Prepath, split_prepath @@ -67,9 +66,6 @@ def get_loader(data_root: Path, prepath: Prepath) -> Loader: return loader_types[prefix](prepath) -def load(config: PscPlotConfig, prepath: Prepath, active_key: str | None = None) -> DataWithAttrs: +def load(config: PscPlotConfig, prepath: Prepath) -> DataWithAttrs: loader = get_loader(config.data_root, prepath) - data = loader.get_data(config) - if active_key: - data = ensure_derived(data, prepath, active_key) - return data + return loader.get_data(config) From 37683833faa056118081cf4601f2e7881ac11d64 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:06:16 -0400 Subject: [PATCH 28/45] ensure_derived; with: don't set active --- src/lib/data/adaptors/with_.py | 1 + src/lib/data/ensure_derived.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 6f8942f..46ea816 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -22,6 +22,7 @@ def apply_world(self, world): data = load(world.config, self.prepath) data = ensure_derived(data, self.prepath, self.key) + data = data.with_active(key=self.key) return world.with_active(prepath=self.prepath, data=data) diff --git a/src/lib/data/ensure_derived.py b/src/lib/data/ensure_derived.py index 41e2626..76a3d47 100644 --- a/src/lib/data/ensure_derived.py +++ b/src/lib/data/ensure_derived.py @@ -7,9 +7,8 @@ def ensure_derived[D: DataWithAttrs](data: D, prepath: Prepath, key: str) -> D: _, prefix = split_prepath(prepath) if isinstance(data, Field): - data = derive_field_variable(data, key, prefix) + return derive_field_variable(data, key, prefix) elif isinstance(data, List): - data = derive_particle_variable(data, key, prefix) + return derive_particle_variable(data, key, prefix) else: raise TypeError(data.__class__) - return data.with_active(key=key) From 7ecaa0fcf6e2d35b5073cdd6bb431fe15ee3c554 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:09:12 -0400 Subject: [PATCH 29/45] data_world: +require_active_data --- src/lib/data/data_world.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/data/data_world.py b/src/lib/data/data_world.py index f8595ea..ce7f8f2 100644 --- a/src/lib/data/data_world.py +++ b/src/lib/data/data_world.py @@ -26,6 +26,11 @@ def active_data(self) -> DataWithAttrs | None: return None return self.datas[self.active_prepath] + def require_active_data(self) -> DataWithAttrs: + if self.active_prepath is None: + raise ValueError("no active dataset; specify one as a positional argument") + return self.datas[self.active_prepath] + def with_active( self, *, From a7df3c8b22f4c238d1a86f7bc802d0df21b18a38 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:10:48 -0400 Subject: [PATCH 30/45] with: fix/cleanup apply_world --- src/lib/data/adaptors/with_.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 46ea816..2f4144c 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -15,13 +15,14 @@ def __init__(self, prepath: Prepath | None, key: str | None = None, *, include_w def apply_world(self, world): if not self.prepath: - return world.with_active(data=world.active_data.with_active(key=self.key)) - - if self.prepath in world.datas: - return world.with_active(prepath=self.prepath, data=world.datas[self.prepath].with_active(key=self.key)) - - data = load(world.config, self.prepath) - data = ensure_derived(data, self.prepath, self.key) + data = world.require_active_data() + elif self.prepath in world.datas: + data = world.datas[self.prepath] + else: + data = load(world.config, self.prepath) + + if self.key: + data = ensure_derived(data, self.prepath, self.key) data = data.with_active(key=self.key) return world.with_active(prepath=self.prepath, data=data) From 5063b53451eb30c6c61c02c8ddacf8708904607a Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:29:16 -0400 Subject: [PATCH 31/45] data_with_attrs; *: all metadata has prepath --- src/lib/data/data_with_attrs.py | 5 ++--- src/lib/data/loaders/particle_bp.py | 1 + src/lib/data/loaders/particle_h5.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/data/data_with_attrs.py b/src/lib/data/data_with_attrs.py index 51cd4c1..14569ee 100644 --- a/src/lib/data/data_with_attrs.py +++ b/src/lib/data/data_with_attrs.py @@ -19,6 +19,7 @@ @dataclass(kw_only=True, frozen=True) class Metadata: + prepath: Prepath active_key: str | None = None var_infos: dict[str, VarInfo] = field(default_factory=dict) @@ -120,9 +121,7 @@ def upper_bound(self, dim_name: str) -> float: ... def dask_collections(self) -> list: ... -@dataclass(kw_only=True, frozen=True) -class FieldMetadata(Metadata): - prepath: Prepath | None = None +class FieldMetadata(Metadata): ... class Field(DataWithAttrs[dict[str, xr.DataArray], xr.DataArray, FieldMetadata]): diff --git a/src/lib/data/loaders/particle_bp.py b/src/lib/data/loaders/particle_bp.py index 81322cf..471c696 100644 --- a/src/lib/data/loaders/particle_bp.py +++ b/src/lib/data/loaders/particle_bp.py @@ -147,6 +147,7 @@ def get_data(self, config: PscPlotConfig) -> LazyList: coordss["t"] = times metadata = ListMetadata( + prepath=self.prepath, weight_key="w", coordss=coordss, species=species_dict, diff --git a/src/lib/data/loaders/particle_h5.py b/src/lib/data/loaders/particle_h5.py index 639d8b4..5cfc3ef 100644 --- a/src/lib/data/loaders/particle_h5.py +++ b/src/lib/data/loaders/particle_h5.py @@ -206,6 +206,7 @@ def get_data(self, config: PscPlotConfig) -> LazyList: coordss["t"] = times metadata = ListMetadata( + prepath=self.prepath, weight_key="w", coordss=coordss, species=species_dict, From d387ae7e7201f5458908731b7fbd0e0520b93dd3 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:29:30 -0400 Subject: [PATCH 32/45] ensure_derived; *: use metadata prepath --- src/lib/data/adaptors/with_.py | 2 +- src/lib/data/ensure_derived.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/data/adaptors/with_.py b/src/lib/data/adaptors/with_.py index 2f4144c..4d5ddd4 100644 --- a/src/lib/data/adaptors/with_.py +++ b/src/lib/data/adaptors/with_.py @@ -22,7 +22,7 @@ def apply_world(self, world): data = load(world.config, self.prepath) if self.key: - data = ensure_derived(data, self.prepath, self.key) + data = ensure_derived(data, self.key) data = data.with_active(key=self.key) return world.with_active(prepath=self.prepath, data=data) diff --git a/src/lib/data/ensure_derived.py b/src/lib/data/ensure_derived.py index 76a3d47..2803928 100644 --- a/src/lib/data/ensure_derived.py +++ b/src/lib/data/ensure_derived.py @@ -1,11 +1,11 @@ from lib.data.data_with_attrs import DataWithAttrs, Field, List from lib.derived_field_variables.derived_field_variable import derive_field_variable from lib.derived_particle_variables.derived_particle_variable import derive_particle_variable -from lib.file_util import Prepath, split_prepath +from lib.file_util import split_prepath -def ensure_derived[D: DataWithAttrs](data: D, prepath: Prepath, key: str) -> D: - _, prefix = split_prepath(prepath) +def ensure_derived[D: DataWithAttrs](data: D, key: str) -> D: + _, prefix = split_prepath(data.metadata.prepath) if isinstance(data, Field): return derive_field_variable(data, key, prefix) elif isinstance(data, List): From 3f44cc3b7be6924dbcbf789cd0db3b2d115d3637 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:37:52 -0400 Subject: [PATCH 33/45] derived_particle_variable: hacky fix for prt.i, etc --- .../derived_particle_variable.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/lib/derived_particle_variables/derived_particle_variable.py b/src/lib/derived_particle_variables/derived_particle_variable.py index 3b21b52..27f05bc 100644 --- a/src/lib/derived_particle_variables/derived_particle_variable.py +++ b/src/lib/derived_particle_variables/derived_particle_variable.py @@ -6,7 +6,7 @@ from lib import var_info_registry from lib.data.data_with_attrs import List -__all__ = ["derived_particle_variable", "derive_particle_variable", "DERIVED_PARTICLE_VARIABLES"] +__all__ = ["derived_particle_variable", "derive_particle_variable"] class DeriveParticleVariable(typing.Protocol): @@ -45,6 +45,13 @@ def register_derived_particle_variable(prefix: str, var: DerivedParticleVariable DERIVED_PARTICLE_VARIABLES.setdefault(prefix, {})[var.name] = var +def get_derived_particle_variables(prefix: str) -> dict[str, DerivedParticleVariable]: + if prefix.startswith("prt."): + # FIXME this is a hardcoded hack + prefix = "prt" + return DERIVED_PARTICLE_VARIABLES[prefix] + + def derived_particle_variable(prefix: str): def derived_particle_variable_inner[F: (function, DeriveParticleVariable)](derive_func: F) -> F: name = derive_func.__name__ @@ -58,13 +65,16 @@ def derived_particle_variable_inner[F: (function, DeriveParticleVariable)](deriv def derive_particle_variable(data: List, active_key: str, ds_prefix: str) -> List: if active_key in data.dims: return data - elif active_key in DERIVED_PARTICLE_VARIABLES[ds_prefix]: - derived_var = DERIVED_PARTICLE_VARIABLES[ds_prefix][active_key] + + derived_vars = get_derived_particle_variables(ds_prefix) + + if active_key in derived_vars: + derived_var = derived_vars[active_key] for base_var_name in derived_var.base_var_names: data = derive_particle_variable(data, base_var_name, ds_prefix) return derived_var.assign_to(data) else: message = f"""No variable named '{active_key}'. The following variables are defined: {data.dims}. -The following variables can be derived: {list(DERIVED_PARTICLE_VARIABLES[ds_prefix])}.""" +The following variables can be derived: {list(derived_vars)}.""" raise ValueError(message) From f4b1cbe64afe755bbd926bb813ac87ec2a0900b8 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:38:08 -0400 Subject: [PATCH 34/45] test_plots: derive->with for test_hamscan --- tests/test_plots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_plots.py b/tests/test_plots.py index b14246f..c131158 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -191,7 +191,7 @@ def test_static_scatter_bp(): @pytest.mark.mpl_image_compare(**MPL_KWARGS) def test_hamscan(): """Archetypal scan for hammerhead distributions ("hams").""" - return make_plot("prt.e -i t=-1 --derive pzx --bin y py=20 pzx=20 t= -v py pzx time=y --compute".split(), data_dir="test-2d") + return make_plot("prt.e -i t=-1 --with pzx --bin y py=20 pzx=20 t= -v py pzx time=y --compute".split(), data_dir="test-2d") # --- Particle moments --- From 7f129573bbfbeeda9f23f6c070da7f4bd11bc4c0 Mon Sep 17 00:00:00 2001 From: James McClung Date: Mon, 27 Jul 2026 16:50:15 -0400 Subject: [PATCH 35/45] derive: +scoped_variable --- src/lib/data/adaptors/derive.py | 63 ++++++++++++++++----------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index a64533c..a98a927 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -5,14 +5,8 @@ from lib.data.adaptor import WorldAdaptor from lib.data.data_with_attrs import Field, List from lib.data.data_world import DataWorld +from lib.data.ensure_derived import ensure_derived from lib.data.loader import load -from lib.derived_field_variables.derived_field_variable import ( - DERIVED_FIELD_VARIABLES, - derive_field_variable, -) -from lib.derived_particle_variables.derived_particle_variable import ( - derive_particle_variable, -) from lib.file_util import Prepath from lib.parsing.args_registry import arg_parser @@ -33,7 +27,7 @@ def apply_world(self, world): if isinstance(active, Field): siblings = _load_siblings(world, scoped_prefixes) - return world.with_active(data=AssignNewFieldVariable(active, siblings).transform(self.ast)) + return world.with_active(data=AssignNewFieldVariable(active, siblings, world).transform(self.ast)) raise ValueError("--derive requires an active variable to derive into; specify one as a positional argument.") @@ -64,21 +58,6 @@ def _load_siblings(world: DataWorld, prepaths: set[Prepath]) -> dict[str, Field] return siblings -def _resolve_field_variable(field: Field, key: str) -> Field: - """Return a Field guaranteed to contain `key`, deriving it via the prefix's - registry when it isn't already in the dataset.""" - if key in field.data: - return field - prefix = field.metadata.prepath - if prefix is None: - raise ValueError(f"--derive cannot resolve '{key}': field metadata has no prefix.") - if key not in DERIVED_FIELD_VARIABLES.get(prefix, {}): - raise ValueError( - f"--derive: '{key}' is not in the '{prefix}' dataset and not in its derived-variable registry {list(DERIVED_FIELD_VARIABLES.get(prefix, {}))}. Note that earlier adaptors (e.g. --downsample) may have dropped variables that became incompatible with the active grid; consider moving --derive earlier in the pipeline." - ) - return derive_field_variable(field, key, prefix) - - class AssignNewVariable(Transformer_InPlace): def __init__(self, data: List): self._data = data @@ -122,8 +101,9 @@ def assignment(self, toks: list): class AssignNewFieldVariable(Transformer_InPlace): - def __init__(self, data: Field, siblings: dict[str, Field]): + def __init__(self, data: Field, siblings: dict[str, Field], world: DataWorld): self._data = data + self.world = world # TODO: load "siblings" into the world instead of this hack self._siblings = siblings super().__init__(visit_tokens=True) @@ -137,14 +117,25 @@ def new_variable(self, toks: list): return str(tok) def variable(self, toks: list): - if len(toks) == 2: - prefix, key = str(toks[0]), str(toks[1]) - sibling = _resolve_field_variable(self._siblings[prefix], key) - self._siblings[prefix] = sibling - return sibling.data[key] key = str(toks[0]) - self._data = _resolve_field_variable(self._data, key) - return self._data.data[key] + data = self.world.require_active_data() + data = ensure_derived(data, key) + return data[key] + + def prepath(self, toks: list): + return "/".join(str(tok) for tok in toks) + + def scoped_variable(self, toks: list): + [prepath, key] = [str(tok) for tok in toks] + if prepath not in self.world.datas: + data = load(self.world.config, prepath) + else: + data = self.world.datas[prepath] + + data = ensure_derived(data, key) + self.world = self.world.with_data(prepath, data) + + return data[key] def addition(self, toks: list): [lhs, rhs] = toks @@ -182,6 +173,7 @@ def assignment(self, toks: list): _expression_0 : "(" expression ")" | variable + | scoped_variable | number _expression_1 : _expression_0 | exponentiation @@ -198,8 +190,13 @@ def assignment(self, toks: list): addition : _expression_3 "+" _expression_2 subtraction : _expression_3 "-" _expression_2 -variable : (CNAME "::")? CNAME -number : SIGNED_NUMBER +variable : CNAME +scoped_variable : prepath "::" CNAME +number : SIGNED_NUMBER + +prepath : (DIR "/")* PREFIX +DIR : /[^\/]+/ +PREFIX : /[.\w\d]+/ %import common.SIGNED_NUMBER %import common.CNAME From c8e3a1fa21fb32ef6e29d9d5addbb57b45ea945f Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 06:57:42 -0400 Subject: [PATCH 36/45] derive: disable dir for now --- src/lib/data/adaptors/derive.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index a98a927..a5a8c53 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -194,8 +194,8 @@ def assignment(self, toks: list): scoped_variable : prepath "::" CNAME number : SIGNED_NUMBER -prepath : (DIR "/")* PREFIX -DIR : /[^\/]+/ +prepath : PREFIX +# DIR : /[^\/]+/ TODO figure out a way to make dirs work; as is, the arbitrary chars are incompatible with math symbols, especially / PREFIX : /[.\w\d]+/ %import common.SIGNED_NUMBER From 4b84137ceb2d5a9f3772210a59fa49f8f3c0c338 Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 06:57:35 -0400 Subject: [PATCH 37/45] +test_crossdata_derive --- tests/baseline/test_crossdata_derive.png | Bin 0 -> 18663 bytes tests/test_plots.py | 11 ++++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 tests/baseline/test_crossdata_derive.png diff --git a/tests/baseline/test_crossdata_derive.png b/tests/baseline/test_crossdata_derive.png new file mode 100644 index 0000000000000000000000000000000000000000..a05c0e1825aeccb0aea7a6810a8af0cf68f9000c GIT binary patch literal 18663 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 a4iSoWQAQm}(gOG@a$7?FX12Jl-~R#IXbock literal 0 HcmV?d00001 diff --git a/tests/test_plots.py b/tests/test_plots.py index c131158..c26de9d 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -101,12 +101,21 @@ def test_image_and_line(): return make_plot("prt.i --bin y py=100 --nan0 --scale log --compute -v y py -w pfd::ey_ec -v y".split()) +# --- Cross-dataset plots --- + + +@pytest.mark.mpl_image_compare(**MPL_KWARGS) +def test_crossdata_derive(): + """Power of electric field acting on ions.""" + return make_plot("pfd --derive ipower=ey_ec*pfd_moments::jy_i".split()) + + # --- Cross-subdir plots --- @pytest.mark.mpl_image_compare(**MPL_KWARGS) def test_crossdir_with(): - """Data from two different subdirectories.""" + """2D vs. 3D B_x(y). The "y"s aren't the same length, and that's ok.""" return make_plot("test-2d/pfd hx_fc -v y --display \\text{2d} --with test-3d/pfd::hx_fc -v y --display \\text{3d}".split(), data_dir=".") From f3a701f0aeadd61c2faeea7c4f697f3cbd4a4d39 Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 07:01:45 -0400 Subject: [PATCH 38/45] derive: -siblings --- src/lib/data/adaptors/derive.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index a5a8c53..2bafd67 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -26,8 +26,7 @@ def apply_world(self, world): return world.with_active(data=AssignNewVariable(active).transform(self.ast)) if isinstance(active, Field): - siblings = _load_siblings(world, scoped_prefixes) - return world.with_active(data=AssignNewFieldVariable(active, siblings, world).transform(self.ast)) + return world.with_active(data=AssignNewFieldVariable(active, world).transform(self.ast)) raise ValueError("--derive requires an active variable to derive into; specify one as a positional argument.") @@ -46,18 +45,6 @@ def _collect_scoped_prefixes(ast) -> set[str]: return prefixes -def _load_siblings(world: DataWorld, prepaths: set[Prepath]) -> dict[str, Field]: - """Resolve each scoped prefix to a loaded Field, reusing any already in the world - and auto-loading the rest the same way `--with` does.""" - siblings: dict[Prepath, Field] = {} - for prepath in prepaths: - if prepath in world.datas: - siblings[prepath] = world.datas[prepath] - else: - siblings[prepath] = load(world.config, prepath) - return siblings - - class AssignNewVariable(Transformer_InPlace): def __init__(self, data: List): self._data = data @@ -101,11 +88,9 @@ def assignment(self, toks: list): class AssignNewFieldVariable(Transformer_InPlace): - def __init__(self, data: Field, siblings: dict[str, Field], world: DataWorld): + def __init__(self, data: Field, world: DataWorld): self._data = data self.world = world - # TODO: load "siblings" into the world instead of this hack - self._siblings = siblings super().__init__(visit_tokens=True) def number(self, toks: list): From 7622b5f879f9655f378de07a5cd5078364223036 Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 07:07:08 -0400 Subject: [PATCH 39/45] derive: AssignNewFieldVariable returns world --- src/lib/data/adaptors/derive.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index 2bafd67..340e868 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -26,7 +26,7 @@ def apply_world(self, world): return world.with_active(data=AssignNewVariable(active).transform(self.ast)) if isinstance(active, Field): - return world.with_active(data=AssignNewFieldVariable(active, world).transform(self.ast)) + return AssignNewFieldVariable(active, world).transform(self.ast) raise ValueError("--derive requires an active variable to derive into; specify one as a positional argument.") @@ -145,7 +145,7 @@ def exponentiation(self, toks: list): def assignment(self, toks: list): [key, subdata] = toks info = var_info_registry.lookup(self._data.metadata.prepath, key) - return self._data.with_active(data=subdata, key=key, info=info) + return self.world.with_active(data=self.world.require_active_data().with_active(data=subdata, key=key, info=info)) _DERIVE_GRAMMAR = r""" From 02f45aca6f5a788ad90d9de7621cf8a62012b3e1 Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 07:09:39 -0400 Subject: [PATCH 40/45] derive: -_data --- src/lib/data/adaptors/derive.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index 340e868..4d9966d 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -26,7 +26,7 @@ def apply_world(self, world): return world.with_active(data=AssignNewVariable(active).transform(self.ast)) if isinstance(active, Field): - return AssignNewFieldVariable(active, world).transform(self.ast) + return AssignNewFieldVariable(world).transform(self.ast) raise ValueError("--derive requires an active variable to derive into; specify one as a positional argument.") @@ -88,8 +88,7 @@ def assignment(self, toks: list): class AssignNewFieldVariable(Transformer_InPlace): - def __init__(self, data: Field, world: DataWorld): - self._data = data + def __init__(self, world: DataWorld): self.world = world super().__init__(visit_tokens=True) @@ -144,8 +143,10 @@ def exponentiation(self, toks: list): def assignment(self, toks: list): [key, subdata] = toks - info = var_info_registry.lookup(self._data.metadata.prepath, key) - return self.world.with_active(data=self.world.require_active_data().with_active(data=subdata, key=key, info=info)) + data = self.world.require_active_data() + info = var_info_registry.lookup(data.metadata.prepath, key) + data = data.with_active(data=subdata, key=key, info=info) + return self.world.with_active(data=data) _DERIVE_GRAMMAR = r""" From 4b97d9f34d4a7ef97dd54e0fca8aff3042b64dd1 Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 07:11:28 -0400 Subject: [PATCH 41/45] derive: just use AssignNewFieldVariable --- src/lib/data/adaptors/derive.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index 4d9966d..5f0b0c5 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -3,11 +3,10 @@ from lib import var_info_registry from lib.data.adaptor import WorldAdaptor -from lib.data.data_with_attrs import Field, List +from lib.data.data_with_attrs import List from lib.data.data_world import DataWorld from lib.data.ensure_derived import ensure_derived from lib.data.loader import load -from lib.file_util import Prepath from lib.parsing.args_registry import arg_parser @@ -17,18 +16,7 @@ def __init__(self, expression: str): self.ast = _DERIVE_PARSER.parse(expression) def apply_world(self, world): - active = world.active_data - scoped_prefixes = _collect_scoped_prefixes(self.ast) - - if isinstance(active, List): - if scoped_prefixes: - raise ValueError("--derive: cross-prefix references (prefix::key) are not supported for particle data.") - return world.with_active(data=AssignNewVariable(active).transform(self.ast)) - - if isinstance(active, Field): - return AssignNewFieldVariable(world).transform(self.ast) - - raise ValueError("--derive requires an active variable to derive into; specify one as a positional argument.") + return AssignNewFieldVariable(world).transform(self.ast) def get_name_fragments(self): if self.ast.data == "assign_default": From 2937cf813d05e12ec15c90125a02a55675eb769e Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 07:13:26 -0400 Subject: [PATCH 42/45] derive: -collect_scoped_prefixes --- src/lib/data/adaptors/derive.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index 5f0b0c5..ab5bb63 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -24,15 +24,6 @@ def get_name_fragments(self): return [f'derive_"{self.expression}"'] -def _collect_scoped_prefixes(ast) -> set[str]: - """Distinct prefixes referenced via `prefix::key` anywhere in the expression.""" - prefixes = set() - for tree in ast.find_data("variable"): - if len(tree.children) == 2: - prefixes.add(str(tree.children[0])) - return prefixes - - class AssignNewVariable(Transformer_InPlace): def __init__(self, data: List): self._data = data From c35727faa57799bebe82fb56f79c6d8419e87bbb Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 07:14:13 -0400 Subject: [PATCH 43/45] derive: consolidate assign classes --- src/lib/data/adaptors/derive.py | 45 +-------------------------------- 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index ab5bb63..4e21976 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -3,7 +3,6 @@ from lib import var_info_registry from lib.data.adaptor import WorldAdaptor -from lib.data.data_with_attrs import List from lib.data.data_world import DataWorld from lib.data.ensure_derived import ensure_derived from lib.data.loader import load @@ -16,7 +15,7 @@ def __init__(self, expression: str): self.ast = _DERIVE_PARSER.parse(expression) def apply_world(self, world): - return AssignNewFieldVariable(world).transform(self.ast) + return AssignNewVariable(world).transform(self.ast) def get_name_fragments(self): if self.ast.data == "assign_default": @@ -25,48 +24,6 @@ def get_name_fragments(self): class AssignNewVariable(Transformer_InPlace): - def __init__(self, data: List): - self._data = data - super().__init__(visit_tokens=True) - - def number(self, toks: list): - [tok] = toks - return float(tok) - - def new_variable(self, toks: list): - [tok] = toks - return tok - - def variable(self, toks: list): - [tok] = toks - return self._data.data[tok] - - def addition(self, toks: list): - [lhs, rhs] = toks - return lhs + rhs - - def subtraction(self, toks: list): - [lhs, rhs] = toks - return lhs - rhs - - def multiplication(self, toks: list): - [lhs, rhs] = toks - return lhs * rhs - - def division(self, toks: list): - [lhs, rhs] = toks - return lhs / rhs - - def exponentiation(self, toks: list): - [lhs, rhs] = toks - return lhs**rhs - - def assignment(self, toks: list): - [new_variable, val] = toks - return self._data.with_active(key=new_variable, data=val) - - -class AssignNewFieldVariable(Transformer_InPlace): def __init__(self, world: DataWorld): self.world = world super().__init__(visit_tokens=True) From 8ec5043975ec7aca7137d3de6e1473631c8be69c Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 07:15:21 -0400 Subject: [PATCH 44/45] derive: cleanup get_name_fragments --- src/lib/data/adaptors/derive.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/data/adaptors/derive.py b/src/lib/data/adaptors/derive.py index 4e21976..fd0399e 100644 --- a/src/lib/data/adaptors/derive.py +++ b/src/lib/data/adaptors/derive.py @@ -18,8 +18,6 @@ def apply_world(self, world): return AssignNewVariable(world).transform(self.ast) def get_name_fragments(self): - if self.ast.data == "assign_default": - return [] return [f'derive_"{self.expression}"'] From 6aba64e2b93aac6e91f71820579adcf452aa67bf Mon Sep 17 00:00:00 2001 From: James McClung Date: Tue, 28 Jul 2026 07:56:10 -0400 Subject: [PATCH 45/45] test_plots: -prev crossprefix test --- .../test_static_2d_derived_cross_prefix.png | Bin 20244 -> 0 bytes tests/test_plots.py | 7 ------- 2 files changed, 7 deletions(-) delete mode 100644 tests/baseline/test_static_2d_derived_cross_prefix.png diff --git a/tests/baseline/test_static_2d_derived_cross_prefix.png b/tests/baseline/test_static_2d_derived_cross_prefix.png deleted file mode 100644 index 9a7913caa3b7701b05a048541d1de7e577e5f7b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20244 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 diff --git a/tests/test_plots.py b/tests/test_plots.py index c26de9d..05849e5 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -37,13 +37,6 @@ def test_animated_2d_derived(): return make_plot("pfd h2_cc -v y z".split()) -@pytest.mark.mpl_image_compare(**MPL_KWARGS) -def test_static_2d_derived_cross_prefix(): - """2D view of a variable derived across prefixes: the active `pfd` field `jy_ec` - times `pfd_moments::jy_e`, which auto-loads the `pfd_moments` prefix.""" - return make_plot("pfd --derive electron_power=jy_ec*pfd_moments::jy_e -i t=-1 -v y z time=".split()) - - @pytest.mark.mpl_image_compare(**MPL_KWARGS) def test_animated_2d_idx(): """2D slice of x-component of magnetic field at the x=1 index."""