Skip to content

Refactor of postgkyl - #229

Open
Maxwell-Rosen wants to merge 337 commits into
mainfrom
refactor-diagnostics
Open

Refactor of postgkyl#229
Maxwell-Rosen wants to merge 337 commits into
mainfrom
refactor-diagnostics

Conversation

@Maxwell-Rosen

@Maxwell-Rosen Maxwell-Rosen commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Refactor of postgkyl

This pull request addresses pain points in postgkyl development. First, postgkyl currently has its own sympy-generated copy of DG kernels that Gkeyll already implements in C. Second, it is a CLI-first library and an awkward scripting interface. Third, equation-informed commands are mixed with basic operations, making structure unclear. This PR fixes all three at once: all DG algebra now runs through Gkeyll’s own compiled kernels via a shim that lives in the gkeyll tree (so drift is a compile error). A fluent Python API becomes the primary surface through GData, with the CLI as a thin shell over it. The new hierarchy separates core operations from equation-informed diagnostics, with the separation enforced by a unit test. Unit testing coverage rises from 33% to 99%. These improvements to the codebase will make postgkyl easier to use, simpler to maintain, and straightforward to extend.

Related GitHub issues

Closes #221. Rename write to save. Performed in this refactor.
Closes #218. [DR] Separating loading data from non-uniform mappings. A verb map is introduced so that loading simply loads data.
Closes #210. [DR] API for scripting interface. This PR implements the API, therefore closing the design review.
closes #198. [DR] Change command “pr” to “print”. All commands are given their full name. The Python side does not use abbreviations or shorthand (interp is now interpolate). The CLI always performs minimal string parsing so that pr resolves to print; unless prandtl is also a command, you have to specify pra/pri.
Closes #188. Plot saving also saves an unnamed .png file. This PR fixes this issue. I’ve fixed this many times in branches.
Closes #159. Plotting grids in 2D and 3D. This PR includes plotly and pyvista inside. This PR was approved by Ammar, but is still blocked by Mana.
Closes #141—hybrid basis specification in interp. We add support for the hybrid basis via the Gkeyll C library in this PR, closing this issue.
Closes #108. Allow specifying interpolation points in interp command. This is addressed using Gkeyll’s quadrature points and nodal evaluation of the interp command. This is a different statement that allows users to specify exactly the logical coordinates at which they want points evaluated.
Closes #52. Add support for hybrid basis used in Vlasov simulations. The Gkeyll C library supports this; therefore, closing this issue.
Closes #13. Weak operations in postgkeyll. This PR supports weak division, multiplication, and integration, with many more features likely to be developed.
Closes #7. Alternative interpolation points. This PR supports quadrature node interpolation and cell edges, resolving this issue.
Closes #6. Refactor the outdated DG part of the code. Oldest issue in the codebase. This issue is resolved by using the C layer of Gkeyll to perform DG operations, utilizing the maxima auto-generated code, and unifying the codebases.

Gkeyll backend support

A linker gpython.c is inside gkeyll/core, which exposes operations such as array_ops and integration to Python. Postgkeyll has a sparse git submodule for gkeyll/core, which builds a dependency-free version of postgkyl at pip install time. Gkeyll’s functionality is linked through an internal compiled file. This design circumvents the need for ctypes or cffi. This structure is advantageous because if the wrapper becomes out of sync, BOTH postgkyl and gkeyll will error, so gkeyll will fail to compile. These errors are loud and diagnosable, while cffi and ctypes can silently fail. This installation works out of the box with pip, without requiring users to install Gkeyll themselves first. For systems where Gkeyll cannot compile, a pure-Python reader is available. Similar to the Lua-C API rewrite, this hardens the compatibility layer and helps with maintainability.

Modal versus nodal operations

Gkeyll owns everything modal, as this functionality is precisely what the Gkeyll repository is built for. Gkeyll performs weak arithmetic, integration, interpolation, and DG evaluation. On the other hand, pointwise operations are exact, so NumPy is responsible for these operations. NumPy affords a rich ecosystem of commands, affording great flexibility. Only interpolation crosses the line between Gkeyll arrays and NumPy arrays. .gkyl files default to loading as modal, but sometimes we store nodal data. As a result, we must store whether an array is modal or nodal in the metadata of the files we save.

Unified script / CLI interface

The script mode and CLI interface now mirror each other. In script mode, one can type

import postgkyl as pg

pg.load('file').interpolate().select(z0=0.0).plot()

While in the command line, one types

pgkyl file interp sel --z0 0.0 pl

Having a unified syntax for both the script and the CLI simplifies working with this package and makes post-processing review easier.

Scripts can go even further, and here is the syntax that this PR aims to support and extend script mode.

import numpy as np
import postgkyl as pg

m0 = pg.load("sim_elc_M0_5.gkyl")
m1 = pg.load("sim_elc_M1_5.gkyl")
upar = m1 / m0              # weak division, inside Gkeyll's kernels
quad = upar.to_quad()       # values at quadrature points
root = np.sqrt(quad)        # NumPy ufuncs work on point-value data
modal = root.to_modal()     # exact projection back to DG coefficients
print(modal)                # prints like a NumPy array

To keep script and CLI in sync, both call upon the same API. The click interface is a thin layer on top of the API, making the repository more modular, while keeping all aspects in sync.

A necessary split

There is a fundamental issue with using this command structure. GData presently contains the grid and values, but introducing methods creates a paradox. The operations need to import GData, but GData has methods of operations, forming a cycle. To break this cycle, GData is separated into 2 classes, GData and GDataState. GData contains the methods, while GDataState contains the data itself. These objects live in different layers of the hierarchy.

Unit testing

postgkyl on main only has 33% coverage. This refactor was meticulous in its use of test-driven development. Now, the repo has 99% code coverage, including our most fundamental functionality. The layer hierarchy is enforced by the test_postgkyl.py test, which walks through every real import and checks it against an allowlist. The architecture diagram cannot silently rot because a test fails when a test fails.

The postgkyl hierarchy

Similar to Gkeyll, this PR implements this proposed hierarchy. Having layers in postgkyl keeps our code organized, modular, and readable. This layering mirrors the Gkeyll repository.

What are diagnostics

This PR separates commands into a set of operations/ and diagnostics/. Operations are fundamental low-layer manipulations of the data (e.g. interpolate, plot, select), without any knowledge about the data they are operating on. Diagnostics are equation-informed tools which help users understand the Gkeyll simulations (e.g. 10m, gyrokinetics, and vlasov). Sitting on top of the API, they can call the fundamental operations, just as a Python script would. This layer is highly flexible, enabling future users to implement and share their own post-processing tools without worrying about the lower levels of the codebase. While the CLI must group commands to preserve chaining, refactoring it into a thin wrapper affords greater modularity for organizing the underlying scripts.

File tree

Below is the file tree, in hierarchical order. This hierarchy is enforced by a unit test test_postgkyl.py.

src/postgkyl/
│
├─ __init__.py          facade · `import postgkyl as pg`              [SURFACE]
│
├─ cli/                 Thin Click shells: argv → gdata / diagnostics   [SURFACE]
│   ├─ app.py
│   └─ commands/        All the callable commands from the command line
│
├─ diagnostics/        equation-specific physics · one module        [COMPOSITION]
│                      per equation model
│
├─ gdata/                ★ THE FLUENT SURFACE  (sits ABOVE operations)  [FLUENT API]
│   ├─ gdata.py          class GData(GDataState) + .interpolate()/.plot()
│   ├─ gdatagroup.py     fluent GDataGroup: broadcasts verbs over its members
│   ├─ verbs.py          module-level fluent verbs with no single `self`
│   │                    (collect/evaluate/relchange/animate) — one-line
│   │                    delegations to `operations`, shared by GData and GDataGroup
│   └─ load.py           pg.load(...) → returns a GData
│
├─ operations/         one function per verb · the single seam        [VERBS]
│   ├─ interpolate.py    interpolate(d: GDataState) -> GDataState      (core verbs
│   ├─ select.py                                                       only —
│   ├─ plot.py                                                         equation-blind)
│   ├─ animate.py        terminal: sequence of datasets → render's animation engine
│   ├─ average.py        terminal-adjacent: weighted average over a dim subset,
│   │                    stays modal/gkyl-native (composes with further verbs)
│   ├─ eval_at_coord_proj.py  terminal-adjacent: eval at coords, project to the
│   │                    lower-dim basis for survivors, stays modal/gkyl-native
│   ├─ local_poly.py     modal coefficients → discontinuity-preserving plot mesh
│   └─ _materialize.py   shared modal → NumPy-shadow bridge used by plot/animate
│
├─ render/             matplotlib · plotly · pyvista → gdatastate/numerics [BACKEND]
│                      (below operations, which delegates plot() to it)
│
├─ gdatastate/         ★ THE CONTAINER  (state only, NO verbs)        [CONTAINER]
│   ├─ gdatastate.py          class GDataState: grid·values·ctx·_result·dunders
│   ├─ gdatastategroup.py     GDataStateGroup
│   └─ guards.py         shared field-domain guard (backend=="gkyl" -> raise);
│                        one home for the ".interpolate() first" check reused
│                        across operations/diagnostics instead of retyped per verb
│
├─ numerics/           pure NumPy math · 0 internal imports           [LEAF]
├─ dg/                 interpolation bridge + modal ops → gpython     [ENGINE]
├─ io/                 readers (C-native first) + writer → gpython    [ENGINE]
└─ gpython/            ★ THE FOREIGN FLOOR · compiled shim            [FLOOR]
    ├─ csrc/             _gpythonmodule.c — CPython extension over gkyl_gpython.h
    │                    (the shim itself lives in gkeyll/core/zero/)
    ├─ _gpython.so       built extension (scripts/build_gpython.sh)
    ├─ _lib.py           loads _gpython · GPYTHON_API_VERSION handshake
    ├─ array.py          GkylArray — capsule owner of a gkyl_array
    ├─ basis.py          basis cache + interpolation matrices via the shim
    ├─ rio.py            file loading via gkyl_array_rio
    └─ kernels.py        weak mul/div/inv · lincomb · reduce · integrate

A graphical user interface (GUI) can operate at the surface level, near the CLI, earning the abstraction of another interface.

Development notes

Version-breaking changes

Since this PR is an entire rewrite of the library, I anticipate it will have version-breaking changes. The old GData class has a fundamentally different structure. Please reach out if your old scripts no longer work and we can fix them. I am very open to feedback that improves the design of our codebase.

I paid special attention to porting the latest developments for gyrokinetics postprocessing, but this took several weeks, and the codebase is very active, so there is a non-negligible chance that I missed something. The best way to know is to get others' eyes on the code and use it.

Nodal arrays need a property value_forrm = "nodal" added to their metadata. Here is an example.

  struct gkyl_msgpack_map_elem meta[] = {
    { .key = "poly_order", .elem_type = GKYL_MP_UNSIGNED_INT, .uval = 0 },
    { .key = "basis_type", .elem_type = GKYL_MP_STRING, .cval = "serendipity" },
    { .key = "value_form", .elem_type = GKYL_MP_STRING, .cval = "nodal" },
  };

Agentic development

This refactor was carried out by Claude Code. I constructed a 15-staged refactoring plan with associated agents to orchestrate. I have attached the files for consideration. The agent was created in Fable 5, reviewed and then implemented with Sonnet 5. Correctness is enforced through a comprehensive layer of unit testing and human review.

Bug fixes

Along the way, the agents found several (5-10) genuine bugs in the codebase. They can be found in the agent's reports and the commit history. These bugs are fixed by the agents, and unit tests are added.

Known limitations

This refactor brings out some limitations of Gkeyll itself. Gkeyll does not have div/mul/integrate kernels for gkhybrid and hybrid basis functions. Integration for the GK distribution function is in gyrokinetic/ rather than core/. This is beyond the scope of this PR.

Modal integration is only supported in total. Per-axis integration was only supported in a nodal since in postgkyl, which is carried over. Gkeyll does not support per-axis integration in the kernels, so Gkeyll must be extended for this application.

Out of scope

In a perfect codebase, I'd like to have the website compiled directly from postgkyl, linked to tested examples and checked through CI. The codebase should also include linting and formatting, but I didn't implement them here. That's a separate discussion. I've included a few walkthrough scripts to guide new users through the codebase. We should prevent people from putting code in main that lacks unit tests. CI should check code coverage to keep our coverage high.

Maxwell-Rosen and others added 30 commits June 25, 2026 13:54
- Introduced a new design document outlining the API changes for Postgkyl, focusing on a fluent scripting style that aligns CLI and script interfaces.
- Extended the GData class to support fluent method chaining for data manipulation and visualization.
- Implemented a new verb library that consolidates CLI commands and script methods, ensuring consistency across interfaces.
- Added detailed examples demonstrating the new API usage patterns for common tasks.
- Restructured the codebase to improve modularity, documentation, and CI processes, including the introduction of doctests for validation.
- Created a new JavaScript file for rotation controls in visualizations, enhancing user interaction with plots.

What changed

1. Externalized the embedded JavaScript (plotly.py � rotation_controls.js)
- The ~265-line JS blob that was living inside a Python f-string is now a standalone rotation_controls.js file that can be edited with real JS tooling (highlighting, linting).
- A small helper _build_rotation_post_script() loads the template and fills in camera parameters via __PGKYL_*__ tokens. Plotly's own {plot_id} placeholder is preserved untouched.
- This eliminated the doubled-brace {{ }} escaping that made the original unreadable.

2. Deduplicated the log-colorbar logic
- The two near-identical ~33-line blocks (surface mode + volume mode) are now a single _apply_log_colorscale() helper called from both sites.

3. Packaging fix (pyproject.toml)
- Added [tool.setuptools.package-data] so the new .js (and the previously-implicit .mplstyle) are guaranteed to ship in built wheels � otherwise installed copies would crash on HTML rotation export.

Verification

- plotly.py: 1143 � 889 lines (net �330 lines in the module; the JS moved to a 263-line file where it belongs).
- Full test suite: 76 passed, 11 skipped.
- End-to-end smoke tests pass for all thrc, surface+logc, and HTML rotation export(template embeds correctly, no leaked tokens).

You can now respond to the reviewer with the cleanup already done � the strongest evidence for "unmaintainable"
(the embedded JS) is gone, the file is me single feature was removed. That makesyour case for keeping both backends much harder to argue against.
… streamline trace creation.

All clean � the only matches are finite_values inside the _finite_range helper itself (expected), not the removed finite_value/finite_count/show_volume_colorbar/surface_trace.

Done � all four refactors implemented and verified

��������������������������������������������������������������������������������������������������������������
�  #  �                        Refactor                         �                   Result                   �
��������������������������������������������������������������������������������������������������������������
� 1   � Reuse _finite_range() instead of the inline             � 11 lines � 1                               �
�     � reimplementation                                        �                                            �
��������������������������������������������������������������������������������������������������������������
� 2   � _scene_axis() helper for the triplicated x/y/z axis     � 26 lines � 8 (+ 16-line reusable helper)   �
�     � dicts                                                   �                                            �
��������������������������������������������������������������������������������������������������������������
� 3   � Hoist show_colorbar (and trace_name/show_trace_legend)  � killed 3 repeated inline conditions        �
�     � computed once                                           �                                            �
��������������������������������������������������������������������������������������������������������������
� 4   � Restructure surface/scatter/volume into a clean         � logz+logc+downsample+build now co-located  �
�     � three-way dispatch                                      � per mode                                   �
��������������������������������������������������������������������������������������������������������������

Verification: file parses, 76 passed / 11 skipped (same as before), and smoke tests confirm correct trace types for volume, volume+logc, volume+logz, scatter, scatter+opacity, surface, surface+logc, and the multi-component subplot path.

Net: plotly.py went 889 � 867 lines, but the more important change is the function body shrank ~110 lines and the tangled control flow is gone. Refactor 4 also fixed three latent messes: the redundant np.array(color_value, copy=True) recopy, the duplicated downsample() call, and the logc handling that was split across two separate blocks.

One deviation worth flagging

For #4 I restructured the logic inline as a clean if surface / else (logz�logc�downsample � if scatter / else volume) dispatch, rather than three separate _build_*_trace() functions as I'd originally pitched. Reason: each builder would have needed 12�14 parameters (Scatter3d nests the color params inside marker= while Surface/Volume keep them top-level, so they can't share a common kwargs dict), and those signatures would have worked against the readability goal � the opposite of what the reviewer wants. The inline restructure delivers the same outcome I promised (each mode readable top-to-bottom, clean dispatch) without the parameter-plumbing overhead. Happy to extract them into functions anyway if you'd prefer that for consistency.
- Introduced end-to-end tests for the documented script API in `test_golden_scripts.py`, covering interpolation, slicing, arithmetic operations, and reductions.
- Added tests for `DatasetGroup` functionality in `test_group.py`, including construction, combining, and broadcasting.
- Implemented tests for the `pg.load` callable and its behavior in `test_loader.py`, ensuring correct loading of single and multiple datasets.
- Created extensive tests for the `postgkyl.ops` verb library in `test_ops.py`, validating the output of various operations and their chaining capabilities.
- Added tests for Wave 4 and Wave 5 verbs in `test_ops_wave4.py` and `test_ops_wave5.py`, focusing on collection, moment calculations, and growth rates.
- Developed tests for multi-dataset plotting in `test_plot_datasets.py`, ensuring correct figure generation and overlay behavior.

