ppgrid 0.2.2__tar.gz → 0.3.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.
- ppgrid-0.3.0/.github/workflows/ci.yml +22 -0
- ppgrid-0.3.0/AGENTS.md +143 -0
- ppgrid-0.3.0/CHANGELOG.md +91 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/PKG-INFO +1 -1
- ppgrid-0.3.0/bench/turbo_bench.py +69 -0
- ppgrid-0.3.0/bench/turbo_decomp.py +96 -0
- ppgrid-0.3.0/docs/turbo-design.md +1168 -0
- ppgrid-0.3.0/ppgrid/_prof.py +75 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/ppgrid/calibrate.py +31 -2
- ppgrid-0.3.0/ppgrid/pipeline.py +2703 -0
- ppgrid-0.3.0/ppgrid/pullpush.py +1000 -0
- ppgrid-0.3.0/ppgrid/turbop.py +870 -0
- ppgrid-0.3.0/ppgrid/zstdmt.py +592 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/pyproject.toml +12 -1
- {ppgrid-0.2.2 → ppgrid-0.3.0}/tests/test_build.py +1 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/tests/test_calibrate.py +44 -0
- ppgrid-0.3.0/tests/test_pipeline.py +883 -0
- ppgrid-0.3.0/tests/test_pullpush_mt.py +371 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/tests/test_tier2_shared.py +1 -1
- ppgrid-0.3.0/tests/test_turbo.py +877 -0
- ppgrid-0.3.0/tests/test_turbop.py +513 -0
- ppgrid-0.3.0/tests/test_zstdmt.py +357 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/uv.lock +1 -1
- ppgrid-0.2.2/.github/workflows/ci.yml +0 -18
- ppgrid-0.2.2/AGENTS.md +0 -94
- ppgrid-0.2.2/CHANGELOG.md +0 -65
- ppgrid-0.2.2/ppgrid/pipeline.py +0 -1077
- ppgrid-0.2.2/ppgrid/pullpush.py +0 -443
- ppgrid-0.2.2/tests/test_pipeline.py +0 -423
- {ppgrid-0.2.2 → ppgrid-0.3.0}/.github/CODEOWNERS +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/.github/workflows/publish.yml +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/.gitignore +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/.pre-commit-config.yaml +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/LICENSE +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/README.md +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/SECURITY.md +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/SUPPORT.md +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/bench/bench_tier2.py +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/bench/compare_tier2.py +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/data/melb_houses.csv +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/examples/run_melb.py +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/ppgrid/__init__.py +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/ppgrid/__main__.py +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/ppgrid/idwgrid.py +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/tests/__init__.py +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/tests/test_acceptance.py +0 -0
- {ppgrid-0.2.2 → ppgrid-0.3.0}/tests/test_ppgrid.py +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
# Runner scoping (2026-10-06, per Frank/jarate pattern): PRs run on
|
|
12
|
+
# GitHub-hosted runners; pushes to main run on the self-hosted 'nf'
|
|
13
|
+
# runners (2x, label 'nf', marzukia org). Inline expression — the
|
|
14
|
+
# env context is NOT available in runs-on (GitHub parse error).
|
|
15
|
+
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'nf' }}
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: astral-sh/setup-uv@v4
|
|
19
|
+
- run: uv sync --all-extras
|
|
20
|
+
- run: uv run --extra dev ruff check .
|
|
21
|
+
- run: uv run --extra dev ruff format --check .
|
|
22
|
+
- run: uv run pytest
|
ppgrid-0.3.0/AGENTS.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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` (93 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-07, PR #39 review fixes: M-1..M-3, Y-1)
|
|
85
|
+
|
|
86
|
+
- M-1: equakes anchors regenerated under the rasterio 1.5.1 lock (the committed
|
|
87
|
+
files were cut on 1.5.0; the vendored GDAL emits different BigTIFF ZSTD tile
|
|
88
|
+
bytes — arrays equal, tile offsets moved). New shas in the A.6 table:
|
|
89
|
+
`examples/equakes/value.tif` `99c8a01c…`, `support_km.tif` `5e545742…`.
|
|
90
|
+
`bench/compare_tier2.py` re-run: bit-identical (melb anchors unchanged).
|
|
91
|
+
- M-2: `turbop.resolve_cap` clamps a preset against `min(physical, cgroup)`
|
|
92
|
+
(a capped slice OOMs at its own limit); the pipeline wires both reads via
|
|
93
|
+
`Pipeline._resolve_turbo_cap` (before: preset 32 on a 25.8 GB slice planned
|
|
94
|
+
32 GB; now clamps to 17.8 GB).
|
|
95
|
+
- M-3: `turbop.per_box_peak_bytes` (64 B/cell bottom-up + 2 GB fixed, x
|
|
96
|
+
in-flight blocks + points/DN extra) + `precheck(per_box_peak_bytes=…)`;
|
|
97
|
+
the pipeline computes it from the worst snapped box after `grid()`.
|
|
98
|
+
Non-strict per-box over budget now warns "best-effort, OOM risk, output may
|
|
99
|
+
be partial" instead of promising exit-0 correct output; strict names the
|
|
100
|
+
per-box infeasibility. Verified: melb @1m --max-ram 8 warns 189.7 GB est >
|
|
101
|
+
6.8 GB budget, then dies 137.
|
|
102
|
+
- Y-1: BigTIFF slot widths in the parallel ZSTD write. `_tif_parse_ifd` sizes
|
|
103
|
+
tags per TIFF type (type 16 LONG8 added; unknown type = hard error) and
|
|
104
|
+
returns `sz324/sz325/fmt324/fmt325`; `_turbo_write_parallel` patches 324
|
|
105
|
+
(8-byte slots) and 325 (4-byte slots) at their real strides, shared
|
|
106
|
+
`_TIFF_TYPE_SIZES` with the overlap guard. Pre-fix, one 8-byte stride
|
|
107
|
+
corrupted both arrays on every real BigTIFF output (7888 tiles: 325 sizes
|
|
108
|
+
read `2000, 0, 2001, 0, …`). Pinned: BigTIFF parallel byte-identity test +
|
|
109
|
+
IFD parse value asserts.
|
|
110
|
+
- Tests: 170 -> 176 (resolve_cap cgroup clamp, per-box peak model + gate,
|
|
111
|
+
pipeline wiring, BigTIFF parallel, IFD value pins).
|
|
112
|
+
|
|
113
|
+
## What changed recently (2026-10-07, turbo write path + CLI, issue #33)
|
|
114
|
+
|
|
115
|
+
- 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).
|
|
116
|
+
- `_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.
|
|
117
|
+
- 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) -> serial GDAL write, byte-exact.
|
|
118
|
+
- 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.
|
|
119
|
+
- `_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).
|
|
120
|
+
- `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.
|
|
121
|
+
- `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).
|
|
122
|
+
- 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).
|
|
123
|
+
|
|
124
|
+
## What changed recently (2026-10-06, turbo read-side MT, 0.3.0)
|
|
125
|
+
|
|
126
|
+
- New `Pipeline(n_threads=...)` kwarg (default `1`, serial path unchanged, byte-identical output). NOT a CLI flag.
|
|
127
|
+
- `_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.
|
|
128
|
+
- `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.
|
|
129
|
+
- 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.
|
|
130
|
+
- Tests: `tests/test_turbo.py` (6, sha256/bit-identity + fallbacks). Bench: `bench/turbo_bench.py`, `bench/turbo_decomp.py`.
|
|
131
|
+
- Design doc in-repo: `docs/turbo-design.md` (accepted criteria A4-A8).
|
|
132
|
+
|
|
133
|
+
## What changed recently (2026-09-22, the "polish" pass)
|
|
134
|
+
|
|
135
|
+
- Bumped to **0.2.1**.
|
|
136
|
+
- `--percentile-step` flag (round output percentiles to a step, e.g. 5 → 90/95/100)
|
|
137
|
+
+ the `is not None` guard fix.
|
|
138
|
+
- README: added hero image (`examples/melb_4panel.jpg`), replaced the old single
|
|
139
|
+
wall-time-vs-resolution chart with two `charted` benchmark charts (head-to-head vs
|
|
140
|
+
gdal at 100K pts + wall-time-vs-point-count scaling), added side-by-side/zoom
|
|
141
|
+
comparison images, fixed the clone URL (`pullpush` → `ppgrid`). Removed stale
|
|
142
|
+
`bench_combined/time/size.png`.
|
|
143
|
+
- All README image refs converted to absolute raw.githubusercontent URLs for PyPI.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.3.0] - 2026-10-08
|
|
8
|
+
- Full-AU (16M points, `--res 100`): 50:46 -> 2:33 wall time with sha256-identical outputs (marzuki-hydrogen, `--turbo 64`, regime A).
|
|
9
|
+
- Turbo: write path + budget cap + CLI (issue #33):
|
|
10
|
+
- `--turbo` (optional preset `16|32|64|128`; bare = auto `min(physical, cgroup) - 8 GB`), `--max-ram <GB>` (implies turbo), `--ram-gb` alias, `--turbo-strict`.
|
|
11
|
+
- 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).
|
|
12
|
+
- `_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 -> serial GDAL write, byte-exact). In-RAM DN is budget-gated; oversized grids fall back to the file-based reproject path with MT warp.
|
|
13
|
+
- 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.
|
|
14
|
+
- `_prepare_shared` / `box_count_banded` run threaded under the budgeted worker count when turbo (serial at `n_threads == 1`, A8 preserved).
|
|
15
|
+
- 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).
|
|
16
|
+
- Regenerated stale anchor `examples/melb/10m/value.tif` (byte-identical to current `main` output; the committed file predated a pipeline change).
|
|
17
|
+
- 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 = per-box warp/write with an 8 GB page-cache floor, per_box = below the 8 GB cap floor.
|
|
18
|
+
- Turbo: speed pass 3 — regime-A in-RAM shared path + byte-exact parallel writer (issue #39):
|
|
19
|
+
- 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).
|
|
20
|
+
- 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.
|
|
21
|
+
- 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).
|
|
22
|
+
- `_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).
|
|
23
|
+
- Fixed the support blend reusing the value `om` buffer across blends; `MADV_NOHUGEPAGE` on large fresh arrays (THP=always with sync defrag made first-touch faults 20-40 us/page).
|
|
24
|
+
- Turbo: pipeline read-side MT (issue #32) behind a new `Pipeline(n_threads=...)` kwarg (default `1`: the serial code path, byte-identical output):
|
|
25
|
+
- `_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).
|
|
26
|
+
- `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.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- Bench harnesses: `bench/turbo_bench.py` (full paths), `bench/turbo_decomp.py` (parallelisable part in isolation).
|
|
30
|
+
|
|
31
|
+
## [0.2.2] - 2026-09-25
|
|
32
|
+
- Code hygiene (no behavior change; output rasters remain byte-identical) — issues #4/#5:
|
|
33
|
+
- 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`.
|
|
34
|
+
- Extracted the nested `_reproject_band` closure to a module-level function.
|
|
35
|
+
- 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).
|
|
36
|
+
- Worker context: `_CTX` now holds the `_WorkerConfig` dataclass object (no per-field dict copy); adding a field is one edit.
|
|
37
|
+
- Deduplicated the 3x3-block neighbourhood / window / snapped-halo-box logic into shared helpers used by the per-block, shared-field, and test paths.
|
|
38
|
+
- Dropped the unused 4th point-memmap band.
|
|
39
|
+
- CLI: `--out` now defaults to `out/` (was `examples/`, which wrote build artifacts into the docs directory); argparse validation errors tested per flag.
|
|
40
|
+
- `ruff`: removed the `global-statement` ignore (no global statements left); COM812 remains ignored (pre-existing, conflicts with the formatter).
|
|
41
|
+
- Performance (issue #10): vectorized the output-band reproject warp; output byte-identical.
|
|
42
|
+
- 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.
|
|
43
|
+
- 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`.
|
|
44
|
+
|
|
45
|
+
## [0.2.1] - 2026-09-22
|
|
46
|
+
- 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.
|
|
47
|
+
- 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.
|
|
48
|
+
- 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`).
|
|
49
|
+
|
|
50
|
+
## [0.2.0] - 2026-08-08
|
|
51
|
+
- Added `__version__` to package.
|
|
52
|
+
- Moved `pyarrow` to optional `[parquet]` dependency.
|
|
53
|
+
- Added `CHANGELOG.md`.
|
|
54
|
+
- Added `SECURITY.md` and `SUPPORT.md`.
|
|
55
|
+
- Added `CODEOWNERS` and pre-commit config.
|
|
56
|
+
- Added `examples/run_melb.py` reproducible example script.
|
|
57
|
+
- Added CI and PyPI badges to README.
|
|
58
|
+
- Stated benchmark hardware in README.
|
|
59
|
+
- Fixed README project structure and CLI table.
|
|
60
|
+
- Added `python` keyword and Python 3.14 classifier.
|
|
61
|
+
- Fixed emdash in pyproject.toml description.
|
|
62
|
+
- Regenerated `uv.lock`.
|
|
63
|
+
|
|
64
|
+
## [0.1.4] - 2026-08-08
|
|
65
|
+
- Fixed PyPI project URLs to point to the renamed GitHub repository (`ppgrid`).
|
|
66
|
+
- Added homepage link between PyPI and GitHub.
|
|
67
|
+
|
|
68
|
+
## [0.1.3] - 2026-08-08
|
|
69
|
+
- Fixed error message tuple (trailing comma in `pullpush.py`).
|
|
70
|
+
- Removed dead `_init_worker` function.
|
|
71
|
+
- Added index validation to `bin_points`.
|
|
72
|
+
- Added 4 acceptance tests (blocked=whole, coverage, georef, validity).
|
|
73
|
+
- Fixed README grammar, install section, structure.
|
|
74
|
+
- Removed `.DS_Store` files from git.
|
|
75
|
+
- Fixed all ruff warnings and errors.
|
|
76
|
+
|
|
77
|
+
## [0.1.2] - 2026-08-08
|
|
78
|
+
- Re-published after making repository public for README images.
|
|
79
|
+
|
|
80
|
+
## [0.1.1] - 2026-08-08
|
|
81
|
+
- Fixed README image paths for PyPI rendering.
|
|
82
|
+
|
|
83
|
+
## [0.1.0] - 2026-08-08
|
|
84
|
+
- Initial release of ppgrid.
|
|
85
|
+
- Fast continent-scale raster interpolation for scattered point data.
|
|
86
|
+
- IDW interpolation with pull-push mipmap pipeline.
|
|
87
|
+
- Windowed reprojection (no OOM on large datasets).
|
|
88
|
+
- CLI with validation and --version flag.
|
|
89
|
+
- Calibrate module for transform selection + cross-validation.
|
|
90
|
+
- 17 tests across 3 test files.
|
|
91
|
+
- 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.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -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()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Decompose turbo speedup: warp-only (GIL release) + parse-only (pool).
|
|
2
|
+
|
|
3
|
+
The full-path numbers in turbo_bench.py include the serial tail (the 512px
|
|
4
|
+
flush loop, pyproj transform) which Amdahl-caps the gain. This harness
|
|
5
|
+
measures the parallelisable part in isolation.
|
|
6
|
+
|
|
7
|
+
Repro: uv run python -m bench.turbo_decomp
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
|
13
|
+
from functools import partial
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import rasterio
|
|
19
|
+
from rasterio.warp import calculate_default_transform
|
|
20
|
+
|
|
21
|
+
from ppgrid import pipeline as pm
|
|
22
|
+
from tests.test_turbo import _write_src_tif
|
|
23
|
+
|
|
24
|
+
TMP = Path(tempfile.mkdtemp(prefix="turbo_bench2"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def median(f, runs=3):
|
|
28
|
+
ts = []
|
|
29
|
+
for _ in range(runs):
|
|
30
|
+
t0 = time.perf_counter()
|
|
31
|
+
f()
|
|
32
|
+
ts.append(time.perf_counter() - t0)
|
|
33
|
+
return float(np.median(ts))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def warp_only():
|
|
37
|
+
src = TMP / "src.tif"
|
|
38
|
+
_write_src_tif(src, 5000, 4000)
|
|
39
|
+
with rasterio.open(src) as ds:
|
|
40
|
+
t, w, h = calculate_default_transform(ds.crs, "EPSG:3857", ds.width, ds.height, *ds.bounds)
|
|
41
|
+
|
|
42
|
+
def warp_all(n_threads):
|
|
43
|
+
warp_tile = 2048
|
|
44
|
+
for j0 in range(0, h, warp_tile):
|
|
45
|
+
band_h = min(warp_tile, h - j0)
|
|
46
|
+
i0s = list(range(0, w, warp_tile))
|
|
47
|
+
p = partial(
|
|
48
|
+
pm._warp_dst_tile,
|
|
49
|
+
str(src),
|
|
50
|
+
j0=j0,
|
|
51
|
+
band_h=band_h,
|
|
52
|
+
warp_tile=warp_tile,
|
|
53
|
+
dst_width=w,
|
|
54
|
+
dst_transform=t,
|
|
55
|
+
dst_crs="EPSG:3857",
|
|
56
|
+
)
|
|
57
|
+
if n_threads > 1:
|
|
58
|
+
with ThreadPoolExecutor(max_workers=n_threads) as ex:
|
|
59
|
+
for _ in ex.map(p, i0s):
|
|
60
|
+
pass
|
|
61
|
+
else:
|
|
62
|
+
for i0 in i0s:
|
|
63
|
+
p(i0)
|
|
64
|
+
|
|
65
|
+
t1 = median(lambda: warp_all(1))
|
|
66
|
+
t4 = median(lambda: warp_all(4))
|
|
67
|
+
print(f"warp-only {w}x{h}: serial {t1:.3f}s, 4t {t4:.3f}s, speedup {t1 / t4:.2f}x")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_only(n_rows=1_000_000):
|
|
71
|
+
rng = np.random.default_rng(0)
|
|
72
|
+
csv = TMP / "big.csv"
|
|
73
|
+
pd.DataFrame(
|
|
74
|
+
{
|
|
75
|
+
"value": rng.normal(0.0, 100.0, n_rows),
|
|
76
|
+
"longitude": rng.uniform(110.0, 155.0, n_rows),
|
|
77
|
+
"latitude": rng.uniform(-50.0, -10.0, n_rows),
|
|
78
|
+
},
|
|
79
|
+
).to_csv(csv, index=False)
|
|
80
|
+
t1 = median(lambda: pd.read_csv(csv))
|
|
81
|
+
|
|
82
|
+
wanted = ["value", "longitude", "latitude"]
|
|
83
|
+
tasks, _ = pm._csv_chunk_tasks(str(csv), wanted, 4)
|
|
84
|
+
|
|
85
|
+
def p4():
|
|
86
|
+
with ProcessPoolExecutor(max_workers=4) as ex:
|
|
87
|
+
chunks = list(ex.map(pm._read_csv_chunk, tasks))
|
|
88
|
+
return np.concatenate([c[0] for c in chunks])
|
|
89
|
+
|
|
90
|
+
t4 = median(p4)
|
|
91
|
+
print(f"parse-only {n_rows} rows: serial {t1:.3f}s, 4t {t4:.3f}s, speedup {t1 / t4:.2f}x")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
warp_only()
|
|
96
|
+
parse_only()
|