ppgrid 0.2.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. ppgrid-0.4.0/.github/workflows/ci.yml +44 -0
  2. ppgrid-0.4.0/.github/workflows/publish.yml +43 -0
  3. {ppgrid-0.2.2 → ppgrid-0.4.0}/.gitignore +2 -0
  4. ppgrid-0.4.0/AGENTS.md +165 -0
  5. ppgrid-0.4.0/CHANGELOG.md +101 -0
  6. {ppgrid-0.2.2 → ppgrid-0.4.0}/PKG-INFO +34 -2
  7. {ppgrid-0.2.2 → ppgrid-0.4.0}/README.md +33 -1
  8. ppgrid-0.4.0/bench/turbo_bench.py +69 -0
  9. ppgrid-0.4.0/bench/turbo_decomp.py +96 -0
  10. ppgrid-0.4.0/docs/DESIGN-sparse-output.md +506 -0
  11. ppgrid-0.4.0/docs/turbo-design.md +1174 -0
  12. ppgrid-0.4.0/ppgrid/_prof.py +75 -0
  13. ppgrid-0.4.0/ppgrid/_tempfiles.py +40 -0
  14. {ppgrid-0.2.2 → ppgrid-0.4.0}/ppgrid/calibrate.py +31 -2
  15. ppgrid-0.4.0/ppgrid/pipeline.py +2872 -0
  16. ppgrid-0.4.0/ppgrid/pullpush.py +1038 -0
  17. ppgrid-0.4.0/ppgrid/turbop.py +870 -0
  18. ppgrid-0.4.0/ppgrid/zstdmt.py +595 -0
  19. {ppgrid-0.2.2 → ppgrid-0.4.0}/pyproject.toml +15 -2
  20. {ppgrid-0.2.2 → ppgrid-0.4.0}/tests/test_build.py +3 -1
  21. {ppgrid-0.2.2 → ppgrid-0.4.0}/tests/test_calibrate.py +44 -0
  22. ppgrid-0.4.0/tests/test_pipeline.py +886 -0
  23. ppgrid-0.4.0/tests/test_pullpush_mt.py +398 -0
  24. ppgrid-0.4.0/tests/test_reproject_skip.py +253 -0
  25. {ppgrid-0.2.2 → ppgrid-0.4.0}/tests/test_tier2_shared.py +121 -1
  26. ppgrid-0.4.0/tests/test_turbo.py +1030 -0
  27. ppgrid-0.4.0/tests/test_turbop.py +513 -0
  28. ppgrid-0.4.0/tests/test_zstdmt.py +357 -0
  29. {ppgrid-0.2.2 → ppgrid-0.4.0}/uv.lock +1 -1
  30. ppgrid-0.2.2/.github/workflows/ci.yml +0 -18
  31. ppgrid-0.2.2/.github/workflows/publish.yml +0 -15
  32. ppgrid-0.2.2/AGENTS.md +0 -94
  33. ppgrid-0.2.2/CHANGELOG.md +0 -65
  34. ppgrid-0.2.2/ppgrid/pipeline.py +0 -1077
  35. ppgrid-0.2.2/ppgrid/pullpush.py +0 -443
  36. ppgrid-0.2.2/tests/test_pipeline.py +0 -423
  37. {ppgrid-0.2.2 → ppgrid-0.4.0}/.github/CODEOWNERS +0 -0
  38. {ppgrid-0.2.2 → ppgrid-0.4.0}/.pre-commit-config.yaml +0 -0
  39. {ppgrid-0.2.2 → ppgrid-0.4.0}/LICENSE +0 -0
  40. {ppgrid-0.2.2 → ppgrid-0.4.0}/SECURITY.md +0 -0
  41. {ppgrid-0.2.2 → ppgrid-0.4.0}/SUPPORT.md +0 -0
  42. {ppgrid-0.2.2 → ppgrid-0.4.0}/bench/bench_tier2.py +0 -0
  43. {ppgrid-0.2.2 → ppgrid-0.4.0}/bench/compare_tier2.py +0 -0
  44. {ppgrid-0.2.2 → ppgrid-0.4.0}/data/melb_houses.csv +0 -0
  45. {ppgrid-0.2.2 → ppgrid-0.4.0}/examples/run_melb.py +0 -0
  46. {ppgrid-0.2.2 → ppgrid-0.4.0}/ppgrid/__init__.py +0 -0
  47. {ppgrid-0.2.2 → ppgrid-0.4.0}/ppgrid/__main__.py +0 -0
  48. {ppgrid-0.2.2 → ppgrid-0.4.0}/ppgrid/idwgrid.py +0 -0
  49. {ppgrid-0.2.2 → ppgrid-0.4.0}/tests/__init__.py +0 -0
  50. {ppgrid-0.2.2 → ppgrid-0.4.0}/tests/test_acceptance.py +0 -0
  51. {ppgrid-0.2.2 → ppgrid-0.4.0}/tests/test_ppgrid.py +0 -0
@@ -0,0 +1,44 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ # PRs: full interpreter sweep on GitHub-hosted runners, pinned versions
11
+ # (requires-python >=3.11, classifiers 3.11-3.14; review m6).
12
+ test:
13
+ if: github.event_name == 'pull_request'
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: astral-sh/setup-uv@v4
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ - run: uv sync --all-extras --locked
25
+ - run: uv run --extra dev ruff check .
26
+ - run: uv run --extra dev ruff format --check .
27
+ - run: uv run pytest
28
+
29
+ # Pushes to main: GitHub-hosted, single leg (review m6: matrix is PR-only).
30
+ # 2026-10-08: was runs-on 'nf' — that label points at the
31
+ # nestfinderau/nestfinder REPO runners (label 'nestfinder-ci'); nothing
32
+ # named 'nf' is registered on the marzukia account, so the first main
33
+ # push (#39 merge) queued 9.5h starved. Wire real self-hosted runners
34
+ # to this account before switching back.
35
+ test-main:
36
+ if: github.event_name == 'push'
37
+ runs-on: ubuntu-latest
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - uses: astral-sh/setup-uv@v4
41
+ - run: uv sync --all-extras --locked
42
+ - run: uv run --extra dev ruff check .
43
+ - run: uv run --extra dev ruff format --check .
44
+ - run: uv run pytest
@@ -0,0 +1,43 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+ inputs:
8
+ dry_run:
9
+ description: "Build and check the upload, but do not publish"
10
+ type: boolean
11
+ default: false
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v4
19
+ - run: uv sync --locked -q
20
+ # Fail fast when a release tag does not match the pyproject version
21
+ # (before: an opaque uv publish "File already exists", or a
22
+ # workflow_dispatch publishing whatever version is checked out).
23
+ - name: Guard release tag matches pyproject version
24
+ if: github.event_name == 'release'
25
+ env:
26
+ TAG_NAME: ${{ github.event.release.tag_name }}
27
+ run: |
28
+ ver=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
29
+ if [ "$TAG_NAME" != "v$ver" ]; then
30
+ echo "::error::release tag '$TAG_NAME' does not match pyproject version '$ver' (expected 'v$ver'); aborting before upload"
31
+ exit 1
32
+ fi
33
+ echo "release tag $TAG_NAME matches pyproject version $ver"
34
+ - run: uv build
35
+ - name: Publish
36
+ env:
37
+ DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
38
+ run: |
39
+ if [ "$DRY_RUN" = "true" ]; then
40
+ uv publish --dry-run --token ${{ secrets.PYPI_TOKEN }}
41
+ else
42
+ uv publish --token ${{ secrets.PYPI_TOKEN }}
43
+ fi
@@ -8,3 +8,5 @@ __pycache__/
8
8
  .DS_Store