# Postgkyl Refactor Plan — One Verb Library, Two Front-Ends

> **Purpose.** Re-organize the `commands/` layer so that a single *master class* /
> verb library drives both the Python script API and the CLI. The CLI becomes a thin
> shell (migrated from Click to Typer); the script API reads top-down like prose
> (`pg.load('f.gkyl').interp().sel(z0=0).plot()`); and `GData` gains Python-native
> ergonomics (printing, `+ - * /`, NumPy interop). The two surfaces share **one
> implementation per verb** so they can never drift again.
>
> **Source documents.** Human-authored intent: `RESEDIGN_NOTES.md` (authoritative).
> Prior-session design: `API_REDESIGN.md`. This plan reconciles the two, grounds them
> in the current code, resolves the open conflicts, and lays out a phased path.

---

## 0. Implementation status (live)

**Delivered and green — 768 tests passing (from a 639 baseline, +129 new, zero regressions).**

| Phase | Status | Where |
|---|---|---|
| 1 — GData ergonomics (`_result`, `.copy`, `__repr__`/`__str__`, `is_interpolated`, arithmetic dunders, `__array__`/`__array_ufunc__`, guardrails) | ✅ Done | `data/gdata.py`; `tests/test_gdata.py` |
| 2 — `ops/` seam (`select`, `interpolate`, `differentiate`, `integrate`, `_dg`) | ✅ Done | `ops/`; `tests/test_ops.py` |
| 3 — Fluent methods (`sel/select`, `interp/interpolate`, `diff/differentiate`, `integrate`, `plot`, `with_`) | ✅ Done | `data/gdata.py`; `tests/test_ops.py` |
| 4 — `output.plot_datasets` + `pg.plot` + `GData.plot` | ✅ Done | `output/plot.py`, `__init__.py`; `tests/test_plot_datasets.py` |
| 5 — `DatasetGroup` (broadcast + terminal verbs, `.with_`, `&`) | ✅ Done | `group.py`; `tests/test_group.py` |
| 6 — CLI thinned over `ops` (`select`, `interpolate`, `differentiate`, `integrate`, `plot`) via `commands/_apply.py` — behavior unchanged | ✅ Done | `commands/`; parity in `tests/test_commands.py` |
| 7 — `pg.load` callable + `pg.load.many` | ✅ Done | `loader.py`; `tests/test_loader.py` |
| 8 — port remaining verbs to `ops`+fluent+thin-CLI (26 verbs) | ✅ Done | `ops/`; `tests/test_ops*.py` |
| Golden scripts #1#6 verified end-to-end | ✅ Done | `tests/test_golden_scripts.py` |

**Verb coverage (Phase 8 — 26 `ops` verbs, 32 fluent `GData` methods).** `ops` + fluent
`GData` methods + thinned Click commands now exist for: `select`, `interpolate`,
`differentiate`, `integrate`, `fft`, `magsq`, `mask`, `relchange`, `agyro`, `mom_agyro`,
`current`, `energetics`, `parrotate`, `perprotate` (+ `bparrotate`/`bperprotate` via
`coords='3:6'`), `transform_frame`, `euler`, `tenmoment`, `mhd`, `velocity`, `grid`,
`val2coord`, `extract_input`, `laguerre_compose`, `fit`, `growth`, and `collect` (group
aggregation). Terminal fluent methods: `plot`, `plotly`, `pyvista`, `plotly_animate`, and
`animate` (`DatasetGroup.animate` via new `output.animate`); `write`/`info` already on
`GData`; `print(d)` covers `pr`. Discovery: `pg.load.outputs()` covers `listoutputs`.

Bugs fixed while porting: `mask` (broken context/typos), `val2coord` (removed `np.int`),
`pkpm` (broken f-string tuple), and a `grid`-verb/`grid`-property name collision was
avoided (the verb is `ops.grid`; `GData.grid` stays the grid-array property).

**CLI framework decision (resolved).** Stays on **Click** (thinned), not Typer. The §8.3
spike showed Typer is not installed, the suite has 103 `ctx.invoke` Click call-sites, and
chaining isn't first-class in Typer; the user confirmed "thin over ops, keep Click." The
`ops` seam is framework-independent, so a Typer swap remains a clean, isolated follow-up.

**Intentionally NOT verbs (remain CLI commands / script helpers)**
- **Standalone GK analysis+visualization tools** — `gk_distf`, `gk_energy_balance`,
  `gk_nodes`, `gk_particle_balance` (246–471 lines each). These load files, compute, and
  render complete figures; they are mini-applications, not `verb(data) -> data` transforms,
  so they stay as CLI commands. `trajectory` (3D particle-path animation) is the same shape.
- **Inherently CLI/REPL state** — `status`/`activate`/`deactivate` (DataSpace stack state),
  `style` (matplotlib rcParams), `load` (the CLI loader; `pg.load` is the script equivalent).
- The full-featured CLI `animate` keeps its grouptags/multiblock/saveframes branches; the
  common one-frame-per-dataset path is available to scripts via `output.animate` /
  `DatasetGroup.animate`. The CLI `collect` keeps its chunk/multi-tag orchestration;
  `ops.collect` covers the single-group case. `fit`/`growth` keep their result-printing CLI
  bodies, with `ops.fit`/`ops.growth` (+ `GData.fit`/`GData.growth`) as the script entry.
- `temp.py` (`mult`/`pow`/`log`/`abs`/`norm`) is dead code (unregistered, references a
  defunct context) — superseded by the GData arithmetic dunders.

**Remaining (future phases)**
- Phase 7 — `Simulation` (species/frame model) not started.
- Phase 9 — doctest wiring (`[tool.pytest.ini_options]` + `--doctest-modules`), a bundled
  `pg.example()` fixture for portable doctests, and a user migration guide.
- The multiblock branch of `select` still lives in the command (not yet moved into `ops`).

---

## 1. Executive summary

Today `pgkyl` has **two divergent interfaces** over the same functionality:

- **CLI** — a left-to-right chain of verbs: `pgkyl f.gkyl interp sel --z0 0 plot`.
- **Script** — scattered statements with intermediate objects:
  `d = pg.GData(...)`, `pg.GInterpModal(d).interpolate(overwrite=True)`,
  `pg.tools...`, `pg.output.plot(...)`.

They are maintained separately and drift in naming and behavior. The fix is a single
**verb layer** (`src/postgkyl/ops/`) that is the *only* implementation of each
operation. On top of it sit **two thin front-ends**:

```
L0  tools/                 pure numpy functions                      (unchanged)
L1  data/  GData + readers  I/O + grid/values storage                (extended, not broken)
L2  ops/   verb functions   ONE implementation per verb  ← NEW SEAM  (the "master class" logic)
           ops.select(data, *, z0=…, inplace=False) -> GData
L3a GData / DatasetGroup    fluent methods (1-line delegations to L2) ← NEW
L3b commands/ (Typer)       thin shells that translate argv → L2/L3   (thinned + Typer)
```

**The single source of truth:** `GData.sel(...)`, `DatasetGroup.sel(...)`, the CLI
`select` command, and `ops.select(...)` all run the exact same L2 function.

The end-state golden script:

```python
import postgkyl as pg
pg.load('elc_M0_0.gkyl').interp().sel(z0=0.0).plot()
```

---

## 2. Goals & non-goals

**Goals**
1. One implementation per verb; CLI verb name == `GData` method name == `ops.<verb>`.
2. Top-down, prose-like script API: `subject → verb → verb → verb`.
3. `GData` is the **master class**: fluent verbs, `print()`, arithmetic dunders, and
   NumPy interop (`np.sqrt(a**2 + b**2)` returns a `GData` carrying its grid).
4. **Guardrails:** block NumPy/arithmetic on raw (non-interpolated) DG modal data with
   a clear error.
5. CLI is a thin Typer layer over the verb library; chaining UX preserved.
6. Examples live in docstrings and are **verified in CI** (doctests).
7. Every phase is independently shippable and keeps `pytest` green.

**Non-goals (this round)**
- Lazy/deferred pipeline execution (record verbs, run at a terminal). *Deferred.*
- Rewriting the numerical `tools/` — they stay pure and untouched.
- Changing on-disk file formats or reader internals.
- Comparison dunders producing masks (`d > 0`). *Deferred.*

---

## 3. Current-state findings (what shapes the design)

Grounded in the code as of this branch:

| Area | Finding | Implication |
|---|---|---|
| `GData` (`data/gdata.py`) | Central class. Stores `_grid` (list of 1-D arrays) + `_values` ((N+1)-D array). `ctx` dict holds all metadata. Has `push(grid, values)` (mutate-in-place, returns self), `.grid`/`.values` read/write properties, `.info()`, `.write()`, `.tag`/`.label`/`.status`. **No** `__repr__`, dunders, `__array__`, or `.copy()`. | `push()` is the existing "overwrite" mechanism → basis for `_result()`. Ergonomics are pure additions. |
| Interpolation (`data/dg.py`) | `GInterpModal(data, poly_order=None, basis_type=None, num_interp=None, …)` auto-detects `poly_order`/`basis_type` from `ctx` when `None`. `interpolate(comp=0, overwrite=False)` returns `(grid, values)` or pushes. | `.interp()` with no args already works via auto-detect. |
| **Modal/nodal state** | `ctx["is_modal"]` is set `True` by the readers (`gkyl_reader.py:194`, `gkyl_adios_reader.py:155/159`) and **never cleared after interpolation**. The `is_modal` locals in `commands/interpolate.py`/`differentiate.py` only pick `GInterpModal` vs `GInterpNodal`. | **There is no reliable "has been interpolated" flag today.** The guardrail (Goal 4) requires us to add one (see §9). |
| Command boilerplate | ~10 "transform" commands repeat the same *tag-or-overwrite* branch (canonical: `commands/interpolate.py:67-75`): if `--tag` → build new `GData(ctx=dat.ctx)`, `push`, `dataspace.add`; else call the op with `overwrite=True`. | This branch belongs in **one** helper (`_result()` + a CLI `apply()` middleware). |
| `commands/plot.py` | ~80 Click options, a pre-loop *globalrange* scan computing shared vmin/vmax, then a loop calling `postgkyl.output.plot(dat, args, label_prefix=…, **kwargs)` once per dataset; handles figure numbering, subplots, legend, save, batch_mode. | The multi-dataset loop becomes `output.plot_datasets([...], **kw)`, shared by `pg.plot` and the CLI. |
| `ev_cmd.py` | Already implements numpy-level ops on `(grid, values)` stacks: `add, subtract, mult, divide, power, sq, sqrt, abs, sin/cos/tan, log, log10, min/max, mean, exp, grad, integrate, curl, divergence, …` with an RPN registry. | Arithmetic dunders + `__array_ufunc__` can **reuse** this logic; keep `.ev('f g -')` for complex RPN. |
| CLI plumbing (`pgkyl.py`) | Click `chain=True` group with a custom `PgkylCommandGroup` providing: command **abbreviation**, explicit **aliases** (`pl`,`ply`,`pv`,…), and **bare filenames as implicit `load`**. `DataSpace` (dict tag→list[GData]) holds the stack; commands iterate via `ctx.obj["data"].iterator(use)`. | Chaining + abbreviation + bare-file load is the CLI **contract** to preserve. It is a Click-group feature (see §8 — biggest migration risk). |
| Tests | CLI tested by **`ctx.invoke(cmd.x)`** with a hand-built Click `Context` (`tests/test_commands.py`), **not** `CliRunner`. `tests/cli`, `tests/unit`, `tests/integration` are empty. **No** doctest config; no `[tool.pytest.ini_options]`. | Typer migration needs a test strategy (§12). Doctests must be wired up. |
| `pyproject.toml` | `[project.scripts] pgkyl = "postgkyl.pgkyl:cli"`. Deps include `click>=8.1.7`; **`numpy>=1.24.4,<2`**; `python>=3.10`. Optional groups: `adios`, `test`. | Swap `click`→`typer`; update entry point; respect NumPy<2 in all new code. |
| Test fixtures | Small `.gkyl` files exist: `shock-f-ser-p1.gkyl` (2.2 K), `twostream-field-energy.gkyl` (1-D, good for line plots), `twostream-f-p2.gkyl` (129 K). | Good doctest fixtures — but see §12 for path-portability (`pg.example(...)`). |

---

## 4. Target architecture

### 4.1 The object model

| Object | Role | Lives in |
|---|---|---|
| **`GData`** | The **master class** — a single dataset and the fluent subject of every verb. Verb methods (delegating to `ops`), arithmetic dunders, NumPy protocol, `__repr__`, `_result()`, `.copy()`, `.with_()`. | `data/gdata.py` (extended) |
| **`ops.<verb>`** | The verb library — exactly one implementation per operation. Pure-ish functions `op(data, *, …, inplace=False) -> GData`. Wrap `tools/`, `data/`, `output/`. | `ops/` (NEW) |
| **`DatasetGroup`** | Ordered collection of `GData`. Non-terminal verbs **broadcast** over members (return a new group); terminal verbs (`plot`, `animate`, `info`, `write`) act on all together. Backs `.with_()`, `pg.load.many()`, `Simulation` frame sweeps, and the CLI stack. | `group.py` (NEW) |
| **`Simulation`** | Knows the Gkeyll file-naming convention. `sim.species`, `sim.fields`, `sim.field(sp, name).frame(i)/.frames()`. | `sim.py` (NEW, late phase) |
| **`_Loader` / `pg.load`** | Callable singleton + namespace: `pg.load(file)`, `pg.load.many(glob)`, `pg.load.simulation(name, …)`. | `loader.py` (NEW) |
| **`pg.plot`, `pg.animate`, …** | Top-level varargs helpers: `pg.plot(a, b)`. Thin wrappers over `output.plot_datasets`. | `__init__.py` / `output/` |
| **Typer CLI** | A thin shell mapping argv → `DatasetGroup`/`GData` verb calls. Preserves chaining, abbreviation, aliases, bare-file load. | `commands/` + `pgkyl.py` (thinned) |

### 4.2 "What is the master class?" — resolving the two docs

`RESEDIGN_NOTES.md` asks for *"a master class that has methods for the commands… the
CLI wraps the master object… the layer here sits between `commands/` and click."*
`API_REDESIGN.md` says *"no new class — extend `GData`."* **These are the same design:**
the master class **is `GData`**, elevated to a fluent facade whose methods are the verbs,
backed by the new `ops/` seam. The CLI calls those same verbs.

- The **verb vocabulary** is defined once in `ops/`.
- **Two fluent front-ends** expose it: `GData` (one dataset) and `DatasetGroup` (many).
- The **CLI** is a Typer shell that builds a `DatasetGroup` from argv and calls verbs on
  it. The chain `pgkyl f sel plot` becomes, literally, `load(f).sel(...).plot(...)`.

*Rejected alternative:* a single monolithic orchestrator object holding the whole
`DataSpace`. It breaks read-order = data-flow, doesn't compose, and doesn't match the
human's own examples (`pg.load(...).select(...).plot()`). `GData`-as-facade +
`DatasetGroup` is strictly more composable.

---

## 5. Core contracts (code sketches)

These are the load-bearing pieces. Signatures are illustrative but precise.

### 5.1 `GData._result()` — centralize the tag-or-overwrite branch

```python
# data/gdata.py
def _result(self, grid, values, *, inplace=False, tag=None, label=None, **ctx_updates):
    """The one place that decides 'mutate self' vs 'emit a new GData'."""
    target = self if inplace else self.copy(data=False)
    target.push(grid, values)                 # existing mutate primitive
    if tag is not None:   target.set_tag(tag)
    if label is not None: target.set_label(label)
    target.ctx.update(ctx_updates)            # e.g. interpolated=True
    return target

def copy(self, data=True):
    """Deep copy of metadata (and optionally arrays) without re-reading a file."""
    new = GData(tag=self._tag, label=self._custom_label, ctx=self.ctx)  # ctx is copied in __init__
    if data and self._values is not None:
        new.push([g.copy() for g in self._grid], self._values.copy())
    new.color = self.color
    return new
```

This single helper replaces the copy-pasted branch in `select.py`, `interpolate.py`,
`differentiate.py`, `integrate.py`, `fft.py`, `magsq.py`, `relchange.py`, `mask.py`, … .

### 5.2 L2 verb contract

```python
# ops/select.py  (absorbs commands/select.py orchestration + data/select.py logic)
def select(data, *, comp=None, z0=None, z1=None, …, z5=None,
           inplace=False, tag=None, label=None) -> "GData":
    grid, values = _select_arrays(data, comp=comp, z0=z0, …)   # the existing pure logic
    return data._result(grid, values, inplace=inplace, tag=tag, label=label)
```

- **Returns a new `GData` by default** (so a stored handle stays stable); `inplace=True`
  mutates and returns `self` (for large 5-D data). This generalizes today's `overwrite=`.
- `ops.interpolate` additionally sets `interpolated=True` in `ctx` (see §9).
- The multiblock branch currently embedded in `commands/select.py` moves *into*
  `ops/select.py` so **both** front-ends get it.

### 5.3 L3a fluent methods (1-line delegations, lazy imports to avoid cycles)

