skygrad 1.0.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 (41) hide show
  1. skygrad-1.0.0/.github/workflows/ci.yml +37 -0
  2. skygrad-1.0.0/.github/workflows/publish.yml +29 -0
  3. skygrad-1.0.0/.gitignore +13 -0
  4. skygrad-1.0.0/.python-version +1 -0
  5. skygrad-1.0.0/ARCHITECTURE.md +64 -0
  6. skygrad-1.0.0/LICENSE +21 -0
  7. skygrad-1.0.0/PKG-INFO +117 -0
  8. skygrad-1.0.0/README.md +92 -0
  9. skygrad-1.0.0/THEORY.md +374 -0
  10. skygrad-1.0.0/assets/contact-sheet-facing.png +0 -0
  11. skygrad-1.0.0/assets/contact-sheet.png +0 -0
  12. skygrad-1.0.0/pyproject.toml +60 -0
  13. skygrad-1.0.0/scripts/contact_sheet.py +58 -0
  14. skygrad-1.0.0/src/skygrad/__init__.py +14 -0
  15. skygrad-1.0.0/src/skygrad/astronomy.py +126 -0
  16. skygrad-1.0.0/src/skygrad/color.py +82 -0
  17. skygrad-1.0.0/src/skygrad/gradient.py +295 -0
  18. skygrad-1.0.0/src/skygrad/palette.py +70 -0
  19. skygrad-1.0.0/src/skygrad/png.py +105 -0
  20. skygrad-1.0.0/src/skygrad/py.typed +0 -0
  21. skygrad-1.0.0/tests/__init__.py +0 -0
  22. skygrad-1.0.0/tests/conftest.py +28 -0
  23. skygrad-1.0.0/tests/golden/civil_dusk.png +0 -0
  24. skygrad-1.0.0/tests/golden/deep_night.png +0 -0
  25. skygrad-1.0.0/tests/golden/equator_noon.png +0 -0
  26. skygrad-1.0.0/tests/golden/glow_dusk_away.png +0 -0
  27. skygrad-1.0.0/tests/golden/glow_dusk_toward.png +0 -0
  28. skygrad-1.0.0/tests/golden/glow_golden_offset.png +0 -0
  29. skygrad-1.0.0/tests/golden/glow_panorama.png +0 -0
  30. skygrad-1.0.0/tests/golden/golden_hour.png +0 -0
  31. skygrad-1.0.0/tests/golden/midnight_sun.png +0 -0
  32. skygrad-1.0.0/tests/golden/southern_summer.png +0 -0
  33. skygrad-1.0.0/tests/test_astronomy.py +129 -0
  34. skygrad-1.0.0/tests/test_color.py +65 -0
  35. skygrad-1.0.0/tests/test_facing.py +179 -0
  36. skygrad-1.0.0/tests/test_golden.py +97 -0
  37. skygrad-1.0.0/tests/test_gradient.py +118 -0
  38. skygrad-1.0.0/tests/test_palette.py +70 -0
  39. skygrad-1.0.0/tests/test_png.py +124 -0
  40. skygrad-1.0.0/tests/test_properties.py +63 -0
  41. skygrad-1.0.0/uv.lock +417 -0