9
9
  **/.DS_Store
10
10
  out/
11
+ # Full-AU sparse point set (53 MB, generated; not shipped in the sdist either)
12
+ data/au_gcc_sparse.csv
ppgrid-0.4.0/AGENTS.md ADDED
@@ -0,0 +1,165 @@
1
+ # AGENTS.md
2
+
3
+ Working notes + rules for AI agents (and humans) touching this repo. Read this before editing.
4
+
5
+ ## What this is
6
+
7
+ `ppgrid` — pull-push mipmap scattered-data interpolation. Turns `N` geolocated points
8
+ into a pair of int16 GeoTIFFs (value percentile + support-km) at continent scale in
9
+ minutes, no GPU. It is an **IDW approximation**: fast, visually clean, and
10
+ deliberately *not* a spatially exact interpolator (the output is for visualisation,
11
+ not downstream calculation).
12
+
13
+ - Python 3.11+, `uv` managed. Entry point `ppgrid` (`ppgrid.pipeline:main`; `ppgrid.idwgrid` is a back-compat shim).
14
+ - Deps: numpy, pandas, pyproj, rasterio. Optional `[parquet]` (pyarrow).
15
+ - Tests: `uv run pytest` (190 tests). Lint/format: `ruff` (line-length 120).
16
+
17
+ ## Repo layout
18
+
19
+ - `ppgrid/pipeline.py` — the Pipeline + CLI. All the logic lives here. `ppgrid/idwgrid.py` is a thin back-compat shim (module alias to `ppgrid.pipeline`).
20
+ - `ppgrid/calibrate.py` — transform selection + spatially-blocked CV fill-cap.
21
+ - `data/` — example inputs: `melb_houses.csv` (13,580 pts), `all_equakes.csv` (44,376 pts).
22
+ - `examples/` — committed outputs + repro scripts + README images (see "Images" below).
23
+ - `tests/` — pytest. `examples/run_melb.py` regenerates the Melbourne example rasters.
24
+
25
+ ## Versioning / release
26
+
27
+ - `pyproject.toml` `version` is the source of truth; `__version__` reads it via
28
+ `importlib.metadata`. Keep `CHANGELOG.md` in step with every version bump.
29
+ - Releasing: bump `version` + `uv.lock`, update CHANGELOG, push a tag, the
30
+ `publish.yml` workflow uploads to PyPI. The README PyPI badge shows the *published*
31
+ version, so it lags the repo until a tag is cut.
32
+
33
+ ## Rules — do not fuck these up
34
+
35
+ ### Images in the README (this bit bites)
36
+ - The README is the PyPI `long_description`. **Every image MUST be an absolute
37
+ `https://raw.githubusercontent.com/marzukia/ppgrid/main/examples/...` URL.**
38
+ Relative `examples/...` paths render on GitHub but 404 on PyPI's static host.
39
+ - README images are committed under `examples/`. They resolve from `main`, so a
40
+ new image is invisible on the repo's README **until the branch merges to main**.
41
+ - The benchmark charts (`bench_headtohead.png`, `bench_scaling.png`) are generated
42
+ with **`charted`** (github.com/marzukia/charted — zero-dep Python SVG/PNG). Regenerate,
43
+ don't hand-edit:
44
+ ```bash
45
+ uvx --from "charted[png]" python # build BarChart / LineChart, .to_png_bytes()
46
+ ```
47
+ Data for those charts lives in the blog post
48
+ (`mrzk.io` ppgrid post, `fig-bar-benchmark.json` / `fig-line-scaling.json`).
49
+
50
+ ### The `--percentile-step` guard
51
+ - `pipeline.py` uses `if pct_step is not None:` (NOT `if pct_step:`). A step value of
52
+ `0` is invalid (validated to `(0, 100]`) but the `is not None` form is the correct
53
+ guard and matches the docstring. Do not "simplify" it back to a truthiness check.
54
+
55
+ ### CRS defaults
56
+ - Working CRS `EPSG:6933` (Wagner VII, equal-area, metres are true) for interpolation.
57
+ - Output CRS `EPSG:3857` (Web Mercator) for the final GeoTIFF.
58
+ - Input `EPSG:4326`. All three are CLI-overridable (`--work-crs`, `--out-crs`,
59
+ `--src-crs`). Don't hardcode a different default.
60
+
61
+ ### Output encoding is opinionated — leave it
62
+ - Value band = **percentile, not raw value**: `percentile = DN / scale`, default
63
+ scale 100 so DN 5000 = 50th percentile. int16 fits 0-10000. Raw values are restored
64
+ via the `calibration.json` LUT. This is deliberate (clean vectorisation/MVT, rounds
65
+ to whole numbers). If you "fix" it to store raw values you break the int16 design
66
+ and the decoding example in the README.
67
+
68
+ ### Data provenance
69
+ - `melb_houses.csv`: Kaggle Melbourne Housing Snapshot, **CC BY-NC-SA 4.0**.
70
+ - `all_equakes.csv`: USGS FDSN, **Public Domain**.
71
+ - Keep the Data Lineage table in the README accurate if the equake download window
72
+ or filters change.
73
+
74
+ ## Git / pushing
75
+
76
+ - The repo lives under the `marzukia` GitHub org. The `monkytheluffy` bot identity is
77
+ NOT a collaborator (push 403s). Push with the `marzukia` PAT:
78
+ ```bash
79
+ PAT=$(cat ~/.config/marzukia-pat)
80
+ git push "https://x-access-token:${PAT}@github.com/marzukia/ppgrid" <branch>
81
+ ```
82
+ - Branches go to a PR, not main, per the fleet merge gate.
83
+
84
+ ## What changed recently (2026-10-08, sparse-output branch reconciled onto main post-#50)
85
+
86
+ - PR #27 (`sparse-output-lowram`) merged `origin/main` (0.3.0, turbo) and reconciled: **main's turbo behaviour is the baseline; #27's low-RAM optimisations layer on top.**
87
+ - `_reproject_band` gained `task_info` (work-grid block geometry + populated-block set from `grid()`): a coarse 2048px dst tile whose source footprint intersects no task block is written as a plain NoData buffer without a GDAL warp (conservative round-out, so never a false empty). File-source serial reproject only; the turbo array reproject (`_reproject_band_array`) is untouched.
88
+ - `_descent_banded` (main's structure: `_thread_bands` + `_descent_band_body` per-thread buffers) gained the #27 per-level RAM gate: pool clamped to `min(n_threads, nbands, 8)`, and single-thread when `/proc/meminfo` MemAvailable < peak RSS + two bands of f32 buffers. `nworkers` is an alias of `n_threads` (both test suites call it).
89
+ - Non-turbo (low-RAM) shared descent is now parallel by design: `--max-band-parallel` caps the pool, default `workers` (main's baseline for this path was serial `n_threads = 1`). Turbo regime A still never calls `_descent_banded` (full in-RAM descent) — unchanged.
90
+ - Bit-identity holds at every thread count (S3 guarantee; `tests/test_pullpush_mt.py` + the #27 bit-identical/pool/RAM-gate tests all green). 180 (main) + 10 (#27) = 190 tests.
91
+
92
+ ## What changed recently (2026-10-06→08, turbo speed passes 2/3 + 0.3.0 release)
93
+
94
+ - **Speed passes 2/3 (0a6fe90 + 941f4e5, both in the #39 merge history): full-AU 50:46 → 2:33, byte-identical** (regime A, `--turbo 64`, 43 GB; per-box 8.5 GB: 3:20; all outputs sha256-identical to the serial anchors). Pass 2 (0a6fe90) is the dnfill/write-path base; pass 3 (941f4e5) hit the wall target.
95
+ - Regime A keeps the shared full fields + quantised DN fields on tmpfs memmaps (reclaimable clean file cache) instead of anon RAM; `_pull_push_descent(out_val=/out_sup=)` writes the final descent level straight into the shared fields (bit-exact, threaded and serial branches).
96
+ - Parallel stock writer (`_turbo_write_stock_parallel`): fixed three silent serial-fallback bugs — TID scratch collision (`get_ident() & 0xFFFF`), block-major vs global 512 raster-scan tile order (608 vs 616 tiles), 3 GB zero-placeholder reference head (now one 512² tile).
97
+ - Reproject: one no-copy `MemoryDataset(copy=False)` + MultiBand tuple form, shared read-only across warp threads (rasterio's ndarray form copied 3.1 GB per warp call per thread; 8 in-flight copies OOM-killed the run).
98
+ - `_quantize` in-place (same IEEE ops/order, bit-identical), per-thread f32/f64 scratch, owned int16 outputs (the shared thread-local scratch raced the `ex.map` prefetch and corrupted ~10% of blocks).
99
+ - Support-blend fix: `om` buffer reused across blends — computed `(1-a)*pv*ps` instead of `(1-a)*ps`. Correctness fix on the default multi-level path: `support_km.tif` for multi-level grids may differ vs 0.2.2 (now correct), `value.tif` unaffected.
100
+ - `MADV_NOHUGEPAGE` on large fresh arrays (THP=always + sync defrag made first-touch faults 20-40 us/page).
101
+ - Known state: on this GDAL stack the zstd oracle mismatches libtiff streaming, so turbo runs take the byte-exact stock-parallel write fallback; the parallel ZSTD path is unit-tested and activates where the stack matches.
102
+ - **Budget semantics (measured):** `--ram-gb` caps the shared-field budget, NOT process RSS — regime-C runs peaked at 39.4 GB under a 20 GB budget (write/descent buffers sit outside Bt sizing). per_box is the only genuine low-RAM mode.
103
+ - **0.3.0 released 2026-10-08** (tag v0.3.0 @ f9e0380): CHANGELOG consolidated into one [0.3.0] entry; release notes live on the GitHub release; `publish.yml` publishes to PyPI on `release: published`. Issues #29-#33 closed as landed in #39.
104
+ - Review findings from the #39 adversarial review are filed as #40-#45 (all minor, post-release): stale-output warning gap (#40), odd `band_rows` off-by-one (#41), `_turbo_zstd_ok` docstring (#42), dead `downsample_sum(out=)` param (#43), WIP-labelled 0a6fe90 in history (#44), DN temp-file hygiene vs `_open_fresh_memmap` guard (#45).
105
+
106
+ ## What changed recently (2026-10-07, PR #39 review fixes: M-1..M-3, Y-1)
107
+
108
+ - M-1: equakes anchors regenerated under the rasterio 1.5.1 lock (the committed
109
+ files were cut on 1.5.0; the vendored GDAL emits different BigTIFF ZSTD tile
110
+ bytes — arrays equal, tile offsets moved). New shas in the A.6 table:
111
+ `examples/equakes/value.tif` `99c8a01c…`, `support_km.tif` `5e545742…`.
112
+ `bench/compare_tier2.py` re-run: bit-identical (melb anchors unchanged).
113
+ - M-2: `turbop.resolve_cap` clamps a preset against `min(physical, cgroup)`
114
+ (a capped slice OOMs at its own limit); the pipeline wires both reads via
115
+ `Pipeline._resolve_turbo_cap` (before: preset 32 on a 25.8 GB slice planned
116
+ 32 GB; now clamps to 17.8 GB).
117
+ - M-3: `turbop.per_box_peak_bytes` (64 B/cell bottom-up + 2 GB fixed, x
118
+ in-flight blocks + points/DN extra) + `precheck(per_box_peak_bytes=…)`;
119
+ the pipeline computes it from the worst snapped box after `grid()`.
120
+ Non-strict per-box over budget now warns "best-effort, OOM risk, output may
121
+ be partial" instead of promising exit-0 correct output; strict names the
122
+ per-box infeasibility. Verified: melb @1m --max-ram 8 warns 189.7 GB est >
123
+ 6.8 GB budget, then dies 137.
124
+ - Y-1: BigTIFF slot widths in the parallel ZSTD write. `_tif_parse_ifd` sizes
125
+ tags per TIFF type (type 16 LONG8 added; unknown type = hard error) and
126
+ returns `sz324/sz325/fmt324/fmt325`; `_turbo_write_parallel` patches 324
127
+ (8-byte slots) and 325 (4-byte slots) at their real strides, shared
128
+ `_TIFF_TYPE_SIZES` with the overlap guard. Pre-fix, one 8-byte stride
129
+ corrupted both arrays on every real BigTIFF output (7888 tiles: 325 sizes
130
+ read `2000, 0, 2001, 0, …`). Pinned: BigTIFF parallel byte-identity test +
131
+ IFD parse value asserts.
132
+ - Tests: 170 -> 176 (resolve_cap cgroup clamp, per-box peak model + gate,
133
+ pipeline wiring, BigTIFF parallel, IFD value pins).
134
+
135
+ ## What changed recently (2026-10-07, turbo write path + CLI, issue #33)
136
+
137
+ - CLI: `--turbo` (optional preset `16|32|64|128`; bare = auto cap), `--max-ram <GB>` (implies turbo), `--ram-gb` (alias of `--max-ram`), `--turbo-strict`. Pre-check after `grid()` prints cap/budget/regime used; shared infeasible (per-box regime) -> strict: stderr error + exit 3, else `[warn]` + continue on per-box with turbo warp/write (exit 0, correct output).
138
+ - `_write_rasters` turbo path: no work-CRS intermediate files. Quantised int16 DN fields stay in RAM (budget-gated: `nx*ny*4 > budget//2` -> file-based reproject path with MT warp) and are reprojected tile-by-tile through the same GDAL warp kernel as the serial path (`_reproject_band_array`; same 2048px tiles, same 512px serial flush, same metadata) -> byte-identical output, sha256-tested.
139
+ - Parallel per-tile ZSTD (`_turbo_write_parallel`): GDAL zero-filled reference head serialises the exact IFD/tag layout; the pipeline patches TileOffsets/TileByteCounts + oracle-verified zstd frames. Oracle mismatch (e.g. CPL zstd pfn vs libtiff streaming params differ on a stack) -> byte-exact stock-codec parallel write (the serial per-band write is the last resort only if that writer's layout guards fail).
140
+ - Budget-derived shared cap (S3.3): turbo mode derives the cells cap from `Bt = 0.85*C - 4 GB`; non-turbo keeps `_SHARED_MAX_CELLS = 2.5e8`; the `1e8` in-RAM/memmap tier is unchanged.
141
+ - `_prepare_shared` / `box_count_banded` run threaded under the budgeted worker count when turbo (`plan.workers`); `n_threads == 1` keeps the exact serial path (A8).
142
+ - `turbop._read_cgroup_max_gb` fixed: walks the process cgroup hierarchy to the root and takes the smallest finite `memory.max` (a slice limit above the service cgroup binds). Previously only the root cgroup was read, so a memory-capped user slice saw full physical RAM and auto `--turbo` could plan a regime that OOMs the slice.
143
+ - `examples/melb/10m/value.tif` regenerated: the committed anchor predated a pipeline change; current `main` and this branch produce the new bytes (verified against pre-turbo HEAD).
144
+ - Tests: `tests/test_turbo.py` now 22 (A9 regime pins, strict exit 3, non-strict continue, CLI flags/wiring, array-vs-file reproject sha, parallel-zstd byte-identity, 4GiB guard, IFD classic+BigTIFF parse, e2e in-RAM identity, budget-gate fallback, out==work CRS, `box_count_banded` MT, `test_cgroup_reader` walk-up).
145
+
146
+ ## What changed recently (2026-10-06, turbo read-side MT, 0.3.0)
147
+
148
+ - New `Pipeline(n_threads=...)` kwarg (default `1`, serial path unchanged, byte-identical output). NOT a CLI flag.
149
+ - `_reproject_band`: 2048px warp tiles per row band run on a `ThreadPoolExecutor` via the module-level `_warp_dst_tile` helper (per-tile source open: GDAL handles are not thread-safe). The 512px flush loop STAYS serial raster-scan (file bytes depend on tile order). Do not "parallelise" the flush.
150
+ - `ingest`: CSVs >= 100k rows per chunk split into complete-record byte ranges (`_csv_chunk_tasks`, newline scan + quote-parity guards, `None` on any doubt -> serial parse) and parsed in a `ProcessPoolExecutor` (`_read_csv_chunk`, explicit float64 dtypes + per-chunk dtype assert). Chunks concat in original row order (`bin_points` is order-sensitive); row-count mismatch re-runs the serial parse.
151
+ - rasterio's benign `NotGeoreferencedWarning` is ignored at import (CPython warning filters are process-global, not thread-safe; the library's nested suppression leaks under concurrent warps). A matching `filterwarnings` entry in `[tool.pytest.ini_options]` restates it for pytest.
152
+ - Tests: `tests/test_turbo.py` (6, sha256/bit-identity + fallbacks). Bench: `bench/turbo_bench.py`, `bench/turbo_decomp.py`.
153
+ - Design doc in-repo: `docs/turbo-design.md` (accepted criteria A4-A8).
154
+
155
+ ## What changed recently (2026-09-22, the "polish" pass)
156
+
157
+ - Bumped to **0.2.1**.
158
+ - `--percentile-step` flag (round output percentiles to a step, e.g. 5 → 90/95/100)
159
+ + the `is not None` guard fix.
160
+ - README: added hero image (`examples/melb_4panel.jpg`), replaced the old single
161
+ wall-time-vs-resolution chart with two `charted` benchmark charts (head-to-head vs
162
+ gdal at 100K pts + wall-time-vs-point-count scaling), added side-by-side/zoom
163
+ comparison images, fixed the clone URL (`pullpush` → `ppgrid`). Removed stale
164
+ `bench_combined/time/size.png`.
165
+ - All README image refs converted to absolute raw.githubusercontent URLs for PyPI.
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.4.0] - 2026-10-08
6
+ - Performance (sparse output, low-RAM scope): `_reproject_band` skips the reproject warp for coarse output tiles whose work-CRS source footprint (conservatively rounded out) intersects no task block, writing a plain NoData buffer instead — RAM-free, and a no-op when no tile is skippable (the auto-cap case). The skip is guarded end-to-end against a no-skip reference with vertically asymmetric tasks (bit-identical; `tests/test_reproject_skip.py`). `_descent_banded` row-bands within a level now run on a thread pool (levels stay sequential): bit-identical output, `--max-band-parallel` flag (default `min(workers, nbands, 8)`), and a per-level `/proc/meminfo` gate that falls back to single-threaded when available RAM is below peak RSS plus two bands of float32 buffers.
7
+ - Write-path hardening (production polish round):
8
+ - Atomic finals: `value.tif`/`support_km.tif` publish only via `.tif.tmp` rename (serial + turbo) — a mid-write crash leaves the previous run's finals byte-identical and warns (#40).
9
+ - Stale-output warning on any non-zero exit over pre-existing rasters in the out dir; reviewer repro pinned as a test (#40).
10
+ - All 4 unguarded `w+` memmap sites routed through `_open_fresh_memmap` (new shared `ppgrid/_tempfiles.py`) (#45).
11
+ - `_pull_push_descent` `_band_bufs` off-by-one for odd `band_rows` (+4 -> +5) with bit-exact MT regression test (#41).
12
+ - Dead `downsample_sum(out=)` parameter removed (#43).
13
+ - ZSTD oracle mismatch warning unambiguous ("bytes differ at tile N"), names the stock PARALLEL writer as the fallback.
14
+ - CI: PRs on a pinned 3.11-3.14 matrix (ubuntu-latest); main single leg; publish.yml guard — release tag must match pyproject version before upload; `workflow_dispatch` dry_run input -> `uv publish --dry-run`.
15
+ - Tests: 176 -> 190 (stale/mid-write/odd-band regressions + low-RAM bit-identity guards incl. the end-to-end asymmetric-tasks repro).
16
+
17
+ ## [0.3.0] - 2026-10-08
18
+ - Full-AU (16M points, `--res 100`): 30:45 (quiet) to 50:46 (loaded) with the same pre-PR turbo code, load-dependent -> 2:33 wall time (~12-20x) with sha256-identical outputs (marzuki-hydrogen, `--turbo 64`, regime A).
19
+ - Turbo: write path + budget cap + CLI (issue #33):
20
+ - `--turbo` (optional preset `16|32|64|128`; bare = auto `min(physical, cgroup) - 8 GB`), `--max-ram <GB>` (implies turbo), `--ram-gb` alias, `--turbo-strict`.
21
+ - Pre-check after `grid()`: prints the cap/budget/regime actually used; shared infeasible (per-box regime) -> strict: error + exit 3, else `[warn]` + continue on the per-box path with turbo warp/write (exit 0, correct output).
22
+ - `_write_rasters` turbo: skips the work-CRS intermediate files, keeps the quantised int16 DN fields in RAM, and reprojects tile-by-tile from arrays through the same GDAL warp kernel (byte-identical to the file-based path, sha256-tested). Optional parallel per-tile ZSTD via `zstdmt.compress_tiles`, gated by the compression oracle (mismatch -> byte-exact stock-codec parallel write; the serial per-band write is the last resort only if that writer's layout guards fail). In-RAM DN is budget-gated; oversized grids fall back to the file-based reproject path with MT warp.
23
+ - Budget-derived shared cap (S3.3) replaces the hard 2.5e8 cell cap in turbo mode; non-turbo keeps 2.5e8; the 1e8 in-RAM/memmap tier is unchanged.
24
+ - `_prepare_shared` / `box_count_banded` run threaded under the budgeted worker count when turbo (serial at `n_threads == 1`, A8 preserved).
25
+ - Fixed `turbop._read_cgroup_max_gb`: now walks the process cgroup hierarchy to the root and takes the smallest finite `memory.max` (a slice limit above the service cgroup binds; previously only the root cgroup was read, so capped user slices saw the full physical RAM and the auto cap could plan a regime that OOMs the slice).
26
+ - Regenerated stale anchor `examples/melb/10m/value.tif` (byte-identical to current `main` output; the committed file predated a pipeline change).
27
+ - RAM budget `Bt = 0.85 * C - 4 GB` (`C` = effective RAM cap) drives regime selection: A = full in-RAM shared, B = input in RAM + banded val/sup memmap, C = shared all-memmap banded descent (input + val/sup file-backed memmap, 8 GB page-cache floor in its peak accounting), per_box = per-box warp/write fallback taken when regime C has < 2 in-flight units (the only genuine low-RAM mode). `--ram-gb`/`--max-ram` cap this shared-field budget, not process RSS.
28
+ - Turbo: speed pass 3 — regime-A in-RAM shared path + byte-exact parallel writer (issue #39):
29
+ - Regime A keeps the shared full fields (val_full/sup_full) and the quantised DN fields (val_dn/sup_dn) on tmpfs memmaps (reclaimable as clean file cache) instead of anon RAM; `_pull_push_descent` gained optional `out_val/out_sup` and writes the final descent level straight into the shared fields (bit-exact in both the threaded and serial branches).
30
+ - Byte-exact parallel tile writer: full-thread-id TID scratch paths (low-16-bit id collisions truncated a live scratch file mid-run), tile frames reassembled into global 512 raster-scan (block-major assembly was wrong), reference head written as one 512^2 tile. sha256-identical to the committed serial anchors.
31
+ - Reproject: the source band is wrapped once in a no-copy `MemoryDataset` — rasterio's ndarray source form copied the whole band into a fresh MEM dataset per warp call (3 GB per 2048^2 block at full-AU; 8 in-flight copies OOM-killed the run).
32
+ - `_quantize` rewritten in-place (same IEEE ops/order, bit-identical) with per-thread f32+f64 scratch; the int16 outputs are owned per call (thread-local scratch raced the `ex.map` prefetch and corrupted ~10% of blocks).
33
+ - Fixed the support blend reusing the value `om` buffer across blends (computed `(1-a)*pv*ps` instead of `(1-a)*ps`). User-visible: `support_km.tif` for multi-level grids may differ vs 0.2.2 (now correct); `value.tif` unchanged. `MADV_NOHUGEPAGE` on large fresh arrays (THP=always with sync defrag made first-touch faults 20-40 us/page).
34
+ - Turbo: pipeline read-side MT (issue #32) behind a new `Pipeline(n_threads=...)` kwarg (default `1`: the serial code path, byte-identical output):
35
+ - `_reproject_band`: the 2048px warp tiles of each row band run on a `ThreadPoolExecutor` (the GDAL warp runs under `nogil`; each tile opens its own source handle, GDAL dataset handles are not thread-safe). The 512px flush loop stays serial raster-scan, so the on-disk bytes never change (sha256-equal to the serial run is test-covered).
36
+ - `ingest`: CSVs with enough rows (>= 100k per chunk) are split into complete-record byte ranges (newline scan with quote-parity guards; any doubt falls back to the serial parse) and parsed in a `ProcessPoolExecutor` (`read_csv` holds the GIL, so threads cannot overlap the parse). Each chunk parses the wanted columns with explicit float64 dtypes and asserts per-chunk dtype equality; chunks concatenate in original row order (`bin_points` is order-sensitive). A row-count mismatch re-runs the serial parse.
37
+ - New `tests/test_turbo.py` (6 tests): MT reproject sha256-equality vs serial, MT ingest bit-identity vs the single whole-file parse on a 500k-row edge-row fixture (2**53 +/- 1, empty/inf rows, int-only column, quoted fields, non-adjacent columns), serial-parse fallbacks (small file, quoted embedded newline), end-to-end full-run sha256 equality `n_threads=1` vs `4`, and `n_threads` validation.
38
+ - Ignores rasterio's benign `NotGeoreferencedWarning` (MemoryDataset initial transform read): CPython warning filters are process-global and not thread-safe, so the library's nested suppression can leak under concurrent warps.
39
+ - Bench harnesses: `bench/turbo_bench.py` (full paths), `bench/turbo_decomp.py` (parallelisable part in isolation).
40
+
41
+ ## [0.2.2] - 2026-09-25
42
+ - Code hygiene (no behavior change; output rasters remain byte-identical) — issues #4/#5:
43
+ - Renamed `ppgrid/idwgrid.py` -> `ppgrid/pipeline.py`; `ppgrid/idwgrid` is now a thin backwards-compat shim, `ppgrid.Pipeline` is exported from the package root, and the `ppgrid` console script points at `ppgrid.pipeline:main`.
44
+ - Extracted the nested `_reproject_band` closure to a module-level function.
45
+ - Magic numbers in `pipeline.py`, `calibrate.py`, `pullpush.py` promoted to named constants (CRS defaults, int16 support-band encode/decode pair, tile size, memmap band indices, CLI defaults, float32 exact-integer limit, count epsilon, unresolved-support sentinel).
46
+ - Worker context: `_CTX` now holds the `_WorkerConfig` dataclass object (no per-field dict copy); adding a field is one edit.
47
+ - Deduplicated the 3x3-block neighbourhood / window / snapped-halo-box logic into shared helpers used by the per-block, shared-field, and test paths.
48
+ - Dropped the unused 4th point-memmap band.
49
+ - CLI: `--out` now defaults to `out/` (was `examples/`, which wrote build artifacts into the docs directory); argparse validation errors tested per flag.
50
+ - `ruff`: removed the `global-statement` ignore (no global statements left); COM812 remains ignored (pre-existing, conflicts with the formatter).
51
+ - Performance (issue #10): vectorized the output-band reproject warp; output byte-identical.
52
+ - Performance/correctness: shared full-field pyramid descent + fast box-count, calibrate hoisted out of the block loop; fixed `_CTX` not being cleared between runs; capped the shared path at 2.5e8 cells; stopped memmap descent at level `min(2, levels)` for levels=1 grids.
53
+ - Release/docs pass (issues #17/#19/#20/#22): slimmed the sdist (generated example rasters/images and the 8.3 MB `data/all_equakes.csv` no longer ship; ~87 MB -> <1 MB, `data/melb_houses.csv` still ships), added the ruff check + format gate to CI, documented the Python 3.11+ requirement in the README install section, fixed stale docs (test count, module names, closed PR ref), removed stray `examples/melb/value.tif.aux.xml`, untracked `.vscode/settings.json`, added `out/` to `.gitignore`.
54
+
55
+ ## [0.2.1] - 2026-09-22
56
+ - Added `--percentile-step` CLI flag: rounds output percentiles to the nearest step (e.g. `5` -> 90/95/100), clamped to 0-100. Recorded as a `percentile_step` tag on the value GeoTIFF.
57
+ - Fixed `percentile_step` guard: `if pct_step:` -> `if pct_step is not None:` so a falsy step is handled correctly and the validation is consistent with the docstring.
58
+ - README: added hero image (multi-resolution output), replaced the single wall-time-vs-resolution benchmark with a head-to-head vs `gdal_grid`/`gdal_rasterize` (100K points) and a wall-time-vs-point-count scaling chart (generated with `charted`), plus side-by-side comparison images. Fixed the source-clone URL (`pullpush` -> `ppgrid`).
59
+
60
+ ## [0.2.0] - 2026-08-08
61
+ - Added `__version__` to package.
62
+ - Moved `pyarrow` to optional `[parquet]` dependency.
63
+ - Added `CHANGELOG.md`.
64
+ - Added `SECURITY.md` and `SUPPORT.md`.
65
+ - Added `CODEOWNERS` and pre-commit config.
66
+ - Added `examples/run_melb.py` reproducible example script.
67
+ - Added CI and PyPI badges to README.
68
+ - Stated benchmark hardware in README.
69
+ - Fixed README project structure and CLI table.
70
+ - Added `python` keyword and Python 3.14 classifier.
71
+ - Fixed emdash in pyproject.toml description.
72
+ - Regenerated `uv.lock`.
73
+
74
+ ## [0.1.4] - 2026-08-08
75
+ - Fixed PyPI project URLs to point to the renamed GitHub repository (`ppgrid`).
76
+ - Added homepage link between PyPI and GitHub.
77
+
78
+ ## [0.1.3] - 2026-08-08
79
+ - Fixed error message tuple (trailing comma in `pullpush.py`).
80
+ - Removed dead `_init_worker` function.
81
+ - Added index validation to `bin_points`.
82
+ - Added 4 acceptance tests (blocked=whole, coverage, georef, validity).
83
+ - Fixed README grammar, install section, structure.
84
+ - Removed `.DS_Store` files from git.
85
+ - Fixed all ruff warnings and errors.
86
+
87
+ ## [0.1.2] - 2026-08-08
88
+ - Re-published after making repository public for README images.
89
+
90
+ ## [0.1.1] - 2026-08-08
91
+ - Fixed README image paths for PyPI rendering.
92
+
93
+ ## [0.1.0] - 2026-08-08
94
+ - Initial release of ppgrid.
95
+ - Fast continent-scale raster interpolation for scattered point data.
96
+ - IDW interpolation with pull-push mipmap pipeline.
97
+ - Windowed reprojection (no OOM on large datasets).
98
+ - CLI with validation and --version flag.
99
+ - Calibrate module for transform selection + cross-validation.
100
+ - 17 tests across 3 test files.
101
+ - Melbourne housing example (13.5k points at 6 resolutions).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ppgrid
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Fast continent-scale raster interpolation for scattered point data using pull-push mipmap algorithms. Turns millions of geolocated points into gapless GeoTIFF in minutes. No GPU required.
5
5
  Project-URL: Homepage, https://github.com/marzukia/ppgrid
6
6
  Project-URL: Repository, https://github.com/marzukia/ppgrid
@@ -49,7 +49,7 @@ A single 100K-point dataset rendered at four resolutions. The adaptive mipmap py
49
49
 
50
50
  You have `N` points with `(longitude, latitude, value)`. You want a raster where every cell within a specified distance of real data carries an interpolated value, and everything else is nodata.
51
51
 
52
- Standard IDW in QGIS or ArcGIS is `O(N*M)` (~14 hours for 16M points). This tool uses pull-push mipmap interpolation to reduce cost to `O(M)`, independent of N.
52
+ Standard IDW in QGIS or ArcGIS is `O(N*M)` (hours to days at 16M points, depending on the IDW implementation and machine). This tool uses pull-push mipmap interpolation to reduce cost to `O(M)`, independent of N.
53
53
 
54
54
  ## How it works
55
55
 
@@ -160,6 +160,25 @@ ppgrid data.csv --value-col premium --res 100 --cap-km 25 --percentile-step 5
160
160
  | `--work-crs` | `6933` | Working CRS for interpolation (equal-area) |
161
161
  | `--out-crs` | `3857` | Output CRS for final GeoTIFF |
162
162
  | `--skip-calibration` | False | Skip calibration, use defaults |
163
+ | `--turbo` | (none) | Turbo mode: multithreaded pipeline sized to a RAM cap. Optional preset `16|32|64|128` (GB); bare `--turbo` = auto `min(physical, cgroup) - 8 GB` |
164
+ | `--max-ram` | (none) | Explicit RAM cap in GB (overrides the `--turbo` preset). Implies `--turbo` |
165
+ | `--ram-gb` | (none) | Alias of `--max-ram` |
166
+ | `--turbo-strict` | False | Shared path infeasible under the budget: error + exit 3 instead of `[warn]` + per-box fallback (exit 0) |
167
+
168
+ ### Turbo mode
169
+
170
+ `--turbo` enables the multithreaded pipeline. The RAM cap `C` (a preset, `--max-ram`, or bare `--turbo` auto-detect) drives a planning budget **`Bt = 0.85 * C - 4 GB`** and a regime, printed by a pre-check after gridding. None of the turbo settings change the output bytes.
171
+
172
+ - **A** — full in-RAM shared fields. Fastest; needs the most RAM.
173
+ - **B** — input in RAM, output bands file-backed with a limited number of descent bands in flight.
174
+ - **C** — shared all-memmap banded descent: input and output bands file-backed (reclaimable memmap), with an 8 GB page-cache floor in its peak accounting.
175
+ - **per_box** — per-box warp/write fallback, taken when regime C has fewer than 2 in-flight units. This is the only genuine low-RAM mode.
176
+
177
+ When the shared path is infeasible under the budget, the run prints `[warn]` and continues on the per-box path (exit 0, correct output). `--turbo-strict` makes the infeasibility an error (exit 3).
178
+
179
+ **`--ram-gb` caps the shared-field budget, not process RSS.** Write/descent buffers sit outside the `Bt` sizing, so a regime C run under `--ram-gb 20` can peak well above 20 GB RSS (measured: 39.4 GB). Only the per-box path is genuinely low-RAM (measured: 8.5 GB peak).
180
+
181
+ **Byte identity.** Turbo output is sha256-identical to the serial path. The optional parallel per-tile ZSTD write is gated by a compression oracle: where the oracle mismatches (e.g. the reference GDAL 3.12.4 / libtiff 6.2 stack), the run uses the stock-codec **parallel** writer instead, which is byte-exact; the serial per-band write is the last resort only, reached if that writer's layout guards fail.
163
182
 
164
183
  ## Decoding the output
165
184
 
@@ -202,6 +221,19 @@ Melbourne Housing dataset (13,580 points) at various resolutions on the same mac
202
221
  | 250m | 0.6s | 159 KB |
203
222
  | 500m | 0.6s | 49 KB |
204
223
 
224
+ ### Turbo benchmarks (full AU)
225
+
226
+ Full-Australia grid, 16M points, `--res 100 --cap-km 25 --skip-calibration`, on marzuki-hydrogen (Threadripper 24C/48T, 125 GB, CPU-only). All outputs sha256-identical across the pre- and post-turbo-code runs.
227
+
228
+ | Mode | Wall | Peak RSS |
229
+ |------|------|----------|
230
+ | Pre-PR turbo code, regime A (`--turbo 64`, same code) | 30:45 (quiet) to 50:46 (loaded) | 41.7 GB |
231
+ | Regime A (`--turbo 64`) | 2:33 | 43 GB |
232
+ | per-box (24 GB cgroup slice) | 3:20 | 8.5 GB |
233
+ | Regime C (`--ram-gb 20`) | 6:25 pre-PR, 3:08 post | 39.4 GB |
234
+
235
+ The pre-PR regime-A wall varies 30:45-50:46 with machine load (two measured runs of the same code on the same box), so the speedup range is ~12-20x rather than a single ratio. Note the `--ram-gb 20` row: the cap bounds the shared-field budget, not RSS — see the caveat above; per-box is the only genuinely low-RAM mode.
236
+
205
237
  ## Example Outputs
206
238
 
207
239
  `ppgrid` against `gdal_grid` + IDW and `gdal_rasterize` on the same data. Left is `ppgrid`, middle is `gdal_grid` with IDW, right is `gdal_rasterize`:
@@ -13,7 +13,7 @@ A single 100K-point dataset rendered at four resolutions. The adaptive mipmap py
13
13
 
14
14
  You have `N` points with `(longitude, latitude, value)`. You want a raster where every cell within a specified distance of real data carries an interpolated value, and everything else is nodata.
15
15
 
16
- Standard IDW in QGIS or ArcGIS is `O(N*M)` (~14 hours for 16M points). This tool uses pull-push mipmap interpolation to reduce cost to `O(M)`, independent of N.
16
+ Standard IDW in QGIS or ArcGIS is `O(N*M)` (hours to days at 16M points, depending on the IDW implementation and machine). This tool uses pull-push mipmap interpolation to reduce cost to `O(M)`, independent of N.
17
17
 
18
18
  ## How it works
19
19
 
@@ -124,6 +124,25 @@ ppgrid data.csv --value-col premium --res 100 --cap-km 25 --percentile-step 5
124
124
  | `--work-crs` | `6933` | Working CRS for interpolation (equal-area) |
125
125
  | `--out-crs` | `3857` | Output CRS for final GeoTIFF |
126
126
  | `--skip-calibration` | False | Skip calibration, use defaults |
127
+ | `--turbo` | (none) | Turbo mode: multithreaded pipeline sized to a RAM cap. Optional preset `16|32|64|128` (GB); bare `--turbo` = auto `min(physical, cgroup) - 8 GB` |
128
+ | `--max-ram` | (none) | Explicit RAM cap in GB (overrides the `--turbo` preset). Implies `--turbo` |
129
+ | `--ram-gb` | (none) | Alias of `--max-ram` |
130
+ | `--turbo-strict` | False | Shared path infeasible under the budget: error + exit 3 instead of `[warn]` + per-box fallback (exit 0) |
131
+
132
+ ### Turbo mode
133
+
134
+ `--turbo` enables the multithreaded pipeline. The RAM cap `C` (a preset, `--max-ram`, or bare `--turbo` auto-detect) drives a planning budget **`Bt = 0.85 * C - 4 GB`** and a regime, printed by a pre-check after gridding. None of the turbo settings change the output bytes.
135
+
136
+ - **A** — full in-RAM shared fields. Fastest; needs the most RAM.
137
+ - **B** — input in RAM, output bands file-backed with a limited number of descent bands in flight.
138
+ - **C** — shared all-memmap banded descent: input and output bands file-backed (reclaimable memmap), with an 8 GB page-cache floor in its peak accounting.
139
+ - **per_box** — per-box warp/write fallback, taken when regime C has fewer than 2 in-flight units. This is the only genuine low-RAM mode.
140
+
141
+ When the shared path is infeasible under the budget, the run prints `[warn]` and continues on the per-box path (exit 0, correct output). `--turbo-strict` makes the infeasibility an error (exit 3).
142
+
143
+ **`--ram-gb` caps the shared-field budget, not process RSS.** Write/descent buffers sit outside the `Bt` sizing, so a regime C run under `--ram-gb 20` can peak well above 20 GB RSS (measured: 39.4 GB). Only the per-box path is genuinely low-RAM (measured: 8.5 GB peak).
144
+
145
+ **Byte identity.** Turbo output is sha256-identical to the serial path. The optional parallel per-tile ZSTD write is gated by a compression oracle: where the oracle mismatches (e.g. the reference GDAL 3.12.4 / libtiff 6.2 stack), the run uses the stock-codec **parallel** writer instead, which is byte-exact; the serial per-band write is the last resort only, reached if that writer's layout guards fail.
127
146
 
128
147
  ## Decoding the output
129
148
 
@@ -166,6 +185,19 @@ Melbourne Housing dataset (13,580 points) at various resolutions on the same mac
166
185
  | 250m | 0.6s | 159 KB |
167
186
  | 500m | 0.6s | 49 KB |
168
187
 
188
+ ### Turbo benchmarks (full AU)
189
+
190
+ Full-Australia grid, 16M points, `--res 100 --cap-km 25 --skip-calibration`, on marzuki-hydrogen (Threadripper 24C/48T, 125 GB, CPU-only). All outputs sha256-identical across the pre- and post-turbo-code runs.
191
+
192
+ | Mode | Wall | Peak RSS |
193
+ |------|------|----------|
194
+ | Pre-PR turbo code, regime A (`--turbo 64`, same code) | 30:45 (quiet) to 50:46 (loaded) | 41.7 GB |
195
+ | Regime A (`--turbo 64`) | 2:33 | 43 GB |
196
+ | per-box (24 GB cgroup slice) | 3:20 | 8.5 GB |
197
+ | Regime C (`--ram-gb 20`) | 6:25 pre-PR, 3:08 post | 39.4 GB |
198
+
199
+ The pre-PR regime-A wall varies 30:45-50:46 with machine load (two measured runs of the same code on the same box), so the speedup range is ~12-20x rather than a single ratio. Note the `--ram-gb 20` row: the cap bounds the shared-field budget, not RSS — see the caveat above; per-box is the only genuinely low-RAM mode.
200
+
169
201
  ## Example Outputs
170
202
 
171
203
  `ppgrid` against `gdal_grid` + IDW and `gdal_rasterize` on the same data. Left is `ppgrid`, middle is `gdal_grid` with IDW, right is `gdal_rasterize`:
@@ -0,0 +1,69 @@
1
+ """Turbo read-side MT (issue #32): speed evidence for reproject warp + ingest.
2
+
3
+ Repro: uv run python -m bench.turbo_bench
4
+ """
5
+
6
+ import tempfile
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ import rasterio
13
+ from rasterio.warp import calculate_default_transform
14
+
15
+ from ppgrid.pipeline import NODATA, TILE_PX, Pipeline, _reproject_band
16
+ from tests.test_turbo import _write_src_tif
17
+
18
+ TMP = Path(tempfile.mkdtemp(prefix="turbo_bench"))
19
+
20
+
21
+ def median(f, runs=3):
22
+ ts = []
23
+ for _ in range(runs):
24
+ t0 = time.perf_counter()
25
+ f()
26
+ ts.append(time.perf_counter() - t0)
27
+ return float(np.median(ts))
28
+
29
+
30
+ def bench_reproject():
31
+ src = TMP / "src.tif"
32
+ _write_src_tif(src, 5000, 4000)
33
+ with rasterio.open(src) as ds:
34
+ t, w, h = calculate_default_transform(ds.crs, "EPSG:3857", ds.width, ds.height, *ds.bounds)
35
+ profile = {
36
+ "driver": "GTiff",
37
+ "dtype": "int16",
38
+ "nodata": NODATA,
39
+ "compress": "ZSTD",
40
+ "tiled": True,
41
+ "blockxsize": TILE_PX,
42
+ "blockysize": TILE_PX,
43
+ "predictor": 2,
44
+ "count": 1,
45
+ }
46
+ out = TMP / "out.tif"
47
+ t1 = median(lambda: _reproject_band(str(src), str(out), profile, "EPSG:3857", t, w, h))
48
+ t4 = median(lambda: _reproject_band(str(src), str(out), profile, "EPSG:3857", t, w, h, n_threads=4))
49
+ print(f"reproject band {w}x{h}: serial {t1:.3f}s, 4t {t4:.3f}s, speedup {t1 / t4:.2f}x")
50
+
51
+
52
+ def bench_ingest(n_rows=1_000_000):
53
+ rng = np.random.default_rng(0)
54
+ csv = TMP / "big.csv"
55
+ pd.DataFrame(
56
+ {
57
+ "value": rng.normal(0.0, 100.0, n_rows),
58
+ "longitude": rng.uniform(110.0, 155.0, n_rows),
59
+ "latitude": rng.uniform(-50.0, -10.0, n_rows),
60
+ },
61
+ ).to_csv(csv, index=False)
62
+ t1 = median(lambda: Pipeline(str(csv), "value", "longitude", "latitude", str(TMP / "o1"), n_threads=1).ingest())
63
+ t4 = median(lambda: Pipeline(str(csv), "value", "longitude", "latitude", str(TMP / "o4"), n_threads=4).ingest())
64
+ print(f"ingest {n_rows} rows: serial {t1:.3f}s, 4t {t4:.3f}s, speedup {t1 / t4:.2f}x")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ bench_reproject()
69
+ bench_ingest()