```python
# data/gdata.py
def sel(self, *, inplace=False, **z):
    from postgkyl import ops
    return ops.select(self, inplace=inplace, **z)
select = sel                                   # canonical name == CLI command name

def interp(self, basis=None, p=None, interp=None, *, inplace=False):
    from postgkyl import ops
    return ops.interpolate(self, basis=basis, p=p, interp=interp, inplace=inplace)
interpolate = interp

def plot(self, **kw):
    from postgkyl import output
    return output.plot_datasets([self], **kw)  # returns a figure; self stays chainable via group
```

### 5.4 Arithmetic dunders + NumPy protocol (guardrailed)

```python
# data/gdata.py
_HANDLED_TYPES = (numbers.Number, np.ndarray)

def __array__(self, dtype=None):               # lets np.asarray(d), plt.plot(d.grid, d) work
    return np.asarray(self._values, dtype=dtype)

def __array_ufunc__(self, ufunc, method, *inputs, **kw):
    if method != "__call__":
        return NotImplemented
    self._require_operable()                    # guardrail (§9)
    raw = [x._operand() if isinstance(x, GData) else x for x in inputs]
    for x in inputs:                            # grid-compatibility check
        if isinstance(x, GData): self._check_compatible(x)
    out = ufunc(*raw, **kw)
    return self._result(self._grid, out)        # new GData carrying left grid/ctx

def __add__(self, other):  return np.add(self, other)
def __sub__(self, other):  return np.subtract(self, other)
def __mul__(self, other):  return np.multiply(self, other)
def __truediv__(self, o):  return np.true_divide(self, o)
def __pow__(self, other):  return np.power(self, other)
__radd__ = __add__; __rmul__ = __mul__         # reflected; rsub/rtruediv/rpow defined explicitly
def __neg__(self):  return np.negative(self)
def __abs__(self):  return np.abs(self)
```

Routing the dunders through `__array_ufunc__` gives one guardrailed path for **both**
`a + b` and `np.sqrt(a**2 + b**2)`, satisfying `RESEDIGN_NOTES` directly. (Reuse
`ev_cmd`'s array helpers internally where convenient.)

### 5.5 `__repr__` / `print(data)`

```python
def __repr__(self):
    # <GData (x:64, vpar:32) | 1 comp | bounds x[0,1] vpar[-6,6] | ms p1 | tag 'elc'>
    return _summary_header(self) + "\n" + np.array2string(self._values, threshold=12)
```

`.info()` (rich metadata) already exists and is unchanged.

### 5.6 CLI `apply()` middleware (kills Pattern-A boilerplate)

```python
# commands/_apply.py  (new)
def apply(ctx, op, *, use=None, tag=None, label=None, **op_kwargs):
    ds = ctx.obj["data"]
    for dat in ds.iterator(use):
        if tag:
            ds.add(op(dat, inplace=False, tag=tag, label=label, **op_kwargs))
        else:
            op(dat, inplace=True, **op_kwargs)
```

A thinned command then reads:

```python
@app.command()
def select(ctx, z0: str = None, …, use: str = None, tag: str = None):
    apply(ctx, ops.select, use=use, tag=tag, z0=z0, …)
```

### 5.7 `output.plot_datasets` + `pg.plot`

```python
# output/plot.py
def plot_datasets(datasets, **kw):
    """Multi-dataset figure: the globalrange scan + per-dataset loop currently in
    commands/plot.py. Both pg.plot and the CLI plot command call this."""
    …                                          # scan vmin/vmax, manage fig/subplots/legend/save
    for i, dat in enumerate(datasets):
        plot(dat, args, label_prefix=_label(dat, i, kw), **kw)   # existing single-dataset primitive
    …

# __init__.py
def plot(*datasets, **kw):
    from postgkyl import output
    return output.plot_datasets(_flatten(datasets), **kw)        # accepts GData, DatasetGroup, lists
```

### 5.8 `DatasetGroup` (broadcast + terminal verbs)

```python
# group.py
class DatasetGroup:
    def __init__(self, datasets): self._d = list(datasets)
    def __iter__(self):  return iter(self._d)
    def __getitem__(self, i): return self._d[i]
    def with_(self, *others):  return DatasetGroup(self._d + _flatten(others))
    __and__ = with_                                        # optional `a & b` sugar

    def __getattr__(self, name):                           # auto-broadcast non-terminal verbs
        def broadcast(*a, **k):
            return DatasetGroup([getattr(d, name)(*a, **k) for d in self._d])
        return broadcast

    def plot(self, **kw):                                  # terminal verbs defined explicitly
        from postgkyl import output
        return output.plot_datasets(self._d, **kw)
    def collect(self, **kw):  …                            # many → one
```

`GData.with_(*others) -> DatasetGroup` enables `a.with_(b).interp().sel(...).animate()`.

> **Naming note (`.and()`):** `RESEDIGN_NOTES` writes `data1.and(data2)`, but `and` is a
> Python **reserved keyword** — a method literally named `and` is a `SyntaxError`. We
> adopt **`.with_()`** (with optional `&` operator sugar) as the spelling. *Decision in §14.*

### 5.9 `_Loader` / `pg.load`

```python
# loader.py
class _Loader:
    def __call__(self, file, **gdata_kwargs):  return GData(file, **gdata_kwargs)
    def many(self, pattern, **kw):  return DatasetGroup([GData(f, **kw) for f in sorted(glob(pattern))])
    def simulation(self, name, *, model=None, cdim=None, vdim=None, dims=None, species=None):
        return Simulation(name, model=model, cdim=cdim, vdim=vdim, dims=dims, species=species)
load = _Loader()        # exported as pg.load
```

---

## 6. The verb vocabulary (the heart of the refactor)

Every CLI command maps to **one** `ops` verb and **one** underlying implementation. The
fluent method name == CLI command name == `ops.<verb>`. Aliases are method-level only.

| Verb (canonical) | Alias(es) | `ops` module | Underlying impl | Pattern | Notes |
|---|---|---|---|---|---|
| `select` | `sel` | `ops/select.py` | `data/select.py` `_select_arrays` | transform | absorb multiblock branch |
| `interpolate` | `interp` | `ops/interpolate.py` | `data/dg.py` `GInterpModal/Nodal` | transform | sets `interpolated=True` |
| `differentiate` | `diff` | `ops/differentiate.py` | `data/dg.py` | transform | |
| `integrate` | — | `ops/integrate.py` | `tools/calculus.py` | transform | |
| `fft` | — | `ops/fft.py` | `tools/fft.py` | transform | psd/iso flags |
| `mask` | — | `ops/mask.py` | numpy masked array | transform | fix latent typos |
| `magsq` | — | `ops/magsq.py` | `tools/mag_sq.py` | transform | |
| `relchange` | — | `ops/relchange.py` | `tools/rel_change.py` | 2-input | |
| `ev` | — | `ops/ev.py` | `commands/ev_cmd.py` registry | RPN | keep RPN; dunders reuse helpers |
| `fit` | — | `ops/fit.py` | `tools/fit.py` | transform | |
| `growth` | — | `ops/growth.py` | `tools/growth.py` | transform | |
| `agyro` | `mom_agyro` | `ops/agyro.py` | `tools/pressure_diagnostics.py` | 2-input | |
| `euler` | — | `ops/moments.py` | `tools/prim_vars.py` (variant by name) | derived | |
| `tenmoment` | — | `ops/moments.py` | `tools/prim_vars.py` | derived | |
| `mhd` | — | `ops/moments.py` | `tools/prim_vars.py` | derived | |
| `velocity` | — | `ops/moments.py` | `tools/prim_vars.py` | derived | |
| `temp` | — | `ops/moments.py` | `tools/prim_vars.py` | derived | |
| `current` | — | `ops/current.py` | `tools/accumulate_current.py` | n-input | |
| `energetics` | — | `ops/energetics.py` | `tools/energetics.py` | n-input | |
| `parrotate` / `perprotate` | `bparrotate`/`bperprotate` | `ops/rotate.py` | `tools/parrotate.py`,`perprotate.py` | 2-input | b* = coords preset |
| `transform_frame` | `transformframe` | `ops/transform_frame.py` | `tools/transform_frame.py` | 2-input | |
| `laguerre_compose` | `laguerrecompose` | `ops/laguerre.py` | `tools/laguerre_compose.py` | 2-input | |
| `pkpm` | — | `ops/pkpm.py` | laguerre + transform_frame | workflow | |
| `collect` | — | `ops/collect.py` (on group) | GData stacking | many→one | DatasetGroup method |
| `plot` | `pl` | `ops/plot.py` → `output.plot_datasets` | `output/plot.py` | output | terminal |
| `animate` | — | `ops/animate.py` | `output/plot.py` | output | terminal |
| `plotly` | `ply` | `ops/plotly.py` | `output/plotly.py` | output | terminal |
| `plotly_animate` | `ply-anim` | `ops/plotly.py` | `output/plotly.py` | output | terminal |
| `pyvista` | `pv` | `ops/pyvista.py` | `output/pyvista.py` | output | terminal |
| `write` | — | `GData.write` (exists) | `data/write.py` | output | terminal |
| `info` | — | `GData.info` (exists) | — | query | terminal |
| `pr` | — | `ops/pr.py` | numpy print | query | maps to `print(d)` |
| `grid`, `listoutputs`, `extractinput`, `val2coord`, `gk_*`, `trajectory` | — | `ops/…` | respective `tools/`/`data/` | mixed | port last |
| `load` | — | `loader.py` `_Loader` | `GData(...)` | loader | — |
| `status`/`activate`/`deactivate`, `style` | — | *(CLI-only)* | `DataSpace`/`load_style` | CLI state | no `ops` verb |

**Pattern legend:** *transform* = single dataset in→out (tag-or-overwrite); *2/n-input* =
combine inputs; *derived* = pick a `prim_vars` function by variable name; *output* =
terminal/visual; *query* = read-only; *many→one* = aggregation; *CLI state* = manages the
stack/figure, no numerical op.

---

## 7. Target scripts (the API exists to make these read well)

These become **doctests** (§12):

```python
import postgkyl as pg

# 1. Quick look
pg.load('elc_M0_0.gkyl').interp().plot()

# 2. Slice, keep a handle, inspect
n = pg.load('elc_M0_0.gkyl').interp().sel(z0=0.0)
n.plot();  print(n)                         # <GData (x:64)> + truncated values/grid

# 3. Compare two runs on one figure (varargs)
a = pg.load('runA_M0_0.gkyl').interp().sel(z1=0.0)
b = pg.load('runB_M0_0.gkyl').interp().sel(z1=0.0)
pg.plot(a, b)                               # or a.with_(b).plot()

# 4. Arithmetic via dunders / NumPy interop
ref  = pg.load('elc_M0_0.gkyl').interp()
late = pg.load('elc_M0_5.gkyl').interp()
err  = abs(late - ref) / ref
c    = np.sqrt(a**2 + b**2)                  # returns a GData with .grid
err.plot(title='relative change')

# 5. Reductions / spectral
pg.load('elc_M0_0.gkyl').interp().integrate().info()
pg.load('phi_0.gkyl').interp().sel(z1=0.0).fft().plot()

# 6/7. A whole simulation + time series (late phase)
sim = pg.load.simulation('gk55', model='gk', cdim=1, vdim=2)
sim.field('elc', 'M0').frames().interp().sel(z0=0.0).animate()
sim.field('elc', 'M0').frames().interp().integrate().collect().plot()
```

---

## 8. CLI migration: Click → Typer

### 8.1 The contract to preserve
From `pgkyl.py` / `PgkylCommandGroup`:
1. **Chaining** — `pgkyl f.gkyl interp sel --z0 0 plot` (Click `chain=True`).
2. **Abbreviation** — `pgkyl int` → `interpolate`.
3. **Explicit aliases** — `pl`, `ply`, `ply-anim`, `pv`.
4. **Bare filename = implicit `load`** — `pgkyl file.gkyl plot`.
5. **Global pre-options** — `--z0…--z5`, `-c`, `--c2p`, `--style`, `--batch-mode`, etc.

### 8.2 The risk
Typer is a thin layer **over Click**, but it does **not** expose Click's `chain=True`
multi-command pipeline as a first-class feature, and items 2–4 are implemented via a
**custom `click.Group` subclass**. A naive "all-Typer" rewrite would lose the chaining UX
that defines `pgkyl`. **This is the single biggest CLI risk.**

### 8.3 Recommended approach — hybrid (Typer commands under a custom chained group)
Because Typer compiles to Click (`typer.main.get_command(app)` yields a `click.Command`),
we can keep the **chaining/abbreviation/alias/bare-file machinery in a custom Click
`Group`** (as today) while declaring each **command with Typer's type-annotated style**
for cleaner option definitions and free help. Net effect:
- The root stays a `PgkylCommandGroup(chain=True)` (Click) — contract preserved.
- Individual commands move to Typer-style functions (modern, type-hinted, less boilerplate)
  and are registered into the group.
- Since every command body is now a ~3-line call into `ops`/`apply()`, the Click-vs-Typer
  surface is tiny either way.

> **Phase-0 spike (required):** build a 3-command throwaway proving `chain=True` +
> abbreviation + bare-file load works with Typer-declared commands under the custom group.
> If Typer cannot host the chained group cleanly, fall back to **"modernized Click"**
> (keep Click, adopt type-annotated decorators, still thin) — the architectural win (the
> `ops` seam) is independent of the CLI framework. *Decision in §14.*

### 8.4 Entry point & deps
- `pyproject.toml`: replace `click>=8.1.7` with `typer>=0.12` (pulls a compatible Click);
  `[project.scripts] pgkyl = "postgkyl.pgkyl:app"` (or keep `:cli` for the Click group).

---

## 9. NumPy interoperability & the modal/nodal guardrail

`RESEDIGN_NOTES` requires: *"guardrails on these methods so that we can't perform NumPy
operations on DG non-interpolated data."* **Today there is no reliable signal** —
`ctx["is_modal"]` is set by readers and never cleared after interpolation (§3).

**Design:**
1. **Add an explicit, authoritative state.** `ops.interpolate` and `ops.differentiate`
   set `ctx["interpolated"] = True` on their result. Expose a property:
   ```python
   @Property
   def is_interpolated(self):
       # nodal-ready if it was never modal, or has been interpolated
       return (not self.ctx.get("is_modal", False)) or self.ctx.get("interpolated", False)
   ```
   *(Chosen over repurposing `is_modal` because other code reads `is_modal` to select the
   interp class; a dedicated key avoids semantic overload. Decision in §14.)*
2. **Guard the public numeric surface.** `_require_operable()` raises a clear error when
   a dunder or `__array_ufunc__` is invoked on raw modal data:
   ```python
   def _require_operable(self):
       if not self.is_interpolated:
           raise ValueError(
               "Cannot do array math on raw DG (modal) data — call .interp() first.")
   ```
3. **Grid compatibility.** Binary ops require matching grid shapes (scalars/plain arrays
   broadcast); mismatch → clear `ValueError` naming both shapes.
4. **`__array__`** returns the values so `np.asarray(d)` and `plt.plot(d.grid, d)` work.
   `__array_ufunc__` returns a new `GData` carrying the left operand's grid/ctx, so
   `np.sqrt(a**2 + b**2)` is itself a `GData` with `.grid` (matches the doc's example).

---

## 10. Backward compatibility & deprecation

- **Keep public names:** `pg.GData`, `pg.GInterpModal`, `pg.GInterpNodal`, `pg.tools`,
  `pg.output`, `pg.data` continue to import and behave as before.
- **Re-export moved logic:** `postgkyl.data.select` stays a working call that now
  delegates to `ops.select` (returning the historical `(grid, values)` for callers that
  expect it, via a compat shim). `GInterpModal(...).interpolate(overwrite=…)` unchanged.
- **`overwrite=` → `inplace=`:** verbs accept the new `inplace=` everywhere; where an old
  function had `overwrite=`, keep it as a deprecated alias for one release with a warning.
- **CLI behavior is byte-for-byte preserved** — verified by parity tests (§12). Only the
  *internals* of command functions change.
- New top-level names (`pg.load`, `pg.plot`, fluent methods) are **additive**.

---

## 11. Phased implementation roadmap

Each phase is independently shippable and keeps `pytest` green. Phases 1–5 are additive;
6 swaps command internals; 7–8 build the simulation/diagnostic layers; 9 hardens docs.

| Phase | Title | Deliverables | Green-keeping |
|---|---|---|---|
| **0** | Foundations & spikes | Add `[tool.pytest.ini_options]` (+ `--doctest-modules`, `testpaths`); scaffold `tests/cli`; run the **Typer-chaining spike** (§8.3); ratify §14 decisions. | No code paths changed. |
| **1** | `GData` ergonomics | `_result()`, `.copy()`, `__repr__`/`__str__`, `is_interpolated`, arithmetic dunders + reflected + `__neg__`/`__abs__`, `__array__`, `__array_ufunc__`, `_require_operable()`. Unit tests + doctests. | Pure additions; existing tests untouched. |
| **2** | `ops/` seam | Create `src/postgkyl/ops/`. Move `select`, `interpolate`, `differentiate` logic into `ops.*` returning `GData` via `_result`, honoring `inplace=`; `ops.interpolate` sets `interpolated=True`. Back-compat shim for `data.select`. Unit tests for `ops`. | Commands still call old paths or new `ops` with identical results. |
| **3** | Fluent methods | `GData.sel/select`, `interp/interpolate`, `diff`, `integrate`, `fft`, `mask`, `magsq` as 1-line delegations (lazy import). Doctests: golden scripts #1#5 (#4 arithmetic/NumPy). | Additive. |
| **4** | `plot_datasets` + `pg.plot` | Factor multi-dataset loop + globalrange scan out of `commands/plot.py` into `output.plot_datasets`. Add `pg.plot`/`pg.animate`; `GData.plot/animate` delegate. CLI `plot` now calls `plot_datasets`. | `tests/test_plot.py` + CLI plot parity. |
| **5** | `DatasetGroup` + combining | `group.py` (broadcast `__getattr__` + terminal verbs), `GData.with_()`, `pg.plot(*datasets)` varargs, optional `&`. Optionally back `DataSpace` with it. | Additive; CLI unaffected. |
| **6** | Thin CLI + Typer | `commands/_apply.py` middleware; rewrite Pattern-A/B commands as thin shells calling `ops`. Migrate command declarations to Typer per Phase-0 decision; preserve chaining/abbrev/aliases/bare-file. Update `pyproject` deps + entry point. Migrate CLI tests (§12). | CLI parity tests + `tests/test_commands.py` ported. |
| **7** | Loader + Simulation | `loader.py` (`pg.load` callable + `.many` + `.simulation`); `sim.py` (`Simulation`, frame handles → `GData`/`DatasetGroup`). Doctests: golden #6#7. | Additive. |
| **8** | Moment/diagnostic verbs | Port `agyro/euler/tenmoment/mhd/velocity/temp`, `current`, `energetics`, rotations, `transform_frame`, `laguerre`/`pkpm`, `collect` (group), plus `grid/listoutputs/extractinput/val2coord/gk_*`. Fluent methods + thin CLI for each. | Per-verb parity tests. |
| **9** | Docs & cleanup | All golden scripts as CI doctests; `pg.example(...)` fixture loader (§12); user migration guide; remove `commands/old/`, fix latent bugs (e.g. `mask` typos) opportunistically. | Full suite + doctests. |

---

## 12. Verification & CI strategy

The human's requirement — *"chock-full of examples that are verified through CI"* — is met
by doctests on the master class, plus parity tests guaranteeing the CLI never regresses.

1. **Keep `pytest` green every phase.** The existing 100+ tests are the safety net.
2. **Doctests as living examples.** Wire `--doctest-modules` into
   `[tool.pytest.ini_options]`. Every fluent verb's docstring carries a runnable `>>>`
   example. The golden scripts (§7) live in module docstrings.
3. **Portable fixtures for doctests.** Doctests must not depend on CWD. Add a tiny
   `pg.example(name)` helper that loads a bundled small sample (e.g. `shock-f-ser-p1`,
   `twostream-field-energy`) via `importlib.resources`, so `>>> pg.example('shock').interp()`
   runs anywhere in CI.
4. **CLI parity tests (new `tests/cli`).** Before Phase 6, capture golden outputs/states
   for representative chains (`interp sel --z0 0 plot --save`, `ev`, `collect`, `agyro`).
   After thinning/Typer, assert identical behavior. Use Typer's `CliRunner`
   (`from typer.testing import CliRunner`) — Typer compiles to Click, so this works; port
   the existing `ctx.invoke(...)` tests to it.
5. **`ops` unit tests.** Each verb tested directly at the `ops` layer (front-end-agnostic),
   covering `inplace=True/False`, tag/label, and grid/ctx propagation.
6. **REPL smoke checklist** (manual, per the design doc): `print(d)`, `d.values.shape`,
   `(d - d).values ≈ 0`, `np.sqrt(d**2).is_interpolated`, guardrail raises on raw modal,
   `pg.plot(d, d)`, `inplace=True` mutates / default leaves source unchanged.
7. **NumPy<2 & adios guards.** CI matrix keeps `numpy<2`; `adios2` paths remain optional
   (`try/except ImportError`).

---

## 13. Risks & mitigations

| Risk | Likelihood | Mitigation |
|---|---|---|
| Typer can't host the chained-group UX cleanly | Med | Phase-0 spike; hybrid (custom Click group hosting Typer commands); fallback to "modernized Click". The `ops` win is framework-independent. |
| Guardrail signal unreliable (`is_modal` never cleared) | High (confirmed) | Add explicit `interpolated` flag set by `ops.interpolate`; `is_interpolated` property (§9). |
| `__array_ufunc__` surprises (reductions, `out=`, multi-output) | Med | Support `method=="__call__"` only initially; return `NotImplemented` otherwise; expand deliberately with tests. |
| `.copy()` accidentally re-reads files / shares mutable ctx | Med | Construct with `file_name=""`, copy `ctx` (ctor already copies), deep-copy arrays; unit-test aliasing. |
| Performance: default `inplace=False` copies large 5-D arrays | Med | `inplace=True` documented for big data; CLI uses `inplace=True` via `apply()`. |
| Hidden CLI behaviors (globalrange, batch_mode, multiblock, save naming) lost in `plot_datasets` extraction | Med | Move the loop verbatim first; parity tests on `tests/test_plot.py` + CLI golden chains. |
| Back-compat break for `data.select` returning `(grid, values)` | Low | Compat shim preserves the tuple return. |
| Scope creep across ~50 commands | Med | Land verbs by traffic (select/interp/plot first); §6 table tracks completion. |

---

## 14. Open decisions (recommendations baked in; confirm or override)

1. **Combine spelling:** `.with_()` + optional `&` (since `.and()` is a `SyntaxError`).
   *Recommended: `.with_()`.*
2. **Master class:** `GData`-as-fluent-facade + `DatasetGroup`, **not** a monolithic
   orchestrator. *Recommended as written (§4.2).*
3. **Guardrail flag:** dedicated `ctx["interpolated"]` + `is_interpolated` property, rather
   than overloading `is_modal`. *Recommended (§9).*
4. **CLI framework:** hybrid (custom Click chained group hosting Typer-declared commands);
   fall back to modernized-Click if the Phase-0 spike fails. *Recommended (§8.3).*
5. **Verb returns new by default; `inplace=` to mutate.** *Recommended (matches API_REDESIGN).*
6. **Method aliases** (`sel`/`select`, `interp`/`interpolate`): keep both, canonical name ==
   CLI command name. *Recommended.*

---

## 15. Critical files index

**Extend**
- `src/postgkyl/__init__.py` — export `load`, `plot`, `animate`; keep `GData`, `GInterp*`.
- `src/postgkyl/data/gdata.py` — `_result`, `.copy`, `__repr__`, dunders, `__array__`/
  `__array_ufunc__`, `is_interpolated`, fluent methods.
- `src/postgkyl/output/plot.py` — add `plot_datasets(list, **kw)` (loop from `commands/plot.py`).

**New**
- `src/postgkyl/ops/` — one module per verb (§6); the single source of truth.
- `src/postgkyl/group.py` — `DatasetGroup`.
- `src/postgkyl/loader.py` — `_Loader` / `pg.load`.
- `src/postgkyl/sim.py` — `Simulation` (Phase 7).
- `src/postgkyl/commands/_apply.py` — CLI tag-or-overwrite middleware.
- `tests/cli/` — Typer `CliRunner` parity tests; `pg.example()` fixture support.

**Thin**
- `src/postgkyl/commands/*.py` — ~3-line shells calling `ops`/`apply()`.
- `src/postgkyl/pgkyl.py` — root group (chaining/abbrev/alias/bare-file) hosting Typer commands.
- `src/postgkyl/commands/data_space.py` — optionally backed by `DatasetGroup`.

**Config**
- `pyproject.toml` — `click`→`typer`; entry point; `[tool.pytest.ini_options]` with
  `--doctest-modules` + `testpaths`.

---

*End of plan. Sections §14 (open decisions) and §8.3 (Typer spike) are the two gates to
clear before heavy implementation; everything in Phases 1–5 can proceed in parallel with
that since it is purely additive.*
…l plot commands to script interface. Parse negative values copied over
What was done

I gave every public pg.* callable a thorough, Pylance-parsable Google-style docstring documenting every argument, and exposed all arguments that were previously hidden behind **kwargs. Work was parallelized across 6 agents over

Operations (pg.ops.*) — 21 files te, differentiate, integrate, fft,magsq, relchange, mask, agyro, mom_agyro, current, energetics, parrotate, perprotate,            transform_frame, euler, tenmomentd, val2coord, extract_input,laguerre_compose, fit, growth). Each now has full Args:/Returns:/Raises:, with valid enum values listed (e.g. the variable optionssure for agyro, basis codes forinterpolate).

Fluent GData methods — all 23 verb methods expanded from one-liners to full Args: docstrings; info/write/copy/load were already

Argument exposure — GData.plot (7 GData.pyvista (35), andDatasetGroup.plot/animate/plotly_animate previously took bare **kwargs; they now carry explicit
signatures mirroring the underlyiaces every argument onautocomplete.

Interpolators (pg.GInterpModal/GInterpNodal) — interpolate/differentiate/interpolateGrid and the constructors now document comp, deprecated), num_interp, basiscodes, etc.
Output (pg.output.*) — plotly, plotly_animate, pyvista fully documented.
Tools (pg.tools.*) — all previously-undocumented functions in params, prim_vars, pressure_diagnostics, calculus (git, init_polar, polar_isotropic.
                                                                                        Verification
                                                                                        - compileall over src/postgkyl →
- import postgkyl → clean; spot-checked 16 entry points, all have Args: and substantial docstrings (0 weak).
- Signature check confirms plot/plotly/pyvista now expose named params instead of **kwargs.
- Test suite: 766 passed, 2 faile

One thing to flag

The 2 test failures (test_info_jorror) are pre-existing andunrelated to this work. They come from the uncommitted edit to gdata.py that was already in your
working tree at the start: GData.self, index=0, header=True) toinfo(self), but DatasetGroup.info (and the CLI info command) still call dat.info(index=i). I
confirmed this by reverting gdataests pass there. I left ituntouched since resolving it (re-add index/header vs. update the callers) is a behavior decision
in your in-progress refactor. Wan

The remaining diagnostics are cSpstgkyl, agyro, geqdsk, …) andPylance "not accessed" notes on the plot params — both benign; the latter is the same locals()-forwarding pattern your ready uses.
- Updated load_style.py, set_frame.py, and verb_print.py to replace Click with Typer for context handling.
- Added integration tests for the Typer-based CLI in test_cli_integration.py, covering various command functionalities and error handling.
- Modified test_commands.py to directly call command functions instead of using invoke, aligning with the new Typer implementation.
- Adjusted test_fit.py and test_gk_load_quantity.py to replace Click exceptions with Typer exceptions for better error handling.