@@ -0,0 +1,37 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ concurrency:
11
+ group: ci-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ jobs:
15
+ checks:
16
+ runs-on: ubuntu-latest
17
+ timeout-minutes: 10
18
+ strategy:
19
+ fail-fast: false
20
+ # Full supported range (requires-python >=3.11). Goldens are compared
21
+ # byte-exact on every leg: on one ubuntu image the four CPythons share a
22
+ # libm, so identical pixels across versions is the determinism contract
23
+ # holding, and a divergence is a real signal, not flake. mypy runs under
24
+ # each interpreter, so it type-checks against each version too.
25
+ matrix:
26
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: astral-sh/setup-uv@v5
30
+ with:
31
+ python-version: ${{ matrix.python-version }}
32
+ enable-cache: true
33
+ - run: uv sync --locked
34
+ - run: uv run ruff check .
35
+ - run: uv run ruff format --check .
36
+ - run: uv run mypy
37
+ - run: uv run pytest -q
@@ -0,0 +1,29 @@
1
+ name: Publish to PyPI
2
+
3
+ # Trusted Publishing (OIDC): no stored API token. PyPI verifies this repo +
4
+ # workflow via a short-lived OIDC token and issues a short-lived upload
5
+ # credential; PEP 740 attestations are generated automatically. Triggered by
6
+ # publishing a GitHub Release — that human action is the deliberate gate.
7
+ on:
8
+ release:
9
+ types: [published]
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ publish:
16
+ runs-on: ubuntu-latest
17
+ environment:
18
+ name: pypi
19
+ url: https://pypi.org/p/skygrad
20
+ permissions:
21
+ id-token: write # REQUIRED: lets the job mint the OIDC token
22
+ contents: read # checkout
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: astral-sh/setup-uv@v5
26
+ with:
27
+ python-version: "3.13"
28
+ - run: uv build # sdist + wheel into dist/
29
+ - uses: pypa/gh-action-pypi-publish@release/v1 # uploads dist/, no token
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[oc]
3
+ .venv/
4
+ .pytest_cache/
5
+ .hypothesis/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ dist/
9
+ # Design scaffolding and generated review reports: kept local, not shipped.
10
+ docs/
11
+ .DS_Store
12
+ *.egg-info
13
+ !uv.lock
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,64 @@
1
+ # skygrad architecture
2
+
3
+ A pure, one-way pipeline that turns *(lat, lon, datetime)* into a PNG of the
4
+ sky. The single most important fact about its shape: **output is deterministic
5
+ to the decoded pixel for a fixed `MODEL_VERSION`, with no RNG anywhere** —
6
+ nearly every rule below exists to protect that.
7
+
8
+ ## Component map
9
+
10
+ Dependency arrows point strictly downward: a module may import those above it
11
+ in this table, never below. `__init__` imports from all.
12
+
13
+ | Component | Responsibility | Entry points |
14
+ |---|---|---|
15
+ | `color.py` | sRGB ↔ linear ↔ Oklab conversions + interpolation (stdlib only) | `hex_to_oklab`, `oklab_to_srgb`, `lerp3`, `Triple` |
16
+ | `astronomy.py` | `(lat, lon, when_utc)` → solar `(elevation, azimuth)`; the only place naive datetimes are interpreted (stdlib only) | `to_utc`, `solar_position`, `solar_elevation` |
17
+ | `palette.py` | The art console: `ANCHORS` (per-elevation stops) + `GLOW` tables + `MODEL_VERSION` (imports `color`) | `ANCHORS`, `GLOW`, `U_OFFSETS`, `MODEL_VERSION` |
18
+ | `gradient.py` | `Sky`, the validation boundary, elevation→Oklab blending, row/pixel evaluation (imports all) | `Sky`, `Sky.at`, `render`, `write_png` |
19
+ | `png.py` | Bayer dithering + minimal deterministic PNG encoder, flat + leveled (stdlib only) | `encode_rows`, `encode_leveled_rows` |
20
+ | `__init__.py` | Public surface | `render`, `write_png`, `Sky`, `MODEL_VERSION`, `__version__` |
21
+
22
+ ## Invariants
23
+
24
+ - **Dependency arrows point strictly downward.** Breaks: an upward import (e.g. `palette` importing `gradient`) couples the art console to the validation boundary and defeats isolation/testability.
25
+ - **All input validation lives at the `gradient.py` public boundary; internals are total over clean inputs.** Breaks: an unvalidated value (NaN, out-of-range, `bool` as a dimension) reaches the math and yields garbage or an opaque error instead of a precise `ValueError`/`TypeError`.
26
+ - **No RNG anywhere; dithering is positional (Bayer).** Breaks: any randomness makes output non-reproducible and silently breaks every consumer pinning goldens.
27
+ - **`ANCHORS` are strictly ascending in elevation, each with exactly one stop per `U_OFFSETS` entry; `GLOW` has one entry per anchor elevation.** Breaks: equal adjacent elevations → `ZeroDivisionError` in bracket blending; mismatched stop counts → `zip(strict=True)` raises.
28
+ - **Glow strength is exactly `0.0` at the −18° floor.** Breaks: the `strength == 0.0` short-circuit in `_png_facing` stops firing, and night renders are no longer byte-identical to the `facing=None` path.
29
+ - **`facing=None` (and any facing at night) is byte-identical to the v1 vertical path; `encode_leveled_rows` at level 0 everywhere reproduces `encode_rows`.** Breaks: existing v1 goldens diverge and `MODEL_VERSION` must bump.
30
+ - **Any change that alters a rendered color bumps `MODEL_VERSION`.** Breaks: consumers' pinned goldens diverge silently instead of failing deliberately.
31
+ - **Compare decoded pixels, never compressed bytes.** Breaks: DEFLATE output varies across zlib builds, so byte comparison gives false negatives across environments.
32
+
33
+ ## Landmines
34
+
35
+ - **`strength == 0.0` in `_png_facing` is a deliberate exact float compare**, not a smell — it is the night byte-identity short-circuit. Do not replace it with an epsilon comparison.
36
+ - **The facing path is per-pixel and capped at `_FACING_MAX_PIXELS` (2048², ~4M px)** *on top of* the `[1, 16384]` per-dimension limit; the vertical path is per-row and keeps the full limit. Raising the cap raises a real worst case (~270M iterations / ~800 MB at 16384²), not just a validation number.
37
+ - **Naive datetimes are local mean solar time (`UTC = naive − lon/15 h`), resolved only in `astronomy.to_utc`.** `solar_position` then re-applies longitude as `+4·lon` min; the two cancel for naive input — it is *not* a double-count.
38
+ - **`to_utc` never raises**: out-of-range shifts clamp to `datetime.min`/`max`. Rendering produces a sky for any valid position/datetime; only the public boundary rejects.
39
+ - **`solar_azimuth` is ill-conditioned near zenith passage** (elevation ≈ 90°). Fine for the glow (insensitive to azimuth there); unreliable for any other use at high sun.
40
+ - **Solar position holds ±0.3° roughly 1800–2200**, degrades gracefully outside, never raises. No refraction correction (shifts apparent sunset ~3 min, invisible in a gradient).
41
+ - **The Oklab→sRGB gamut clip is a per-channel clamp** (can shift hue, not preserve it). Harmless for the in-gamut palette; a wildly out-of-gamut tint would clip oddly.
42
+ - **Determinism is pinned to one platform + interpreter per golden comparison.** Cross-platform last-ulp libm divergence is treated as a bug, not absorbed. Committed goldens were recorded on ubuntu/CPython 3.13; CI compares them byte-exact across CPython 3.11–3.14.
43
+
44
+ ## Flow
45
+
46
+ `render(lat, lon, when)` → `Sky.at` (validate · resolve naive→UTC in `to_utc` · `solar_position` · clamp elevation to [−18°, +90°] · blend the two bracketing anchors per-stop **in Oklab**) → `Sky.png`:
47
+
48
+ - **No `facing`:** each row's color at `u = (i + 0.5)/H`, Oklab → linear → gamma-encoded sRGB → `png.encode_rows` (8×8 Bayer dither) → bytes.
49
+ - **With `facing`:** per-pixel separable great-circle weight `cosΔ(x,y) = P(y) + Q(y)·R(x)` → cosine-domain smoothstep → 64 levels → `png.encode_leveled_rows` (lerps base↔glow endpoints per row).
50
+
51
+ The byte stream is exactly `IHDR` (8-bit, color type 2 RGB, no interlace) · `sRGB` (perceptual) · `IDAT` (zlib level 9) · `IEND` — no timestamps, no metadata. A caller never touches the math except through `Sky`.
52
+
53
+ ## Where to change X
54
+
55
+ - **Re-art-direct a sky / add an anchor:** edit hex in `palette.py` `ANCHORS`/`GLOW` (keep the anchor invariants above), then regenerate goldens and bump `MODEL_VERSION`. Full ritual: THEORY.md §Making common changes; commands: README.md §Development.
56
+ - **Solar math:** `astronomy.py`, validated against reference angles in `tests/test_astronomy.py` (±0.5°).
57
+ - **Add a render parameter:** validate at the `gradient.py` boundary (follow the `_validate_*` helpers; reject `bool` and non-finite). If it changes output for existing inputs → `MODEL_VERSION` bump; if opt-in like `facing`, prove default-path byte-compat with a test.
58
+ - **Add a Python version:** the matrix in `.github/workflows/ci.yml` (and `requires-python` in `pyproject.toml` if it is a new floor).
59
+
60
+ ---
61
+
62
+ For *why* any of this is the way it is — the palette-over-physics choice, Oklab
63
+ blending, the v1→v1.1 azimuthal-glow reversal, the determinism design — see
64
+ THEORY.md. For install, build, test, and usage, see README.md.
skygrad-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christophe Pettus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
skygrad-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: skygrad
3
+ Version: 1.0.0
4
+ Summary: Realistic sky gradient PNGs from latitude, longitude, and time
5
+ Project-URL: Homepage, https://github.com/Xof/skygrad
6
+ Project-URL: Source, https://github.com/Xof/skygrad
7
+ Project-URL: Issues, https://github.com/Xof/skygrad/issues
8
+ Author-email: Christophe Pettus <christophe.pettus@pgexperts.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: astronomy,color,gradient,oklab,png,sky,solar
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Multimedia :: Graphics
21
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+
26
+ # skygrad
27
+
28
+ Realistic sky gradient PNGs from latitude, longitude, and time. Zenith at
29
+ the top, horizon at the bottom — one color per row, or the sun's glow
30
+ lobe across the frame when you pass `facing`. The sun's position
31
+ drives every color in the image; the sun itself is never drawn — no disc,
32
+ no moon, no stars, no clouds. Zero runtime dependencies.
33
+
34
+ ![24 hours over Los Angeles](assets/contact-sheet.png)
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from datetime import datetime
40
+ import skygrad
41
+
42
+ # One-shot PNG bytes
43
+ png = skygrad.render(lat=34.05, lon=-118.24,
44
+ when=datetime(1994, 6, 21, 19, 30),
45
+ width=512, height=1024)
46
+
47
+ # Or write straight to a file
48
+ skygrad.write_png("dusk.png", lat=34.05, lon=-118.24,
49
+ when=datetime(1994, 6, 21, 19, 30))
50
+
51
+ # Layered access: colors without pixels
52
+ sky = skygrad.Sky.at(lat=34.05, lon=-118.24, when=datetime(1994, 6, 21, 19, 30))
53
+ sky.solar_elevation # degrees; e.g. streetlights on below -4
54
+ sky.color(0.0) # (r, g, b) at the zenith; 1.0 is the horizon
55
+ sky.stops() # [(u, (r, g, b)), ...] — build a CSS gradient
56
+ ```
57
+
58
+ ## Facing (azimuthal glow)
59
+
60
+ Pass a compass direction and the image gains the sun's glow lobe —
61
+ brightest toward the sun, fading with angular distance, gone when you
62
+ face away. Without `facing`, output is byte-identical to skygrad 0.1.
63
+
64
+ ```python
65
+ sky = skygrad.Sky.at(lat=34.05, lon=-118.24, when=datetime(1994, 6, 21, 19, 40))
66
+ sky.solar_azimuth # ≈ 303° at this dusk — west-northwest
67
+ toward = sky.png(facing=sky.solar_azimuth) # sunset glow, centered
68
+ away = sky.png(facing=(sky.solar_azimuth + 180) % 360) # plain dusk gradient
69
+ wide = sky.png(facing=0.0, fov=360.0, width=2048) # full panorama strip
70
+ ```
71
+
72
+ `facing` is degrees clockwise from true north (any finite value,
73
+ normalized mod 360; radians callers use `math.degrees()`). `fov` is the
74
+ horizontal span in degrees, default 90, valid (0, 360] — and requires
75
+ `facing`.
76
+
77
+ A facing render is computed per pixel, so it's capped at ~4 million pixels
78
+ (`width × height ≤ 2048×2048`); go larger and it raises `ValueError`. Tile,
79
+ shrink, or drop `facing` (the plain vertical gradient is per-row and has no
80
+ such cap). Requires Python 3.11+.
81
+
82
+ ![24 hours facing the sunset](assets/contact-sheet-facing.png)
83
+
84
+ ## Time semantics (read this)
85
+
86
+ `when` is a full datetime — the **date matters** (June and December at the
87
+ same hour give completely different skies).
88
+
89
+ - **Timezone-aware** datetimes are honored exactly.
90
+ - **Naive** datetimes mean **local mean solar time** at the given
91
+ longitude: sundial time, where 12:00 puts the sun on the meridian.
92
+ This is deliberate — it makes "local" meaningful for fictional places —
93
+ but it can differ from civil wall-clock time by an hour or more. If you
94
+ want wall-clock local time, attach a tzinfo.
95
+
96
+ ## Determinism
97
+
98
+ Same inputs → same decoded pixels for a given `skygrad.MODEL_VERSION`
99
+ (and byte-identical PNGs within one zlib build). `MODEL_VERSION` bumps
100
+ whenever any input would render different colors, so golden tests in
101
+ consumers break deliberately, never silently. Compare decoded pixels,
102
+ not compressed bytes.
103
+
104
+ ## Development
105
+
106
+ uv sync
107
+ uv run ruff check . && uv run ruff format --check .
108
+ uv run mypy
109
+ uv run pytest -q
110
+
111
+ Golden skies are pinned under `tests/golden/`; regenerate deliberately
112
+ with `uv run pytest --regen-golden` and bump `MODEL_VERSION` if colors
113
+ changed. Regeneration rewrites all ten goldens, but on one zlib build the
114
+ untouched ones are byte-identical rewrites — so a deliberate single-color
115
+ change surfaces as exactly the goldens it should. The module map and
116
+ invariants live in `ARCHITECTURE.md`, and the design
117
+ rationale (theory of operation) in `THEORY.md`.
@@ -0,0 +1,92 @@
1
+ # skygrad
2
+
3
+ Realistic sky gradient PNGs from latitude, longitude, and time. Zenith at
4
+ the top, horizon at the bottom — one color per row, or the sun's glow
5
+ lobe across the frame when you pass `facing`. The sun's position
6
+ drives every color in the image; the sun itself is never drawn — no disc,
7
+ no moon, no stars, no clouds. Zero runtime dependencies.
8
+
9
+ ![24 hours over Los Angeles](assets/contact-sheet.png)
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from datetime import datetime
15
+ import skygrad
16
+
17
+ # One-shot PNG bytes
18
+ png = skygrad.render(lat=34.05, lon=-118.24,
19
+ when=datetime(1994, 6, 21, 19, 30),
20
+ width=512, height=1024)
21
+
22
+ # Or write straight to a file
23
+ skygrad.write_png("dusk.png", lat=34.05, lon=-118.24,
24
+ when=datetime(1994, 6, 21, 19, 30))
25
+
26
+ # Layered access: colors without pixels
27
+ sky = skygrad.Sky.at(lat=34.05, lon=-118.24, when=datetime(1994, 6, 21, 19, 30))
28
+ sky.solar_elevation # degrees; e.g. streetlights on below -4
29
+ sky.color(0.0) # (r, g, b) at the zenith; 1.0 is the horizon
30
+ sky.stops() # [(u, (r, g, b)), ...] — build a CSS gradient
31
+ ```
32
+
33
+ ## Facing (azimuthal glow)
34
+
35
+ Pass a compass direction and the image gains the sun's glow lobe —
36
+ brightest toward the sun, fading with angular distance, gone when you
37
+ face away. Without `facing`, output is byte-identical to skygrad 0.1.
38
+
39
+ ```python
40
+ sky = skygrad.Sky.at(lat=34.05, lon=-118.24, when=datetime(1994, 6, 21, 19, 40))
41
+ sky.solar_azimuth # ≈ 303° at this dusk — west-northwest
42
+ toward = sky.png(facing=sky.solar_azimuth) # sunset glow, centered
43
+ away = sky.png(facing=(sky.solar_azimuth + 180) % 360) # plain dusk gradient
44
+ wide = sky.png(facing=0.0, fov=360.0, width=2048) # full panorama strip
45
+ ```
46
+
47
+ `facing` is degrees clockwise from true north (any finite value,
48
+ normalized mod 360; radians callers use `math.degrees()`). `fov` is the
49
+ horizontal span in degrees, default 90, valid (0, 360] — and requires
50
+ `facing`.
51
+
52
+ A facing render is computed per pixel, so it's capped at ~4 million pixels
53
+ (`width × height ≤ 2048×2048`); go larger and it raises `ValueError`. Tile,
54
+ shrink, or drop `facing` (the plain vertical gradient is per-row and has no
55
+ such cap). Requires Python 3.11+.
56
+
57
+ ![24 hours facing the sunset](assets/contact-sheet-facing.png)
58
+
59
+ ## Time semantics (read this)
60
+
61
+ `when` is a full datetime — the **date matters** (June and December at the
62
+ same hour give completely different skies).
63
+
64
+ - **Timezone-aware** datetimes are honored exactly.
65
+ - **Naive** datetimes mean **local mean solar time** at the given
66
+ longitude: sundial time, where 12:00 puts the sun on the meridian.
67
+ This is deliberate — it makes "local" meaningful for fictional places —
68
+ but it can differ from civil wall-clock time by an hour or more. If you
69
+ want wall-clock local time, attach a tzinfo.
70
+
71
+ ## Determinism
72
+
73
+ Same inputs → same decoded pixels for a given `skygrad.MODEL_VERSION`
74
+ (and byte-identical PNGs within one zlib build). `MODEL_VERSION` bumps
75
+ whenever any input would render different colors, so golden tests in
76
+ consumers break deliberately, never silently. Compare decoded pixels,
77
+ not compressed bytes.
78
+
79
+ ## Development
80
+
81
+ uv sync
82
+ uv run ruff check . && uv run ruff format --check .
83
+ uv run mypy
84
+ uv run pytest -q
85
+
86
+ Golden skies are pinned under `tests/golden/`; regenerate deliberately
87
+ with `uv run pytest --regen-golden` and bump `MODEL_VERSION` if colors
88
+ changed. Regeneration rewrites all ten goldens, but on one zlib build the
89
+ untouched ones are byte-identical rewrites — so a deliberate single-color
90
+ change surfaces as exactly the goldens it should. The module map and
91
+ invariants live in `ARCHITECTURE.md`, and the design
92
+ rationale (theory of operation) in `THEORY.md`.