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.
- skygrad-1.0.0/.github/workflows/ci.yml +37 -0
- skygrad-1.0.0/.github/workflows/publish.yml +29 -0
- skygrad-1.0.0/.gitignore +13 -0
- skygrad-1.0.0/.python-version +1 -0
- skygrad-1.0.0/ARCHITECTURE.md +64 -0
- skygrad-1.0.0/LICENSE +21 -0
- skygrad-1.0.0/PKG-INFO +117 -0
- skygrad-1.0.0/README.md +92 -0
- skygrad-1.0.0/THEORY.md +374 -0
- skygrad-1.0.0/assets/contact-sheet-facing.png +0 -0
- skygrad-1.0.0/assets/contact-sheet.png +0 -0
- skygrad-1.0.0/pyproject.toml +60 -0
- skygrad-1.0.0/scripts/contact_sheet.py +58 -0
- skygrad-1.0.0/src/skygrad/__init__.py +14 -0
- skygrad-1.0.0/src/skygrad/astronomy.py +126 -0
- skygrad-1.0.0/src/skygrad/color.py +82 -0
- skygrad-1.0.0/src/skygrad/gradient.py +295 -0
- skygrad-1.0.0/src/skygrad/palette.py +70 -0
- skygrad-1.0.0/src/skygrad/png.py +105 -0
- skygrad-1.0.0/src/skygrad/py.typed +0 -0
- skygrad-1.0.0/tests/__init__.py +0 -0
- skygrad-1.0.0/tests/conftest.py +28 -0
- skygrad-1.0.0/tests/golden/civil_dusk.png +0 -0
- skygrad-1.0.0/tests/golden/deep_night.png +0 -0
- skygrad-1.0.0/tests/golden/equator_noon.png +0 -0
- skygrad-1.0.0/tests/golden/glow_dusk_away.png +0 -0
- skygrad-1.0.0/tests/golden/glow_dusk_toward.png +0 -0
- skygrad-1.0.0/tests/golden/glow_golden_offset.png +0 -0
- skygrad-1.0.0/tests/golden/glow_panorama.png +0 -0
- skygrad-1.0.0/tests/golden/golden_hour.png +0 -0
- skygrad-1.0.0/tests/golden/midnight_sun.png +0 -0
- skygrad-1.0.0/tests/golden/southern_summer.png +0 -0
- skygrad-1.0.0/tests/test_astronomy.py +129 -0
- skygrad-1.0.0/tests/test_color.py +65 -0
- skygrad-1.0.0/tests/test_facing.py +179 -0
- skygrad-1.0.0/tests/test_golden.py +97 -0
- skygrad-1.0.0/tests/test_gradient.py +118 -0
- skygrad-1.0.0/tests/test_palette.py +70 -0
- skygrad-1.0.0/tests/test_png.py +124 -0
- skygrad-1.0.0/tests/test_properties.py +63 -0
- 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
|
skygrad-1.0.0/.gitignore
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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`.
|
skygrad-1.0.0/README.md
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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`.
|