The migration is complete. Here's the summary.

What changed

Dependency (pyproject.toml): click>=8.4.1 → typer>=0.15.0.

The hard part — chained commands. Modern Typer dropped Click's chain=True multi-command support (its TyperGroup.invoke runs a single subcommand). The entire pgkyl CLI depends on chaining (pgkyl file.gkyl interp sel --z0 0 plot). I rewrote src/postgkyl/pgkyl.py with a PgkylGroup(TyperGroup) that re-implements:
- chained dispatch (loop over subcommands, sharing ctx.obj),
- command-name abbreviation (interp → interpolate, ambiguity error for inte),
- explicit aliases (pl, ply, ply-anim, pv),
- bare-filename → implicit load,
- the global-options callback (--verbose, --z0..z5, --batch-mode, --version, etc.).
All 51 command files converted from @click.command/@click.option to plain Typer functions with     Annotated[T, typer.Option(...)] pareserve each command bodybyte-for-byte, each starts with a shim — kwargs = {k: (v.value if isinstance(v, enum.Enum) else v) for k, v in locals().items() if k !["..."] accesses keep working andChoice options (now str, Enum) normalize back to their original strings. multiple→List, on/off flags (--show/--no-show), arguments, promrator help were all preserved.

Library files: verb_print, set_framder (click.prompt→typer.prompt),output/pyvista (from click import Tuple was a latent bug → typing.Tuple), plus data_space/ev_cmd
(click.echo/style→typer.*).

Tests: rewrote the ~106 ctx.invoke(cmd.X(ctx, **kw) calls (Typercommands can't be driven by ctx.invoke); fixed exception assertions where the custom FitTypeParam
now raises Typer's (vendored) BadPa test bugs the old **kwargs hadmasked. Added tests/cli/test_cli_integration.py (11 tests) that drive the full chained CLI.

Verification

- grep confirms zero click usage anywhere in src/.
- 806 passed, 7 skipped (was 795/7 ssions).
- pgkyl.py coverage 29% → 94%; CLI works through the real pgkyl console-script entry point (version, help, chaining, abbreviation, aliasing to PNG).
                                                                                                   One honest caveat on "100% coverage
                                                                                                   The repo's baseline was 57%, not 10alc_enstrophy.py at 7%) is unrelated to the CLI. Reaching 100% across the whole codebase is a separate, large effort orthogonal to this migration. What I ensured here: theno Click left in src), all testspass, and the new migration infrastructure (pgkyl.py) is now 94% covered by the added integration tests. If you do want the full repo good next task to scope separately. I haven't committed anything — let me know if you'd like a commit.
Implements the plan in REFACTOR.md, organizing the tree toward the L0-L5
layering documented in src/postgkyl/README.md. Tests stay green throughout
(806 passed, 7 skipped).

- Delete dead code: commands/temp.py (unregistered), commands/old/, data/old/.
- Port dg_local_poly into ops/ (L2) as a verb(data)->data transform, with a
  GData.dg_local_poly() method and a thinned CLI command; the per-dimension
  nested loops collapse to an equivalent np.ndindex loop (verified bit-equal
  on 1D/2D DG files).
- Extract grid construction (uniform / c2p / c2p_vel) into data/mapping.py,
  shared by both readers (verified grid-identical on c2p and c2p_vel files).
- Extract load-option global/local resolution into commands/_load_opts.py
  (resolve_load_options -> LoadOptions); thin commands/load.py. Fixes a latent
  bug where a local+global c2p_vel conflict wrote to the wrong field.
- Create the gk/ domain package (L2 reference): move gkeyll_const, gkeyll_enums,
  gk_utils, and gk_quantities/ out of the utils/ grab-bag; repoint imports.
- Expose loader-workflows on pg.load: pg.load.pkpm and pg.load.gk_quantity,
  backed by gk/pkpm.py and gk/load_quantity.py; thin their CLI commands.
- Create the apps/ package (L4): relocate gk_energy_balance, gk_particle_balance,
  gk_nodes, trajectory out of commands/; expose pg.apps.
- Docs: move design history to docs/design/; fix the stale 'no NumPy>=2.0' line
  and click->typer in the root README.

Remaining (documented in REFACTOR.md): ev RPN registry still under commands/;
per-app ctx-free compute/plot split for the relocated apps; relocating
load_gk_distf alongside the other gk/ loaders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracts the post-load grid-deformation logic that was buried in gk_distf
(_apply_mc2nu_grid + helpers) into a first-class ops verb, ops.map:

  op(data, mapping, *, space='conf'|'vel', p=1, basis='ms', interp=None,
     inplace=False, tag=None, label=None) -> GData

It reads a coordinate-mapping DG field, interpolates it, and replaces a block
of the dataset's grid axes with the resulting non-uniform coordinates (values
untouched). 'conf' deforms the leading cdim axes; 'vel' deforms the trailing
vdim axes; a combined map is two applications (apply twice). Adds the
GData.map() fluent method and a thin 'map' CLI command (pgkyl ... map -f
<mapping> -s conf|vel).

gk_distf now calls ops.map for its mc2nu/mapc2p config-space deformation
instead of its own helpers (single implementation). Verified the verb
produces grids bit-identical to the previous _apply_mc2nu_grid on the 1D and
2D mc2nu mapping fields, and conf/vel/combined modes on synthetic data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mode

I audited the codebase against the layered ideal in CLAUDE.md (four parallel audits over ops/, commands/, the fluent API, and the layer boundaries), then fixed the highest-confidence deviations and verified with the full suite (802 passed, 11 skipped).

What I changed

1. Killed a single-source-of-truth violation in the fit verb. commands/fit.py reimplemented the entire ops/fit.py pipeline (grid prep, dim-dropping, meshgrid, per-component fit, result assembly) — two implementations of one verb, the exact thing L2 forbids. I:
- Moved the pure-numpy _auto_guess heuristics into tools/fit.py as auto_guess (L0, where pure-numpy belongs) and exported it.
- Made ops.fit use auto_guess as its default when no guess is given, store fit_std alongside fit_params/fit_R2 in ctx, and own the dimension-validation error (so the script API gets it too, not just the CLI).
- Thinned commands/fit.py from ~295 → ~130 lines: it now calls ops.fit and just prints from ctx. Net effect: one implementation, and the script API now gets the same smart initial guesses the CLI always had.

2. Renamed the stale click_mode parameter to cli_mode across gdata.py, loader.py, commands/load.py, and gkyl_adios_reader.py. It's a Click-era holdover that now gates a typer.prompt — the name lied about the framework.

3. Aligned CLAUDE.md with reality (it's the reference for the "ideal"): corrected PgkylCommandG(Click) → PgkylGroup (TyperGroup sutration mechanism (_COMMANDS list,not cli.add_command); fixed the plotting-helper location (utils/, not output/); documented the previously-undocumented utils/, gk/ckages; added the two missing fluent verbs (dg_local_poly, map); and noted the tools/ purity gap honestly as a migration target.

Deviations I deliberately did not touch (recommend, but want your call)
These are real but are larger refactors with genuine judgment calls — I'd rather not do them silently:

- tools/ purity (biggest gap): ~15 l .push() directly (prim_vars.pyalone has 26), violating the "pure numpy, no GData" L0 contract. Correct fix is to make them array-in/array-out and push the GDa. High churn, well-covered by tests, but its own focused pass.                                                                          - commands/collect.py & commands/gr ops.collect/the growth logic(collect adds chunk/per-tag grouping; growth adds multi-direction + instantaneous plotting). These can be thinned onto the verbs, but s.
- commands/select.py has ~105 lines of multiblock stitching that lives only in the command, not in ops.select.

I left the speculative "extract eve ops/ into tools/" findings alone —the contract is "don't duplicate logic that lives elsewhere," not "no numpy in verbs," and that
extraction would add indirection wi
…ayers, and the script API gained real coverage.

Summary

I thinned the three thickest/named command files by moving their logic into the proper layers (ops/, tools/, output/), and exposed each as a first-class fluent verb. All 802 tests pass; advanced paths (parallel/saveframes/grouptags/multiblock animate, chunked collect, RPN ev) were smoke-tested end-to-end.

collect (commands/collect.py 115→58 lines)
- The command reimplemented collection from scratch; it now delegates to the existing ops.collect. Added comp_grid to the op; chunking is done in the command by slicing the frame list per call (keeping the op pure).
- Added top-level pg.collect(*datasets) mirroring pg.plot/pg.animate.

ev (commands/ev.py 238→164; the README's documented target)
- Moved the RPN operator registry (commands/ev_cmd.py, 441 lines) → tools/ev_ops.py (L0 pure (grid, values) functions).
- Created ops/ev.py (L2): the generic stack machine apply_operator() (extracted from the command's duplicated _command/_compare) plus a script-facing ev(chain, datasets) that resolves f/fN tokens.
- The command keeps only its DataSpace-specific token resolution and delegates the stack machine.
- Added pg.ev(chain, *datasets), GData.ev(chain, *others), and DatasetGroup.ev(chain).

animate (commands/animate.py 490→181)
- Moved the rendering machinery — frame saving, parallel workers, movie compilation (PIL/ffmpeg), global-range scan, the per-frame updater — into output/plot.py, unifying it with the existing output.animate so script and CLI share one renderer. The command now only collects options, validates the save target, groups frames per mode (plain/grouptags/multiblock), and calls output.animate — matching how commands/plot.py already delegates to plot_datasets.

Docs: updated src/postgkyl/README.md (the open file) to mark the ev migration done and describe the new homes.

One pre-existing issue I noticed but left alone (out of scope, not caused by this work): info on a freshly-collected dataset raises KeyError: 'grid_type' because collected GData has no grid_type in its ctx — the old command built the GData the same way.
The refactor is complete and the full suite is green (809 passed, 11 skipped). Here's what changed.

Goal

Coordinate mapping (c2p / c2p_vel)  load path — it lived in thereaders, in GData, and as global+local CLI options. It is now fully separated into the map verb, a
normal pipeline op that runs on alr

What I did, by layer
                                                                                              L1 — DG engine (data/dg.py, data/ma
- Added two reusable helpers that turn a coordinate-mapping field's DG coefficients into node coordinates: interp_c2p_conf_grid (tions/field-aligned maps work) andinterp_c2p_vel_grid (separable 1D-per-axis, hybrid-aware).
- Removed the now-dead c2p/c2p_vel nterpolateGrid().
- mapping.py: dropped the unused c2p_vel_grid, kept c2p_grid (now used by the helper), updated docs.

L2 — the verb (ops/map.py, commands/map.py)
- Rewrote map to do curvilinear coning interpolation resolution fromthe target's value shape so it lines up with already-interpolated data (this also makes the gkhyb
+1-in-vpar case "just work"). Basiso the command lost -p/-b.

L1/L3 — load path stripped
- gkyl_reader.py, gkyl_adios_reader.py: removed c2p/c2p_vel params and grid branches.
- gdata.py, loader.py: removed mapctructor, pg.load, pg.load.many, andthe fluent GData.map signature).
- commands/load.py, _load_opts.py, -vel/--fv (local and global) and the global_c2p* ctx keys.

L3 loader — loaders/gk_distf.py
- Velocity mapping is no longer appops.map(..., space="vel") afterinterpolation, alongside the existing conf maps.

Verification

- Rewrote the c2p tests (test_interpolate.py, test_load.py) to interpolate-then-map; they pass with the same numbers (same matrices, sa
- Added test_map.py (8 tests: conf curvilinear, separable vel, values-untouched, error handlincommand).
- Ran your real workflow: migrated plot-1x-2x.py from the removed pg.load(mapc2p_name=...) to .interp().map(file, space="conf"). he 1x (1D) and 2x (genuinelycurvilinear 2D) mirror cases, and select works on the curvilinear grid.
Two decisions you confirmed up front: map preserves full curvilinear capability, and --fv was removed (it only existed to support

One note: a conf map on multi-dim cr) now produces N-D curvilinear grid arrays rather than the old rectilinear 1D arrays — this is more correct for field-aligned        coordinates, and plotting/select/gr(not grid_type), so they handle it.I left REFACTOR.md untouched; its §1 proposed a different (reader-side GridMap) design that your directive here intentionally supers
The `pgkyl` CLI was originally built on Click and later ported to Typer. The port
is functionally complete, but the code is still written in Click idioms. This
document is a plan for modernizing it into idiomatic, maintainable Typer.

The guiding constraint: **no behavior changes for users.** Every step below
preserves the existing command surface (flags, chaining, aliases, bare-filename
loading) and must pass the current `tests/` suite. Steps are ordered so each one
is independently shippable.

## What's Click-shaped today

| # | Click-ism | Modern Typer replacement |
|---|---|---|
| 1 | `ctx.obj` is a stringly-typed `dict` (`ctx.obj["data"]`, `ctx.obj["global_cuts"]`, …) | a typed `@dataclass` state object — autocomplete, type checks, no typo'd keys |
| 2 | `--z0…--z5`/`-c`/`--tag`/`--label` re-declared verbatim in `main`, `load`, and `select` | reusable `Annotated` option aliases declared once |
| 3 | `from typing import List, Optional` + `Annotated` from `typing_extensions` | `list[str]`, `X \| None`, `Annotated` from `typing` (project is ≥3.10) |
| 4 | `kwargs = {k: v for k, v in locals().items() if k != "ctx"}` repack (select/interpolate) | use the parameters directly |
| 5 | `interpolate` unwraps enums via that same `locals()` comprehension | pass `Enum` values natively; call `.value` only at the ops boundary |
| 6 | `typer.echo(typer.style(msg, fg="yellow"))` throughout | `typer.secho(msg, fg=...)` (or Rich) |
| 7 | `ctx.fail(...)` (Click) and `quit()` in `data_space.py` | `typer.BadParameter` / `raise typer.Exit(1)` |
| 8 | `load()` has a parameter also named `load` (`--load/--no-load`) | rename the param, keep the flag spelling |
| 9 | cuts typed `Optional[str]` but semantically "int or slice" | optional custom parser type `CoordCut` for validation/clarity |

Items **1** and **2** are the structural maintainability wins; the rest are
local cleanups.

## Plan

### Step 1 — Shared option aliases (`commands/_options.py`)

Pure de-duplication, no behavior change. Create one module of reusable
`Annotated` aliases so the coordinate cuts and tagging options are declared once
and shared by `main`'s global pre-options, `load`, and `select`. Help text and
flag spellings can then never drift between them.

```python
"""Reusable Typer option aliases shared across pgkyl commands."""
from __future__ import annotations

from typing import Annotated
import typer

def _zcut(d: int) -> type:
  return Annotated[
      str | None,
      typer.Option(f"--z{d}", help=f"Partial load: {d}th coord (int or slice)."),
  ]

Z0, Z1, Z2, Z3, Z4, Z5 = (_zcut(d) for d in range(6))

Component = Annotated[
    str | None,
    typer.Option("--component", "-c", help="Partial load: comps (int or slice)."),
]
VarName = Annotated[
    list[str] | None,
    typer.Option("--varname", "-d", help="ADIOS variable name [default: CartGridField]."),
]
Tag = Annotated[str, typer.Option("--tag", "-t", help="Tag for the dataset.")]
Label = Annotated[str | None, typer.Option("--label", "-l", help="Custom label.")]
CompGrid = Annotated[
    bool, typer.Option("--compgrid", help="Disregard the mapped grid information.")
]
```

Then adopt the aliases in `load.py`, `select.py`, and `pgkyl.py`'s `main`
callback.

**Done when:** the three sites import from `_options` instead of re-declaring,
and `pgkyl --help` / per-command `--help` output is unchanged.

### Step 2 — Local cleanups

Independent, mechanical, low-risk. Can be split per file.

- Drop the `kwargs = {k: v for k, v in locals().items() ...}` repack in
  `select.py` and `interpolate.py`; reference parameters directly.
- In `interpolate.py`, pass the `_BasisType` enum through and call `.value` only
  at the `ops.interpolate` boundary (remove the manual `locals()` unwrap).
- Replace `typer.echo(typer.style(msg, fg=...))` with `typer.secho(msg, fg=...)`
  across `commands/`.
- Replace `ctx.fail(...)` with `typer.secho(..., err=True)` + `raise
  typer.Exit(1)`, and replace `quit()` in `data_space.py` with `raise
  typer.Exit(1)`.
- Replace `from typing import List, Optional` + `typing_extensions.Annotated`
  with `list[...]`, `... | None`, and `from typing import Annotated`.
- Rename `load()`'s `load` parameter to `do_load` (keep `--load/--no-load`).

**Done when:** `tests/` pass and no `commands/` module imports
`typing_extensions` or calls `ctx.fail` / `quit()`.

### Step 3 — Typed application state (`commands/state.py`)

The cross-cutting change. Replace the `ctx.obj` dict with a dataclass so reads
are type-checked and discoverable. Migrate command-by-command.

```python
from dataclasses import dataclass, field
from postgkyl.commands import DataSpace

@DataClass
class AppState:
  data: DataSpace = field(default_factory=DataSpace)
  verbose: bool = False
  batch_mode: bool = False
  saveframes_prefix: str = ""
  compgrid: bool = False
  global_var_names: list[str] | None = None
  global_cuts: tuple = (None,) * 7
  in_data_strings: list[str] = field(default_factory=list)
  in_data_strings_loaded: int = 0
  start_time: float = 0.0
  # fig / ax / rcParams as needed
```

The `main` callback builds one `AppState` and assigns it to `ctx.obj`; commands
read attributes (`state: AppState = ctx.obj` → `state.data`, `state.global_cuts`).
Migrate one command per commit to keep diffs reviewable.

**Done when:** no `commands/` module indexes `ctx.obj["..."]`; `verb_print` and
the `PgkylGroup` dispatch also read the dataclass.

### Step 4 (optional) — Custom parser type for cuts

The cuts are typed `str` but mean "int or slice." A small `CoordCut` parser type
(or a `typer.Option(parser=...)`) can validate the `start:end:stride` / integer
forms at parse time and produce clearer errors than today's downstream failures.
Evaluate after Step 3; only worth it if it removes parsing logic from the verbs.

### Open question — keep the global/local pre-options?

`main` declares `--z0…--z5`/`-c`/`--varname` as *global* pre-options, and `load`
declares the same as *local* options; `resolve_load_options` then reconciles them
(local wins, with a warning). This precedence dance is a Click chained-group
habit. Before Step 3, decide whether to keep it. If dropped, `_load_opts.py` and
the `global_cuts`/`global_var_names` state fields go away, simplifying both the
state object and `load`.

## Example: `load.py` after Steps 1–3

```python
import glob
from typing import Annotated

import typer

from postgkyl.commands import _options as opt
from postgkyl.commands._load_opts import resolve_load_options
from postgkyl.commands.state import AppState
from postgkyl.data import GData
from postgkyl.utils import verb_print

def _crush(s: str) -> tuple:
  """Sort key: split a frame name so its trailing _<int> sorts numerically."""
  parts = s.split("_")
  stem, ext = parts[-1].split(".")
  parts[-1] = int(stem)
  parts.append(ext)
  return tuple(parts)

def _resolve_files(pattern: str) -> list[str]:
  """Expand a load pattern into a sorted, restart-free file list."""
  if not any(c in pattern for c in "*?!"):
    return [pattern]
  files = [f for f in glob.glob(pattern) if "restart" not in f]
  try:
    return sorted(files, key=_crush)
  except Exception:
    typer.secho(
        "WARNING: loaded files appear to be of different types; sorting off.",
        fg=typer.colors.YELLOW)
    return files

def load(
    ctx: typer.Context,
    z0: opt.Z0 = None,
    z1: opt.Z1 = None,
    z2: opt.Z2 = None,
    z3: opt.Z3 = None,
    z4: opt.Z4 = None,
    z5: opt.Z5 = None,
    component: opt.Component = None,
    varname: opt.VarName = None,
    tag: opt.Tag = "default",
    label: opt.Label = None,
    compgrid: opt.CompGrid = False,
    reader: Annotated[str | None, typer.Option("--reader", "-r", help="Reader name.")] = None,
    do_load: Annotated[bool, typer.Option("--load/--no-load", help="Load data eagerly.")] = True,
):
  """Load one or more Gkeyll output files into the dataset stack."""
  verb_print(ctx, "Starting load")
  state: AppState = ctx.obj

  pattern = state.in_data_strings[state.in_data_strings_loaded]
  files = _resolve_files(pattern)

  opts = resolve_load_options(ctx, z0=z0, z1=z1, z2=z2, z3=z3, z4=z4, z5=z5,
      component=component, varname=varname)
  z0, z1, z2, z3, z4, z5 = opts.cuts

  for var in opts.var_names:
    for fn in files:
      try:
        state.data.add(GData(
            file_name=fn, tag=tag, comp_grid=state.compgrid,
            z0=z0, z1=z1, z2=z2, z3=z3, z4=z4, z5=z5, comp=opts.comp,
            var_name=var, label=label, reader_name=reader,
            load=do_load, cli_mode=True))
      except NameError as e:
        typer.secho(repr(e), fg=typer.colors.RED, err=True)
        raise typer.Exit(1)

  state.data.set_unique_labels()
  state.in_data_strings_loaded += 1
  verb_print(ctx, "Finishing load")
```

What improved:

- The signature shrank from ~16 lines of inline `Annotated` to one alias per
  option, shared with `main` and `select` (Step 1).
- Glob/sort logic extracted to a testable `_resolve_files` helper; the verb body
  reads as "resolve files → resolve options → build GData."
- `ctx.fail` → `typer.secho(..., err=True)` + `raise typer.Exit(1)` (Step 2).
- `typer.echo(typer.style(...))` → `typer.secho` (Step 2).
- `load` param renamed `do_load`, keeping `--load/--no-load` (Step 2).
- Typed `state` replaces four `ctx.obj["..."]` lookups; `Annotated` from
  `typing`, `str | None` / `list[str]` types (Step 3).

## Implementation status

Steps 1–4 were first carried out on the load-style commands (`load`, `select`,
`main`), then the typed-state migration (Step 3) was rolled out to **all**
commands, apps, and helpers, and the transitional shim was removed.

Done:

- **Step 1** — `_options.py` aliases adopted by `load` and `main`.
- **Step 2** — in `load`/`select`/`_load_opts`: dropped the `locals()` repack,
  `typer.echo(typer.style(...))` → `typer.secho`, `ctx.fail` → `secho(..., err=True)`
  + `raise typer.Exit(1)`, `typing_extensions.Annotated` → `typing.Annotated`,
  `Optional`/`List` → `... | None`/`list[...]`, and `load`'s `load` param renamed
  `do_load` (flag spelling `--load/--no-load` unchanged).
- **Step 3** — `commands/state.py` defines `AppState`. `main` builds it and
  **every** command, app, and helper (`utils/verb_print.py`,
  `utils/load_style.py`, `apps/*`, `PgkylGroup.get_command`) now reads it by
  attribute (`ctx.obj.data`, `ctx.obj.rcParams`, …). The previously-dynamic
  `plot_handles` key is now a declared field. The transitional mapping shim
  (`__getitem__`/`__setitem__`/`__contains__`/`get`/`_extra`) has been
  **removed** — `AppState` is a plain dataclass. All test fixtures and ad-hoc
  `ctx.obj = {...}` literals across the test suite were converted to `AppState`.

Decisions:

- **`select`'s cuts were intentionally not unified** with the `_options` aliases.
  They are a different option (`--comp` not `--component`, accept floats, mean
  "indices to select"); merging would change behavior. Documented in
  `_options.py`.
- **Step 4 (`CoordCut`) was evaluated and declined.** The int/float/slice/comma
  parsing lives downstream in `data.select` and is shared with the Python API, so
  a CLI parser would either diverge CLI from API or merely add a validation layer
  (failing the "only if it removes parsing logic" criterion) while risking
  rejection of currently-accepted inputs.

Deferred:

- `quit()` in `data_space.py` → `typer.Exit` (shared infra; Step 2 cleanup not
  yet applied there).
- Step 2 cleanups (`secho`, modern typing, `locals()` repack removal) on the
  commands beyond `load`/`select` — only Step 3's state migration was applied
  fleet-wide.
- Pre-existing bug, unrelated to this work: `pgkyl file.gkyl load ...` crashes
  with `IndexError` at `load.py` because the bare filename already triggers a
  load and the explicit second `load` reads past `in_data_strings`. A natural
  fix now that state is fully typed.

## Scope notes

- `PgkylGroup` in `pgkyl.py` (the chained-dispatch / abbreviation / alias /
  bare-filename group) stays as-is. Modern Typer still does not provide Click's
  `chain=True`, so this custom group is load-bearing and out of scope.
- The `_COMMANDS` registration table and `commands/__init__.py` exports are
  unaffected; this plan only changes the bodies and signatures of command
  callbacks plus two new helper modules.

# Modernizing the pgkyl CLI for Typer — Part 2

Part 1 (`TYPER_INTERFACE.md`) modernized the load-style commands and migrated
the whole CLI off the untyped `ctx.obj` dict onto the typed `AppState`. This
part targets the remaining **structural duplication and Click-era boilerplate**
that survives across the ~45 command modules, using Typer's own features to
remove it.

Same constraint as Part 1: **no user-facing behavior changes.** The command
surface (flags, chaining, aliases, bare-filename loading) is preserved. Help
text may be normalized (the test suite asserts only that `--help` runs, not its
content). Each step is independently shippable and must pass `tests/`.

## Scope (measured across `commands/`)

| Pattern | Count | Step |
|---|---|---|
| `verb_print(ctx, "Starting X")` / `"Finishing X"` bracketing | 42 commands | 6 |
| `kwargs = {k: v for k, v in locals().items() if k != "ctx"}` repack | 35 commands | 7 |
| `--use` / `--tag` / `--label` triad redeclared inline | 17 commands | 5 |
| `v.value if isinstance(v, enum.Enum)` unwrap hack | 11 commands | 8 |
| `from typing_extensions import Annotated` | 44 files | 8 |
| `quit()` in `data_space.py` | 3 sites | 8 |

The help strings for the triad have already drifted into typos and
near-duplicates (`"Specily tag for data."`, `"Custom label for the result/"`,
plus 30+ punctuation-only variants of `"Custom label for the result"`) —
consolidating fixes these for free.

## Step 5 — Extend `_options.py` to the `--use` / `--tag` / `--label` triad

The single biggest dedup. 17 transform commands redeclare the same three
options. Add shared aliases (canonical help text) and adopt them.

```python
# commands/_options.py
Use   = Annotated[str | None, typer.Option("--use", "-u", help="Tag to apply to (default: all).")]
Tag   = Annotated[str | None, typer.Option("--tag", "-t", help="Tag for the resulting dataset.")]
Label = Annotated[str | None, typer.Option("--label", "-l", help="Custom label for the result.")]
```

`integrate` then shrinks from a 6-line signature to:

```python
def integrate(ctx, axis: Annotated[str, typer.Argument()],
    use: opt.Use = None, tag: opt.Tag = None, label: opt.Label = None):
```

This is the pattern already proven for the cuts in Part 1 Step 1, applied to
~17 files. Collapses ~50 inline declarations to 3 and fixes the typos.

**Watch for:** a few commands give `--use`/`--tag` genuinely command-specific
help (`"Specify the tag to integrate."`, the `parrotate`/`perprotate` "rotated
array parallel/perpendicular to …" wording). Leave those inline rather than
forcing them into the shared alias — same judgment call as `select`'s distinct
cuts in Part 1.

**Done when:** the 17 commands with the generic triad import from `_options`;
per-command-specific variants are intentionally left inline; `--help` for each
still lists the same flags.

## Step 6 — Centralize verbose tracing via callback wrapping

42 commands open with `verb_print(ctx, "Starting X")` and close with
`verb_print(ctx, "Finishing X")` — 84 lines of boilerplate that also force an
awkward body shape. The `_COMMANDS` registration loop in `pgkyl.py` already
wraps every callback, so add the tracing there once:

```python
# pgkyl.py
import functools

def _traced(func):
  @functools.wraps(func)            # preserve signature so Typer still introspects it
  def wrapper(ctx: typer.Context, *args, **kwargs):
    verb_print(ctx, f"Starting {func.__name__}")
    try:
      return func(ctx, *args, **kwargs)
    finally:
      verb_print(ctx, f"Finishing {func.__name__}")
  return wrapper

for _name, _func, _hidden in _COMMANDS:
  app.command(name=_name, hidden=_hidden)(_traced(_func))
```

`functools.wraps` keeps `__name__`, `__doc__`, and the signature intact, so
Typer's introspection (and `--help`) is unaffected. Then strip the two
`verb_print` calls from each of the 42 command bodies.

Notes:
- The emitted name comes from `func.__name__` (e.g. `mom_agyro`), whereas the
  current strings are hand-written (`"mom-agyro"` etc.). If exact wording
  matters, pass the registered `_name` into `_traced` instead of using
  `func.__name__`.
- Typer/Click also expose `@app.result_callback()`, but that fires once at the
  end of the whole chain — not per command — so the wrapper is the correct tool.

**Done when:** no command body calls `verb_print` for its own start/finish; the
wrapper emits them; verbose output (`pgkyl -v …`) still brackets each command.

## Step 7 — Delete the `locals()` repack (35 commands)

A Click-era holdover from when bodies forwarded `**kwargs`. With Typer binding
named parameters, every `kwargs["use"]` is just `use`. Already removed from
`select`; apply the same mechanical change to the other 34: delete the
`kwargs = {...}` line and replace `kwargs["x"]` → `x` throughout the body.

Best done **after** Step 6 (smaller bodies) and **with** Step 5 (the triad
adoption rewrites those same signatures), so the three changes land per-file as
one clean diff.

**Done when:** no `commands/` module contains `k != "ctx"`.

## Step 8 — Mechanical sweeps

Independent, low-risk, can be split per file.

- **Native enum handling (11 commands).** `interpolate`, `plot`, `plotly`,
  `write`, etc. define `str`-based enums then re-flatten them with
  `v.value if isinstance(v, enum.Enum)` inside the `locals()` hack. Typer already
  validates enum members and renders choices in `--help`; drop the unwrap and
  take `.value` once at the `ops` call:

  ```python
  apply(ctx, ops.interpolate, basis=basis_type.value if basis_type else None, ...)
  ```

- **`from typing import Annotated` (44 files).** On Python ≥3.10 `Annotated`
  lives in `typing`; drop the `typing_extensions` import.

- **`data_space.py`.** Replace the 3 `quit()` calls (the `site` builtin, meant
  for the interactive REPL; it raises `SystemExit` opaquely) and the 3
  `typer.echo(typer.style(...))` with `typer.secho(msg, fg=..., err=True)` +
  `raise typer.Exit(1)`, matching what `load`/`_load_opts` now do.

**Done when:** no `commands/` module imports `typing_extensions`; no
`isinstance(v, enum.Enum)` unwrap remains; `data_space.py` has no `quit()`.

## Polish (optional, lower priority)

- **`rich_help_panel`.** Tag the shared aliases with
  `rich_help_panel="Common options"` so cuts/tag/label/use group into their own
  section in `--help`, de-cluttering the current flat option wall.
- **Custom parser type for selector strings.** The `--index`/`--use` forms
  (`0,2,5`, `1:6:2`) are parsed by hand inside `DataSpace.iterator`. A
  `click.ParamType` would validate at parse time with a clear error and
  centralize that logic. Same caveat as the declined `CoordCut` (Part 1 Step 4):
  the parsing is shared with non-CLI callers, so it's only worth it if
  `iterator` is refactored to accept the parsed form.
- **Merge `activate`/`deactivate`.** Near-identical (one flips `focused`); a
  shared helper or a single command with an `--off` flag would halve the code.
  Behavior-affecting, so a judgment call.

## Suggested order

Steps 5, 6, 7 are independent and high-leverage; 8 is mechanical. The cleanest
sequence is **6 → 7 → 5**: the verbose-wrapper and `locals()` removal are what
make every command body shrink, then the triad aliases rewrite the signatures.
Doing 7 and 5 together per file keeps each command's diff to a single pass.

Together these remove on the order of **200+ lines** of boilerplate across ~45
files and make a new command a ~5-line shell over an `ops` verb.

## Implementation status — DONE

All four steps were enacted (order 6 → 7 → 5 → 8). Full suite green throughout
(809 passed, 11 skipped); pyflakes clean.

- **Step 6 — done.** `_traced(name, func)` wrapper added to the `_COMMANDS`
  loop in `pgkyl.py` (uses `functools.wraps`, emits `Starting/Finishing <cli
  name>`). Stripped the static Start/Finish `verb_print` lines from 46
  command/app files via a regex anchored on messages beginning with
  `Starting`/`Finishing` (so dynamic progress lines like `"Plotting nodes for
  …"` were preserved), and removed the resulting unused `verb_print` imports.
  Verbose tracing is now uniform across every command.
- **Step 7 — done.** Removed the `locals()` repack from 30 commands (23 simple
  + 7 enum-form), referencing parameters directly.
  - **Kept** in the 5 collect-and-forward plotting commands (`plot`, `plotly`,
    `plotly_animate`, `animate`, `pyvista`): they build a payload dict (adding
    computed keys) and forward it, so the repack is the right pattern there.
  - **Shadowing fixes:** inlining surfaced 6 commands where a parameter name was
    reused as a loop/assignment target (the old `locals()` snapshot had hidden
    this). `energetics` and `relchange` were genuinely broken (caught by tests —
    `relchange` was tagging results with the source tag instead of
    `"rel_change"`); `pr`/`growth` had latent multi-iteration bugs; the
    `zip()` cases (`laguerre_compose`, `transform_frame`) were benign. All six
    were fixed by renaming the shadowing binding.
- **Step 8 — done.** Native enum handling via a shared `enum_value()` helper in
  `_apply.py` (keeps the `isinstance(_, Enum)` guard so direct/programmatic
  string calls — used by the tests — still work); `typing_extensions.Annotated`
  → `typing.Annotated` across 48 files; `data_space.py`'s 3 `quit()` →
  `typer.Exit(1)` and `echo(style)` → `secho(..., err=True)`.
- **Step 5 — done.** Added `Use`/`Tag`/`Label` aliases to `_options.py` and
  adopted them in 17 commands (defaults preserved per-command). Canonical help
  text fixed the surfaced typos (`magsq`'s "Specify the tag to integrate"
  copy-paste, `relchange`'s `"Custom label for the result/"`). `current`'s
  genuinely-specific tag help (`"Tag for the resulting current array."`) was
  kept inline.

Net effect: ~84 verbose-bracket lines, ~30 `locals()` repacks, and ~50 inline
triad declarations removed; a new transform command is now a ~5-line shell.

Remaining polish (not done): `rich_help_panel` grouping, the selector-string
parser type, and the `activate`/`deactivate` merge — all still optional.

## Out of scope (unchanged from Part 1)

- `PgkylGroup` chained-dispatch / abbreviation / alias / bare-filename behaviour
  stays as-is (modern Typer still lacks Click's `chain=True`).
- The `_COMMANDS` registration table is touched only to add the `_traced`
  wrapper (Step 6).

All four steps of TYPER_INTERFACE_PART2.md are enacted. Full suite green throughout: 809 passed, 11 skipped. Nothing committed.

Summary

Step 6 — centralized verbose tracing. Added a functools.wraps-based _traced(name, func) wrapper to the _COMMANDS loop in pgkyl.py, then stripped the static Start/Finish verb_print lines from 46 files (regex anchored on Starting/Finishing messages, preserving dynamic progress lines like "Plotting nodes for …") and removed newly-orp

Step 7 — removed the locals() repacn the 5 collect-and-forward plotting commands (plot/plotly/plotly_animate/animate/pyvista) where it legitimately builds a forwarded      payload.
                                                                                                    Step 8 — mechanical sweeps. Shared .py (keeps the isinstance guard sodirect string calls work); typing_extensions.Annotated → typing.Annotated across 48 files;          data_space.py's quit() → typer.Exitrr=True).
                                                                                                    Step 5 — triad aliases. Use/Tag/Labdopted in 17 commands (defaultspreserved), keeping current's specific tag help inline.
Two things worth flagging
1. Inlining the repack surfaced real bugs. Six commands reused a parameter name as a loop/assignmenttarget — the old locals() snapshot  relchange (it was tagging resultswith the source tag instead of "rel_change") and energetics; pr/growth had latent multi-iteration   bugs; the two zip() cases were beni shadowing binding. So this stepfixed pre-existing latent bugs, not just cosmetics.                                                 2. The enum-unwrap repack was load- its isinstance(v, Enum) guard alsolet tests pass plain strings directly. My first pass (.value if x) broke 55 tests; the enum_value() helper restores that dual behavior.
                                                                                                  Also fixed incidental typos the aliify the tag to integrate",relchange's "result/"). The doc's status section records all decisions and the remaining optional polish (rich_help_panel, selector pte merge).
- Introduced explicit conversion methods: to_modal, to_nodal, to_quad in GData.
- Updated GDataState to enforce explicit representation changes and improve error messaging.
- Enhanced interoperability with NumPy for nodal and quad representations.
- Added tests for representation round trips and pointwise operations.
- Introduced `kernels.py` for weak operations on Gkeyll arrays, including multiplication, division, and integration.
- Added `rep.py` for representation changes between modal, nodal, and quadrature forms, with explicit conversion functions.
- Implemented `rio.py` for file loading through Gkeyll's C read path, supporting header and field reads.
- Created `gkyl_c_reader.py` to handle `.gkyl` file reading via Gkeyll, ensuring native data handling.
- Added `integrate.py` for performing grid integrals of modal data using Gkeyll's integration capabilities.
- Developed `represent.py` for explicit representation changes and pointwise application of functions on datasets.
- Included a new test file for validating the integration and representation functionalities.
- Updated existing tests to ensure compatibility with the new FFI structure and functionality.
…CLI directory?

• I’ll trace who imports command_spec and what contract it enforces; that will tell us whether it is genuinely shared notation or just CLI
  internals living too high.

• Ran 3 commands · ctrl + t to view transcript

• command_spec is a dependency-free protocol between the Python API and the CLI, not CLI machinery itself.

  API-owning modules such as operations, diagnostics, render, and io attach frozen CommandSpec metadata to their functions. The CLI then
  discovers and compiles those records into Click commands.

  Putting it under cli/ would make lower layers import the top-level CLI layer. Worse, postgkyl.cli.__init__ imports the app and triggers
  command compilation, creating import-cycle and partial-initialization risks.

  So the separation is justified. The only questionable part is packaging: because it is currently one file, command_spec.py could be simpler
  than a command_spec/ directory. A clearer name like cli_spec.py might also communicate that it is the CLI’s dependency-free schema, while
  keeping it outside cli/.

› I like putting cli_spec.py instead of a command_spec/ directory

• Done. command_spec/ is now src/postgkyl/cli_spec.py, with all imports and architecture docs updated.
… and -c / --chain in

  the CLI. Why is that? How do we set these two options to be the non -- or - options so we
  specify like integrate-axis 2 or ev "f0 2 *"?

  (refactor-diagnostics) postgkyl: pgkyl integrate-axis --help
  Usage: pgkyl integrate-axis [OPTIONS]

    ``int dz`` over one or more axes of point-value data (non-terminal).

  Options:
    -a, --axis TEXT        axis (or axes) to integrate over: an ``int``, a
                           ``tuple`` of ``int``, a comma-separated string
                           (``"0,1"``), a colon slice string (``"0:2"``), or
                           ``None`` (integrate over every axis).
    -i, --inplace BOOLEAN  Mutate and return ``data`` instead of creating a
                           dataset.  [default: False]
    -t, --tag TEXT         Optional tag for the returned dataset.
    -l, --label TEXT       Optional label for the returned dataset.
    -h, --help             Show this message and exit.
  (refactor-diagnostics) postgkyl:
   *  History restored

  (refactor-diagnostics) postgkyl: pgkyl integrate-axis --help
  Usage: pgkyl integrate-axis [OPTIONS]

    Integrate over one or more axes of point-value data (non-terminal).

  Options:
    -a, --axis TEXT        axis (or axes) to integrate over: an ``int``, a
                           ``tuple`` of ``int``, a comma-separated string
                           (``"0,1"``), a colon slice string (``"0:2"``), or
                           ``None`` (integrate over every axis).
    -i, --inplace BOOLEAN  Mutate and return ``data`` instead of creating a
                           dataset.  [default: False]
    -t, --tag TEXT         Optional tag for the returned dataset.
    -l, --label TEXT       Optional label for the returned dataset.
    -h, --help             Show this message and exit.
  (refactor-diagnostics) postgkyl: pgkyl ev --help
  Usage: pgkyl ev [OPTIONS]

    Evaluate an RPN expression over an explicit list of datasets.

    ``f``/``fN`` tokens in ``chain`` refer to ``datasets[N]`` (``f`` == ``f0``);
    see the module docstring for the token grammar. The result is built via
    ``datasets[0]._result(...)`` (so it stays the caller's concrete dataset
    class) and holds the single value left on top of the stack.

  Options:
    -c, --chain TEXT  the RPN expression, e.g. ``"f0 f1 +"`` or ``"f sq 2 *"``.
                      [required]
    -t, --tag TEXT    optional tag for the returned dataset (defaults to
                      ``'default'``).
    -l, --label TEXT  optional label for the returned dataset (defaults to
                      ``chain``).
    -h, --help        Show this message and exit.

    Implemented positional CLI arguments:

  pgkyl file.gkyl interpolate integrate-axis 2
  pgkyl file.gkyl ev "f0 2 *"

  Previously, the compiler unconditionally lowered every parameter to a Click option. A new
  explicit CliArgument marker now selects positional lowering while preserving generated help
  text.

  axis remains optional ([AXIS]) because None means all axes; CHAIN is required.

  Updated tests/docs; 61 relevant tests pass. Existing unrelated workspace changes were
  preserved.
…d which captures both

  full integration and partial integration? This must preserve performing exact DG
  integration

  Several of the diagnostics repeat gyrokinetics and gk in the cli pgkyl --help menu

    gyrokinetics-gk-energy-balance  Plot (and compute) the energy balance of a
                                    gyrokinetic simulation.
    gyrokinetics-gk-nodes           Plot the nodes of a (possibly multiblock)
                                    grid, with optional overlays.
    gyrokinetics-gk-particle-balance
                                    Plot (and compute) the particle balance of a
                                    single species.
    gyrokinetics-load-gk-distf      Build a real distribution function from
                                    saved ``Jf`` data.
    gyrokinetics-load-gk-quantity   Load and compute a pre-named gyrokinetic
                                    quantity.

  Please condense the names, for instance to be gyrokinetics-load-quantity, gyrokinetics-
  load-distf

  The merged command is now functioning in both CLI modes: integrate alone prints the full
  native DG integral, while integrate 2 replaces a 3-D dataset with an exact native 2-D modal
  result and remains chainable. I’m tightening the regression suite next, especially the
  invariant that integrating the reduced modal result over its remaining axes equals the
  original full DG integral.

   Implemented full renames with no legacy aliases.

  CLI commands are now:

  - gyrokinetics-energy-balance
  - gyrokinetics-nodes
  - gyrokinetics-particle-balance
  - gyrokinetics-load-distf
  - gyrokinetics-load-quantity

  Python APIs were likewise renamed to energy_balance, nodes, particle_balance, load_distf,
  and load_quantity. Documentation and examples were updated.

  Verification: 252 targeted tests passed, 4 skipped.

  Implemented.

  Use:

  pg.gyrokinetics.load_quantity(...)
  pg.gyrokinetics.load_distf(...)
  pg.gyrokinetics.available_quantities()

  The bare pg.load_quantity, pg.load_distf, and pg.available_gk_quantities names were
  removed. CLI names remain condensed. Documentation and examples updated; 31 targeted tests
  pass.
…like this pgkyl

  gyrokinetics-load-distf --name zzim --species ion -f : --suffix fdot --use-c2p-vel True
  --use-mc2nu
  True ev "f0 abs" sel --z0 0.0 anim
  Usage: pgkyl gyrokinetics-load-distf [OPTIONS]
  Try 'pgkyl gyrokinetics-load-distf --help' for help.

  Error: Invalid value for '--frame' / '-f': ':' is not a valid integer.
  (pgkyl) mhrosen@login29:~/scratch/gkylmax/a-thesis-simulations/1x-beams-anneal> pgkyl
  gyrokinetics-load-distf --help
  Usage: pgkyl gyrokinetics-load-distf [OPTIONS]

    Build a real distribution function from saved ``Jf`` data.

  Options:
    --name TEXT               Simulation name prefix.  [required]
    -t, --tag TEXT            Tag for the resulting dataset.  [default: f]
    --suffix TEXT             Use ``<name>-<species>_<suffix>_<frame>.gkyl`` as
                              the input.  [default: ""]
    --use-c2p-vel BOOLEAN     Convert velocity-space computational coordinates
    -b, --block-idx INTEGER   Use block-specific files with a ``_b<idx>``
                              prefix.
    --num-interp INTEGER      Interpolate onto a general mesh of the specified
                              amount (default: ``poly_order + 1`` points per
    --mc2nu-file TEXT         Explicit field-aligned coordinate mapping filename
                              override.
    --mapc2p-file TEXT        Explicit configuration-space mapping filename
                              override.
    --jacobtot-inv-file TEXT  Explicit inverse total-Jacobian filename override.
    -h, --help                Show this message and exit.
  (pgkyl) mhrosen@login29:~/scratch/gkylmax/a-thesis-simulations/1x-beams-anneal> pgkyl
  gyrokinetics-load-distf --name zzim --species ion -f 0:100 --suffix fdot --use-c2p-vel
  True --use-mc
  2nu True ev "f0 abs" sel --z0 0.0 anim

  Implemented multi-frame gyrokinetics-load-distf.

  - -f :, -f 0:100, stepped ranges, and comma lists now work.
  - Multi-frame loads return a labeled GDataGroup.
  - Invalid/empty ranges report clear errors.
  - CLI help now shows --frame TEXT.
…han values. So I'm

  thinking about using an integrated diagnostic and it defaults to plotting in time, but I
  want to plot in index on the x axis
Implemented grid_indices=True for Matplotlib plots.

  diagnostic.plot(grid_indices=True)

  CLI:

  pgkyl ... plot --grid-indices True

  Uses zero-based indices without modifying stored grid/time values. Added 1-D, 2-D, and CLI
  tests; 246 relevant tests pass.
…kyl/diagnostics

 Renamed diagnostics packages to:

  - gyrokinetics → gk
  - vlasov → vm
  - moments → mom
  - pkpm unchanged

  Updated imports, pg.gk, CLI names (gk-*), tests, examples, and architecture docs.

  Verification: 416 passed, 4 skipped.
Entire-Checkpoint: 01M1PT69CHF3VPNRW640BS29AA
…ke this. Change this to have a unified style everywhere, so if it is a underrscore in the API, then it is an underscore in the CLI. Do not preserve

  legacy details. Update all tests

  Implemented unified underscore naming across the CLI.

  - Commands: local_poly, five_moment_pressure, gk_rz
  - Options: --file_name, --gas_gamma, --grid_indices
  - Removed dash conversion and legacy dashed spellings.
  - Updated tests, documentation, and examples.

  Verification: 1679 passed, 6 skipped; 2 unrelated Kaleido export tests deselected due hanging. Focused suite: 95 passed.
Entire-Checkpoint: 01M1PTNFHDJZKY88QR5YPVT11E
…n instructions to resolve any conflicts between arguments which share the same first letter by not putting a shorthand. Instead, if there are for

  instance two parameters which share the same first letter (e.g. options log and linear), resolve this conflict by putting the shorthand notation on the argument which comes first in the functional call. This ensures that
  there is a shorthand for every first letter while placing priority to the arguments which appear first in the function definition.

  Implemented first-parameter priority for conflicting shorthand initials; -h remains reserved.

  Examples: paths receives -p before pair, and z0 receives -z before z1–z5.

  Updated src/postgkyl/cli/compiler.py:143, tests, and documentation.

  Tests: 1679 passed, 6 skipped; 2 unrelated export tests deselected. CLI tests: 29 passed.
Entire-Checkpoint: 01M1PV7F3C70J6FHXC9RRNWG0D
Implement clang-format as the formatter for this repository. Use it in pre-commit and CI.
  Create the formatter to use only 2 spaces for indentation. First, build the configuration
  file for this formatter. The user MUST verify that the formatter is correct for this job.
  Implement ONLY WHEN the user has approved the configuration. This formatter does not have
  to preserve the # end statements in our files, and can remove them. You may want to write
  a script which removes all these statements
I’ll update the proposed C configuration to sort includes. One constraint changes the
  Python part: clang-format has no Python parser, so running it on .py files would corrupt or
  reject them. I’ll keep implementation paused and present the compatible Python-formatter
  choice for approval.

 Updated .clang-format to sort includes alphabetically within preserved include blocks.

  clang-format cannot parse Python. May I use YAPF—the closest configurable equivalent—with
  two-space indentation for Python, while clang-format handles C? I’ll create its
  configuration for review before implementing hooks or CI.

─────────────────────────────────────────────────────────────────────────────────────────────

› Yes use YAPF

  - Added YAPF and clang-format pre-commit hooks.
  - Added CI formatting checks.
  - Applied and verified clang-format.
  - Removed 5,048 # end comments and the insertion script.
  - Python syntax checks pass; pytest reached 95% without failures before stalling in
    rendering tests.

    The formatting/diagnostics refactor is complete: YAPF covers tracked Python files, clang-
  format handles C with alphabetized includes, both run in pre-commit and CI, and YAPF is now
  installed. No next step is pending.

  - YAPF formats all tracked Python files.
  - clang-format formats C with alphabetized includes.
  - Both run through pre-commit and CI.
  - Removed 5,048 # end comments and the insertion script.
  - Formatter checks are clean.
  - Tests: 1,679 passed, 6 skipped; two Plotly media-export tests timed out in the renderer.

Entire-Checkpoint: 01M1PXZGCB9DJZHW54QWM9ZMW4
… the boolean flag when specified without a

  flag. So something like plot --no_show defaults to false and is interpreted as true when the flag is provided,
  but when given is true. Therefore, all boolean values should default to false. So plot defaults to show, but
  when --no_show is provided, then it does not show, but someone could also provide --no_show False which does
  show or --no_show True which does not show. Each boolean should have a name which makes sense

  Rather than changing the prefix to the boolean flags, change the scripts themselves so
  that the CLI perfectly mirrors the API. therefore, all API booleans should default to
  False. Update the documentation too and agent tools to reflect this

Entire-Checkpoint: 01M1Q3Z7BZADX4HFXVK8RKAW6P
Entire-Checkpoint: 01M1Q49SCAH57DE7BGH532Y7ZE
- Enhance the GitHub Actions workflow to include steps for installing and validating pre-commit hooks.
- Implement automated formatting checks and apply formatting changes if necessary.
- Update README to reflect changes in pre-commit installation and CI behavior.
- Add pre-commit as an optional dependency in pyproject.toml.

Entire-Checkpoint: 01M1Q4HYZS0SBN8VTY52JSMT54
…,690 tests and about

  95.3% statement coverage locally. The next gains are CI reliability, reproducibility, and
  artifact testing.

  ### Highest priorities

  1. Test the exact commit

  The formatting job currently rewrites the code, applies that patch to every test job, then
  optionally commits it later (.github/workflows/test.yml:37). That means CI tests code that
  is not actually in the PR commit, and fork PRs can pass while remaining unformatted.

  Make formatting a check-only required job:

  pre-commit run --all-files --show-diff-on-failure

  If automatic formatting is desired, keep it as a separate optional/manual workflow.

  2. Add coverage enforcement

  I measured 95.33% statement coverage with the two external Kaleido exports excluded. Add
  pytest-cov to the test extra and enforce an initial 95% floor in one Linux job. Then
  introduce branch coverage and changed-line coverage without chasing 100%.

  pytest --cov=postgkyl --cov-branch \
    --cov-report=term-missing --cov-report=xml \
    --cov-fail-under=95

  3. Prevent hangs and silent skips

  The Kaleido GIF test (tests/test_render_plotly.py:534) did not complete within 45 seconds
  even though Chrome detection passed. Add:

  - pytest-timeout, with strict timeouts around external renderers.
  - Job-level timeout-minutes.
  - Explicit unit, native, render, and external_tool markers.
  - --strict-markers --strict-config -ra.
  - A native-job preflight that fails unless gpython.available() is true; native tests must
    not silently skip because the extension failed to load.

  4. Test distributable artifacts

  CI only tests an editable checkout. The extension currently embeds an absolute runpath to
  the checkout (scripts/build_gpython.sh:72), while libg0core.so is not included as package
  data (pyproject.toml:74). A wheel is therefore likely non-portable.

  Add a packaging job that:

  - Builds sdist and wheel.
  - Runs twine check.
  - Installs the wheel in a clean temporary environment.
  - Imports Postgkyl from outside the repository.
  - Runs pgkyl --version and a native load/interpolate smoke test.
  - Checks Linux/macOS shared-library dependencies with auditwheel/delocate.

  5. Make builds reproducible

  Gkeyll is cloned from a moving branch (scripts/build_gkeyll.sh:13). Pin one exact commit in
  one authoritative location and key the CI cache from it. Update that commit deliberately
  through a scheduled dependency-update workflow.

  Also reconcile requirements.txt:1 with pyproject.toml:13; their dependency names and floors
  currently disagree. Prefer pyproject.toml as the source of truth and generate constraints
  mechanically.

  ### Recommended CI shape

  - quality: formatting, Ruff linting, architecture tests, incremental type checking.
  - unit: pure/core tests over every supported Python version.
  - coverage: complete Linux suite on one representative Python.
  - native: targeted Gkeyll/C-extension tests on Linux and macOS.
  - render: known Chrome/ffmpeg/OpenGL environment, isolated and timed.
  - package: clean sdist/wheel installation.
  - sanitizer: scheduled ASan/UBSan native run.

  Add push on the default branch, workflow_dispatch, concurrency cancellation, pip/native-
  build caching, and Python 3.13/3.14 coverage if they are supported. Currently requires-
  python >=3.10, classifiers, and the 3.10–3.12 matrix disagree.

  ### Improve test quality

  The most valuable new tests would be property-based invariants rather than more branch-
  targeted examples:

  - nodal/modal/quad round trips;
  - integration of constants equals physical volume;
  - native and NumPy linear operations agree after interpolation;
  - I/O round trips preserve grid, metadata, and value form;
  - inplace and emitted-result paths are equivalent without aliasing;
  - malformed states are rejected at construction.

  Finally, make warnings errors by default and scope the broad global ignores in
  pyproject.toml:83 to the tests that intentionally produce them.

  I would implement this in three PRs: CI reliability and coverage first, reproducible
  packaging second, then test tiers/property testing/static analysis.

  The hardened non-external suite is now clean: 1,704 passed, 6 expected skips, and warnings-
  as-errors exposed and eliminated ten unclosed-file leaks. The native extension now loads
  through $ORIGIN with its bundled library, so I’m doing the final branch-coverage and clean-
  artifact validation before handing this over.

  Implemented a comprehensive CI/testing hardening pass.

  Key improvements:

  - Tiered CI across Python 3.10–3.14, Linux/macOS, renderers, coverage, and
    packaging: .github/workflows/test.yml

  - Strict pytest markers, warnings-as-errors, native capability enforcement, and 93% branch-
    coverage gate: pyproject.toml

  - 24 new invariant tests for arithmetic, integration, cloning, and representations: tests/
    test_invariants.py

  - Reproducible Gkeyll revision and portable native wheels using $ORIGIN/@loader_path:
    scripts/build_gkeyll.sh

  - Clean, off-checkout wheel verification: scripts/smoke_wheel.sh
  - Ruff and repository sanity hooks added to pre-commit.
  - Weekly dependency and GitHub Actions updates: .github/dependabot.yml
  - Removed duplicated requirements.txt; pyproject.toml is now authoritative.

  Validation completed:

  - 1,704 passed, 6 expected skips, 4 external tests deselected.
  - Combined branch coverage: 93.54%.
  - Native lane: 495 passed.
  - Pure compatibility lane: 269 passed with the extension physically absent.
  - Clean native wheel installation, import, load, and interpolation passed.
  - YAML, TOML, shell syntax, formatting, and diff checks passed.

  The macOS loader path and Python 3.14 environment remain platform-CI validations.

Entire-Checkpoint: 01M1Q65XWD97TWG5D2F8HG7ETS
… to run pytest --cov

  -n4 to look at which files need coverage improvement. Update the threshold for CI when
  you've improved it as much as possible.

 Completed in about 28 minutes of active work.

  - Coverage improved from 93.54% to 99.54%.
  - Statements: 99.82%; branches: 98.74%.
  - CI threshold raised from 93% to 99% in pyproject.toml and .github/workflows/test.yml.
  - Final suite: 1,927 passed, 6 skipped.
  - git diff HEAD --check passes.
  - Ruff remains unavailable locally (No module named ruff).
  - Unrelated untracked directories were untouched.

Entire-Checkpoint: 01M1Q7SRKWBBZFZ7RVTCK7N3C3
Fixed in setup.py:53:

  - Editable builds now use in-tree native artifacts.
  - Redundant second native build removed.
  - Normal wheel destination is created explicitly.
  - Added packaging regression tests.
  - Corrected installation documentation.

  Use:

  pip install --upgrade numpy setuptools wheel
  pip install --no-build-isolation -e '.[test]'

  Verification passed: editable wheel, native wheel/Twine, Ruff/YAPF, and 1,929 tests with
  99.54% coverage.

Entire-Checkpoint: 01M1Q8PWXXQ76Z4892HSGR2HWT
…batch, including the

  two visible in the pasted tail. I’ve applied YAPF 0.43.0 to exactly those files; no source
  behavior was changed. I’m now checking formatter idempotence, Ruff, and whitespace/conflict
  hygiene across all tracked files.

Entire-Checkpoint: 01M1Q8X6E83RNVN19RYNPHXB9F
Entire-Checkpoint: 01M1Q93RQG762Y54HMGKE24ZMB
 The shared failure is consistent with a pytest/plugin compatibility break: the macOS lane
  permits unbounded pytest and pytest-forked upgrades, and CI resolved pytest 9.1.1 plus
  forked 1.7.5. I’m checking whether the warning originates in the fork wrapper’s inherited
  capture files and whether our macOS shutdown hook amplifies it before choosing the
  narrowest fix.

  Fixed the macOS native CI workflow in .github/workflows/test.yml:199.

  Cause: pytest and pytest-forked both captured child streams, producing unclosed-file
  warnings that warnings-as-errors promoted to setup failures.

  Fix: added --capture=no; pytest-forked still captures crash output.

  Validation: 517 passed, 1 skipped; YAML and diff checks pass.
Entire-Checkpoint: 01M1Q9FQTYXKCSK5WRSMMKNRJF
Comment thread README.md Outdated
  > @@ -13,25 +13,14 @@ Full documentation of the Gkeyll project is available at

   ## Dependencies and Installation

  -Postgkyl requires the following packages:
  +Postgkyl requires the packages listed in pyproject.toml

  I suggest you rewrite it with this structure:

  ## Installation

  1. Create environment
  (at least mention conda, even if you are not going to provide detail steps)
  ### pyenv
  ### mamba
  2. Install dependencies
  distinguish between dependencies in environment.yml and those in the .toml file (not sure why they can't just stay in a
  single file, but ok)
  3. Install postgkyl

  ## Documentation

  ## Developing for postgkyl

  ### pytest
  ### formatting
  This way the user is first faced with just what it needs to know (installation+documentation). The rest is to be left for
  later sections

Rewrote README.md with numbered installation steps, pyenv and mamba options, conda guidance, and a plain-language
  explanation of both dependency files. Documentation comes next, followed by bridge details and development instructions
  for pytest and formatting.

  Checked shell syntax, local links, CLI help, and whitespace.

Entire-Checkpoint: 01M2149062KBJZD88GE0KST23Q
… to print

  coefficients for each component:

  data.fit("linear", print_coeffs=True)

  pgkyl file.gkyl interpolate fit --fit_type linear --print_coeffs

  Verified: 113 tests passed, plus API and CLI output checks.
Entire-Checkpoint: 01M2391TEMKACDPHBXMA6P4E32
…. Your

  example now prints:

  fit 'exp_plateau':
    f(x) = A*exp(b*x) + C
    A: initial offset from C; f(0) = A + C.
    b: exponential rate (inverse x units); b < 0 means decay toward C.
    C: plateau approached as b*x -> -infinity.
    x: input coordinate (time for a time series).
    component 0:
      A = 0.188459978252
      b = -8959.07543391
      C = 1.22691657531

  All built-in models have descriptions. Full precision remains in the returned
  dataset. Verified: 113 tests passed.

Entire-Checkpoint: 01M2398Q1211EGNWCBZBT8FTH2
…nties. Checks

  confirm that windowed fits report errors only for the selected window; constant-
  data R² and estimates with no residual degrees of freedom are labeled undefined.
  All 116 targeted tests passed.

──────────────────────────────────────────────────────────────────────────────────

• Your existing fit ... -p command now also prints:

  - R²
  - Coefficient uncertainties (±1σ)
  - RMSE, residual sum of squares, and residual standard error
  - Sample count, parameter count, and degrees of freedom
  - Fitted coordinate ranges

  For windowed fits, statistics cover only the selected window.

  Verified: 116 tests passed.
Entire-Checkpoint: 01M239G46QPCA4SC036CGZNQFS
…had before. Here is the

  old file. """``print`` -- print the values (or grid) of the active datasets."""

  from __future__ import annotations

  import click
  from .._apply import active_datasets

  np.set_printoptions(precision=16)

  @click.command("print")
  @click.option("--use", default=None, help="Select active datasets carrying this tag.")
  @click.option("--grid", "-g", "show_grid", is_flag=True, default=False,
    # end
    for d in pool:
      if show_grid:
        for axis in d.grid:
          click.echo(axis)
      # end
        # end
      else:
        click.echo(np.asarray(d.values).squeeze())
  # end
      # end
    # end

    Added print with the old 16-digit precision and squeezed output:

  pgkyl file.gkyl print
  pgkyl file.gkyl print --grid
  pgkyl file.gkyl print --use mytag
  pgkyl file.gkyl interpolate print

  --grid also accepts -g. Raw modal data prints coefficients; interpolate first
  for field values. Printing preserves the pipeline and NumPy’s global
  settings.

  Validation: 177 tests passed.
Entire-Checkpoint: 01M25XANMXD4NMVKQSPS4920MA
Keep Postgkyl documentation in this repository and publish it as a section of
the existing Gkeyll Sphinx website. The website selects a Postgkyl revision;
that revision supplies the prose, API and CLI reference, executable examples,
input data, and figure generation. This is a proposed implementation plan;
the website integration has not yet been implemented.

Reviewed against Postgkyl `9355e5614914d0255b97c9729588cab2f500b8e4` and
gkyl-doc `3c339bb46df318025a141d88fcf86fcad9133677`.

## Existing foundations

- gkyl-doc builds `source/` with Sphinx and Furo. Its active toctree has no
  Postgkyl entry; the previous documentation lives in `source_archive/postgkyl/`.
  `source/conf.py` still references `postgkyl/_static`.
- The website's `.readthedocs.yaml` selects Python 3.8, while Postgkyl declares
  Python >=3.10. Use Python 3.12 for the combined build, matching Postgkyl's
  principal CI environment, and validate the resolved dependencies together.
- Public function signatures and Google-style docstrings already supply both
  Python help and the generated CLI. `tests/test_documentation.py` checks this
  contract. Extend that machinery rather than writing another command registry.
- `examples/scripts/` and `examples/cli_tutorial.md` are already executable
  documentation, exercised by `tests/test_examples.py`.
- `tests/test_data/` contains real gyrokinetic output. Synthetic fixtures are
  produced by `tests/generate_test_data.py` into the gitignored
  `tests/test_data/generated/` directory.

## Ownership and build contract

| Material | Authoritative home | Website treatment |
| --- | --- | --- |
| Concepts and task guides | Postgkyl `docs/` | Stage during build |
| Parameters, defaults, return values | Implementing function signatures and docstrings | Generate reference |
| CLI names and options | Existing compiled CLI and command metadata | Generate reference |
| Tutorial code | `examples/scripts/` and existing CLI tutorial | Include the tested source |
| Synthetic data | `tests/generate_test_data.py` | Generate before running examples |
| Simulation fixtures | `tests/test_data/` | Read from selected revision |
| Figures | Outputs of example scripts | Generate into ignored build directories |
| Theme, navigation, hosting | gkyl-doc | Add the section and build integration |
| Published Postgkyl revision | gkyl-doc Git submodule entry | Use for code, docs, examples, and data together |

Add Postgkyl as `external/postgkyl` in gkyl-doc, pinned by the Git submodule
commit. Do not maintain a second revision file or install an unrelated PyPI
release. Normal website builds use that pin; local integration checks may
explicitly supply a different checkout. Validate that the imported package
comes from the selected checkout.

Add one build entry point in Postgkyl, proposed as `scripts/build_docs.py`,
with explicit source and output paths. It prepares a self-contained Sphinx
source subtree: guides, generated reference pages, literal example sources,
figures, and downloadable inputs. gkyl-doc invokes it to populate the ignored
`source/postgkyl/` directory before Sphinx reads the toctree. Clean only this
owned output directory so removed pages cannot survive a rebuild. No generated
pages or figures are committed to gkyl-doc.

The same preparation step supports a standalone Postgkyl preview. A small
Postgkyl-owned Sphinx extension supplies any shared directives and setup to
both builds; the host retains its own theme. Keep documentation machinery
outside `src/postgkyl/` so the library's import DAG does not change.

Build sequence:

1. Check out the selected revision, including its test fixtures.
2. Install NumPy and build tools, then install that checkout with
   `--no-build-isolation` and a new `docs` extra. Declare Postgkyl documentation
   dependencies once in `pyproject.toml`. Build the native bridge through the
   existing installation flow and require `gpython.available()`.
3. Generate fixtures explicitly; a documentation build must not rely on pytest
   having run first. Execute the gallery headlessly with `MPLBACKEND=Agg` and
   `PGKYL_EXAMPLE_OUTPUT` pointing into the build directory.
4. Generate reference pages and stage sources/assets with relative links that
   work both standalone and under `postgkyl/`.
5. Build the combined website with its existing Sphinx invocation.

Use Read the Docs submodule configuration and build hooks for these steps.
Measure a cold native build on the hosted service during the integration
milestone; local success alone does not establish its time or memory budget.
Do not silently skip examples or substitute mock imports when the bridge fails.
Caching can be added if needed, keyed by the source revision, Gkeyll revision,
Python, NumPy ABI, and platform.

## Reader-facing section

Add `postgkyl/index` to `source/index.rst`, with these destinations:

- **Start here:** installation, verifying the bridge, obtaining example data,
  and one complete load → inspect → interpolate → lineout → plot → save task.
- **Working with data:** grids and components; modal, nodal, and quadrature
  representations; explicit conversions; DG versus pointwise arithmetic;
  integration and averaging; groups and CLI pipelines.
- **Tutorials:** scientific questions answered with executable examples and
  the corresponding CLI spelling where available.
- **Reference:** loading, fluent API, operations, diagnostics grouped by
  model family, rendering, and the complete generated command inventory.
- **Troubleshooting and contributing:** missing metadata, bridge builds,
  representation errors, headless rendering, and building these docs locally.

Include existing installation instructions from the README using a Markdown
include mechanism, or move them once and replace the README section with a
link. Do the same for the CLI tutorial; do not hand-copy either. Use Sphinx
autodoc plus Napoleon for Google-style docstrings. Use literal includes of
named script regions so displayed code is the code that runs.

Generate CLI pages from the actual Click commands produced by
`postgkyl.cli.app`, preserving underscore names, defaults, boolean behavior,
and command groups. Derive API inventory from public exports and public class
members, including Python-only members that CLI discovery intentionally hides.
Document canonical callables once and link their fluent/functional aliases.
Derive diagnostic quantity listings from their existing registries.

Describe capability rules using `value_form`, not the oversimplification that
all native data reject NumPy or plotting. Explain that basis metadata is fixed
at loading and that modal arithmetic has different semantics from arithmetic
after interpolation. Fix inaccurate source docstrings at their implementation.

## Initial tutorial gallery

| Reader's task | Existing source/data | Figure and teaching outcome |
| --- | --- | --- |
| Inspect a field and extract a lineout | `01_quickstart.py`; generated `2d_c2p_rot45_ms_p1.gkyl` | Field map plus lineout; identify the input as an analytic coordinate map, explain axes and components, and demonstrate save/reload |
| Compare two profile calculations | `mirror_comparison.py`; generated `mirror_comparison_*_1d_ms_p1.gkyl` | Four labeled panels with linear/log scales; explain that these are synthetic profiles and that the script rescales temperatures |
| Recover fluid primitives | `03_diagnostics_five_moment.py` | Density and pressure across a shock-tube initial discontinuity; show conserved-to-primitive conversion |
| Inspect gyrokinetic density and velocity-space structure | `04_gyrokinetics.py`; `rt_gk_tcv_iwl*` moment, distribution, and geometry files | Density profile and fixed-mu distribution slice; explain Jacobian correction and velocity coordinates |
| View density in physical R–Z coordinates | `05_gk_rz.py`; `rt_gk_tcv_nt_iwl_3x2v_p1-elc_M0_5.gkyl` and matching map | R–Z density map; show which geometry files are needed and when projection reuse helps |
| Measure a growth interval | Existing CLI tutorial and generated `energy_dynvec.gkyl` | Energy history with fitted interval and residuals; distinguish early growth from saturation |
| Choose DG or pointwise arithmetic | `02_arithmetic_and_numpy.py` | Computed comparison with an analytic check; explain why interpolating first changes the question |

Each page states the scientific question, input provenance, prerequisites,
copyable execution command, expected interpretation, and a small numerical
check. Include the complete script and the minimum required input bundle for
download, preserving relative filenames and geometry companions. Provide
clear axis labels, units only when verified, captions, and alt text. Generate
PNG or SVG for browser display; keep PDF downloads where useful.

Before publishing, audit the existing tutorials' scientific explanations:
the shock example is an initial condition, not an evolved shock solution;
the mirror profiles are not a simulation convergence study; the dynvector
uses logistic curves rather than measured instability data. Check the `M1`
description and its illustrative `mass=2.0` against the quantity implementation
and available provenance before presenting physical units or conclusions.

No new simulation output is needed for the first gallery. Move the shock-tube
initial-condition construction into the existing test-data generator so the
tutorial loads a reproducible fixture. Add deterministic analytic fixtures
there only if a tutorial needs a known growth rate or a clearer field example.
Store generated files under `tests/test_data/generated/`, with correct basis,
value-form, component, and time metadata. Test numerical invariants rather
than pixel equality. Do not add random coefficient fields as physical examples.

## Implementation milestones and acceptance

1. **Documentation foundation in Postgkyl.** Add `docs/`, the docs extra,
   reference generation, and the standalone preparation/build command.
   Acceptance: a clean checkout builds navigable reference pages with actual
   signatures and links; changing a source default changes the reference.
2. **Executable gallery.** Reuse and improve existing examples, add the
   shock fixture, and generate figures and downloads. Extend existing example
   tests for scientific invariants and required outputs. Acceptance: each
   displayed command runs, inputs are downloadable, and every figure is
   inspected for readable labels, scaling, clipping, and truthful captions.
3. **Website integration.** Add the pinned submodule, navigation entry,
   ignored staging directory, shared extension setup, and RTD hooks. Update
   Python and remove or reconcile obsolete static-path configuration.
   Acceptance: the complete website builds from a clean clone and the new
   section appears in navigation, search, and a hosted preview.
4. **Continuous checks and maintenance.** Add a Postgkyl docs CI lane and a
   gkyl-doc integration lane. Require the bridge and run the existing
   documentation/example tests; build Postgkyl docs with warnings as errors.
   Check the host's existing warning baseline separately and fix integration
   issues without blanket warning suppression. Check internal links and
   downloads on every build; external link checks can run separately.
   Acceptance: a broken example, missing figure, invalid reference, or wrong
   imported revision fails the build.
5. **Publication and revision updates.** Merge the Postgkyl implementation,
   then select its tested commit in gkyl-doc. A subsequent code release updates
   that submodule pin and rebuilds the site; it does not require prose edits
   in gkyl-doc. Include the Postgkyl version/commit and source-edit links on its
   pages, pointing to Postgkyl rather than the generated host files. Retain
   archived material as historical and map old entry URLs to the new landing
   page where hosting supports redirects. Updating the pin is the initial
   maintenance workflow; cross-repository update automation can follow if needed.

The current local check passed: `POSTGKYL_REQUIRE_GKEYLL=1 MPLBACKEND=Agg
python -m pytest tests/test_examples.py tests/test_documentation.py -q` —
30 tests passed. This verifies the reusable baseline, not the proposed website
build or the scientific accuracy of every existing caption.

## Integration references

- [Sphinx autodoc and Napoleon](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html)
  provide signature/docstring extraction and Google-style parsing.
- [Sphinx literal includes](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude)
  allow pages to display the executable source.
- [Read the Docs configuration](https://docs.readthedocs.com/platform/stable/config-file/v2.html)
  supports selecting Python and checking out submodules.
- [Read the Docs build hooks](https://docs.readthedocs.com/platform/stable/build-customization.html)
  provide the preparation steps before the Sphinx build.

Implemented in both repositories:

  - Generated time-series data, collect examples, and two
    animations.

  - Plotly 2D surfaces/3D isosurfaces and PyVista examples.
  - Direct grid/value access and manual NumPy manipulation.
  - Paired Python scripts and CLI commands for all 19 published
    figures, with automated equivalence checks.

  - 171 individual Python API reference pages linked to CLI
    commands.

  - Python 3.12 documentation CI, with website builds sourcing
    Postgkyl main.

  Validation: 41 documentation/example tests and 3 website
  integration tests passed. The complete website builds with
  warnings treated as errors.

  Open the local preview (/home/maxwell-rosen/Documents/gkyl-
  doc/build/html/postgkyl/index.html). Changes are local;
  nothing has been published.

Entire-Checkpoint: 01M262P5WD0PNFXEACD976METC
…ading

Entire-Checkpoint: 01M26BPTGZVCY6YTEV0NSMBBQ5
Entire-Checkpoint: 01M26CRPNSNFYWWFPW2YZEEGR5
Entire-Checkpoint: 01M26DDP6ZQ7699Q37X0ZQ65EX
Entire-Checkpoint: 01M26F0WH1SJC67JG4VRPA0H9B
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

3 participants