starhue 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.
starhue-1.0.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Devesh Aggarwal
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
starhue-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: starhue
3
+ Version: 1.0.0
4
+ Requires-Python: >=3.8
5
+ License-File: LICENSE
6
+ Dynamic: license-file
@@ -0,0 +1,281 @@
1
+ # starhue
2
+
3
+ `starhue` takes a temperature value in kelvin, and returns the actual sRGB color that a
4
+ blackbody of that temperature would show to your eye — along with the physics behind it:
5
+ the Planck spectrum, the Wien peak, Stefan–Boltzmann exitance, and the Harvard spectral
6
+ class. It also runs in reverse, turning a color back into a correlated color temperature
7
+ (CCT), and renders it all as a star card in your terminal.
8
+
9
+ <p align="center">
10
+ <img src="starhue/assets/sun.png" alt="starhue card for the Sun (5772 K)" width="640">
11
+ </p>
12
+
13
+ ---
14
+
15
+ ## Installation
16
+
17
+ `starhue` is pure standard library — no third-party dependencies, just Python 3.8+.
18
+ Clone the repo and install it editable into a virtualenv:
19
+
20
+ ```bash
21
+ git clone https://github.com/devesh-aggarwal/starhue.git
22
+ cd starhue
23
+ python -m venv .venv && source .venv/bin/activate
24
+ python -m pip install -e .
25
+ ```
26
+
27
+ There is nothing else to fetch. Confirm it works:
28
+
29
+ ```bash
30
+ python -m starhue 5772 # should print a star card for the Sun
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ The standard use case is one or more temperatures in kelvin → a star card per temperature:
36
+
37
+ ```bash
38
+ python -m starhue 5772 # the Sun
39
+ python -m starhue 3500 5772 12100 # several stars at once
40
+ python -m starhue --from-color '#ffd1a3' # inverse: a color → its nearest blackbody
41
+ python -m starhue 5772 --no-spectrum # card without the sparkline
42
+ ```
43
+
44
+ `python -m starhue 5772` prints:
45
+
46
+ ```text
47
+ ╭────────────────────────────────────────────────╮
48
+ │ ★ 5772 K class G │
49
+ │ ████████ #fff1ea rgb(255, 241, 234) │
50
+ │ yellow, Sun-like · neutral white │
51
+ │ │
52
+ │ peak λ 502.0 nm · 3.393e+14 Hz │
53
+ │ exitance 62.94 MW/m² │
54
+ │ │
55
+ │ ▄▄▅▆▆▇▇▇██████████▇▇▇▇▇▆▆▆▆▆▅▅▅▅▅▄▄▄▄▄▄▃▃▃▃▃▃▃ │
56
+ │ ▲ │
57
+ │ 300nm 1100nm │
58
+ ╰────────────────────────────────────────────────╯
59
+ ```
60
+
61
+ Reading the card: the swatch with its `#rrggbb` / `rgb()` values is the star's display
62
+ color; **peak λ** is the Wien peak wavelength (nm) and its frequency (Hz); **exitance** is
63
+ the Stefan–Boltzmann radiant exitance (here in MW/m²); the sparkline is the Planck spectrum
64
+ sampled 300–1100 nm, with ▲ marking the peak. *(In a real terminal the swatch and sparkline
65
+ are full 24-bit color.)*
66
+
67
+ ## Python API
68
+
69
+ ```python
70
+ import starhue
71
+
72
+ # One-liners — pick the output format you want
73
+ starhue.temperature_to_color(5772) # '#fff1ea' (hex by default)
74
+ starhue.temperature_to_color(3500, 'rgb') # (255, 200, 140) (r, g, b), 0–255
75
+ starhue.wavelength_to_color(550, 'rgb') # color of a single wavelength
76
+
77
+ # The high-level object
78
+ star = starhue.Star(5772)
79
+ star.hex # '#fff1ea'
80
+ star.rgb # (255, 241, 234)
81
+ star.peak_wavelength_nm # 502.0 — Wien's displacement law
82
+ star.peak_frequency_hz # 3.39e14 — frequency-form Wien's law
83
+ star.radiant_exitance # 6.29e7 W/m² — Stefan–Boltzmann
84
+ star.spectral_type.letter # 'G'
85
+ star.appearance # 'neutral white'
86
+
87
+ # The raw spectrum: list of (wavelength_nm, radiance)
88
+ star.spectrum(380, 750, samples=100, normalize=True)
89
+ ```
90
+
91
+ ### Inverse: color → temperature (CCT)
92
+
93
+ Go the other way too. The correlated color temperature is the nearest point on
94
+ the Planckian locus in CIE 1960 *uv* space, so it round-trips with the forward
95
+ model. `color_to_temperature` takes the color as a `#rrggbb` string or an
96
+ `(r, g, b)` triple and returns the temperature in kelvin.
97
+
98
+ ```python
99
+ starhue.color_to_temperature('#ffd1a3') # 3911.0
100
+ starhue.color_to_temperature((205, 217, 255)) # ~10000
101
+
102
+ starhue.Star.from_color('#fff1ea') # Star(5773 K, #fff1ea, class G)
103
+ starhue.Star.from_color((255, 200, 140)) # also takes an (r, g, b) triple
104
+ ```
105
+
106
+ ## API reference
107
+
108
+ These are the functions and objects you call. They're re-exported onto the
109
+ top-level `starhue` namespace, so `import starhue` is enough to reach all of
110
+ them; the table notes which submodule each lives in if you'd rather import from
111
+ there.
112
+
113
+ ### Forward — temperature / wavelength → color
114
+
115
+ Each returns a `#rrggbb` string by default, or an `(r, g, b)` triple (0–255) with
116
+ `format="rgb"`.
117
+
118
+ | Name | Signature → returns | Description |
119
+ |---|---|---|
120
+ | `temperature_to_color` | `(temperature_k, format="hex", step_nm=1.0) → str \| (int, int, int)` | Color of a blackbody at this temperature (full colorimetric pipeline). |
121
+ | `wavelength_to_color` | `(wavelength_nm, format="hex") → str \| (int, int, int)` | Display color of a single monochromatic wavelength (a spectral ramp, for plots). |
122
+
123
+ ### Inverse — color → temperature
124
+
125
+ | Name | Signature → returns | Description |
126
+ |---|---|---|
127
+ | `color_to_temperature` | `(color) → float` | Correlated color temperature (K) of a `#rrggbb` hex string or `(r, g, b)` triple — the nearest blackbody on the Planckian locus. |
128
+
129
+ ### Color utilities
130
+
131
+ | Name | Signature → returns | Description |
132
+ |---|---|---|
133
+ | `hex_to_rgb` | `(value) → (int, int, int)` | Parse `#rgb`/`#rrggbb` → `(r, g, b)`, 0–255. |
134
+
135
+ ### Physics
136
+
137
+ | Name | Signature → returns | Description |
138
+ |---|---|---|
139
+ | `planck` | `(wavelength_m, temperature_k) → float` | Planck spectral radiance, W·sr⁻¹·m⁻³ (wavelength in **metres**). |
140
+ | `planck_nm` | `(wavelength_nm, temperature_k) → float` | Planck spectral radiance per nm (wavelength in **nanometres**). |
141
+ | `spectrum` | `(temperature_k, lo_nm=300, hi_nm=1100, samples=200, *, normalize=False) → list[(float, float)]` | Sample the Planck curve → `(wavelength_nm, radiance)` pairs. |
142
+ | `wien_peak_wavelength` | `(temperature_k, unit="nm") → float` | Wavelength of peak radiance (Wien); **nm** by default, `unit="m"` for metres. |
143
+ | `wien_peak_frequency` | `(temperature_k) → float` | Frequency of peak radiance (frequency-form Wien), Hz. |
144
+ | `stefan_boltzmann` | `(temperature_k) → float` | Total radiant exitance σT⁴, W·m⁻². |
145
+
146
+ ### Classification
147
+
148
+ | Name | Kind | Signature → returns | Description |
149
+ |---|---|---|---|
150
+ | `spectral_class` | function | `(temperature_k) → SpectralType` | Harvard spectral type (O/B/A/F/G/K/M) for an effective temperature. |
151
+ | `appearance_name` | function | `(temperature_k) → str` | Friendly perceptual color name, e.g. `"warm amber"`, as seen in a vacuum *(in `starhue.classify`)*. |
152
+ | `SpectralType` | object | `NamedTuple(letter, description)` | The value `spectral_class` returns — read `.letter` (e.g. `'G'`) and `.description` off it. |
153
+
154
+ ### The `Star` object (`starhue.Star`)
155
+
156
+ `Star(temperature_k, *, color_step_nm=1.0)` — the high-level facade. The keyword
157
+ `color_step_nm` is the wavelength step (nm) of the color integration; smaller is
158
+ more accurate but slower.
159
+
160
+ **Inverse constructor** (color → nearest blackbody):
161
+
162
+ | Constructor | Signature → returns | Description |
163
+ |---|---|---|
164
+ | `Star.from_color` | `(color_value) → Star` | From either a `#rrggbb` hex string or an sRGB `(r, g, b)` triple (0–255). |
165
+
166
+ **Attributes, properties & methods:**
167
+
168
+ | Member | Kind | Type / returns | Description |
169
+ |---|---|---|---|
170
+ | `temperature` | attribute | `float` | The star's temperature, K. |
171
+ | `rgb` | property | `(int, int, int)` | Display sRGB color, 0–255. |
172
+ | `hex` | property | `str` | Display sRGB color as `#rrggbb`. |
173
+ | `peak_wavelength_nm` | property | `float` | Wien peak wavelength, nm. |
174
+ | `peak_frequency_hz` | property | `float` | Wien peak frequency (frequency form), Hz. |
175
+ | `radiant_exitance` | property | `float` | Stefan–Boltzmann exitance, W·m⁻². |
176
+ | `spectral_type` | property | `SpectralType` | Harvard spectral classification. |
177
+ | `appearance` | property | `str` | Friendly perceptual color name, e.g. `"neutral white"`. |
178
+ | `planck(wavelength_nm)` | method | `float` | Spectral radiance at a wavelength in nm. |
179
+ | `spectrum(lo_nm=300, hi_nm=1100, samples=200, *, normalize=False)` | method | `list[(float, float)]` | Sample the Planck curve → `(wavelength_nm, radiance)` pairs. |
180
+
181
+ Anything not listed here (the colorimetry conversion steps in `starhue.color`,
182
+ the physical constants in `starhue.constants`) is internal plumbing the functions
183
+ above build on — usable, but not the intended surface.
184
+
185
+ ## CLI reference
186
+
187
+ ```
188
+ python -m starhue [TEMPERATURES ...] [options]
189
+
190
+ positional:
191
+ TEMPERATURES one or more temperatures in kelvin (default: 5772, the Sun)
192
+
193
+ options:
194
+ --no-spectrum hide the spectrum sparkline in cards
195
+ --from-color COLOR inverse mode: a color (#rrggbb or r,g,b) → its nearest blackbody
196
+ --color / --no-color force or disable ANSI color (auto-detected by default)
197
+ --version
198
+ ```
199
+
200
+ ```bash
201
+ python -m starhue --from-color '#ffd1a3' # what temperature is this color?
202
+ ```
203
+
204
+ Color output honours the `NO_COLOR` and `FORCE_COLOR` conventions.
205
+
206
+ ## Module map
207
+
208
+ | module | role |
209
+ |---|---|
210
+ | `constants` | exact SI / CODATA physical constants |
211
+ | `physics` | Planck's law, Wien's law, Stefan–Boltzmann, spectrum sampling |
212
+ | `color` | CIE 1931 CMF → XYZ → sRGB; chromaticity; wavelength → display color |
213
+ | `cct` | inverse direction: color → correlated color temperature |
214
+ | `classify` | Harvard spectral class + perceptual color names |
215
+ | `star` | the high-level `Star` object |
216
+ | `render` | terminal card + spectrum sparkline |
217
+ | `cli` | the command-line interface |
218
+
219
+ ## The science
220
+
221
+ Everything is computed from first principles with the exact 2019-SI constants.
222
+
223
+ **Planck's law** — spectral radiance of a blackbody:
224
+
225
+ $$B_\lambda(T) = \frac{2hc^2}{\lambda^5}\,\frac{1}{\exp\!\left(\dfrac{hc}{\lambda k_B T}\right) - 1}$$
226
+
227
+ **Wien's displacement law** — where that curve peaks: $\lambda_\text{max} = b / T$.
228
+
229
+ **Stefan–Boltzmann law** — total power radiated per unit area: $j^\star = \sigma T^4$.
230
+
231
+ **Temperature → color** follows the standard colorimetry pipeline:
232
+
233
+ 1. Sample the Planck curve across the visible band (360–830 nm).
234
+ 2. Integrate against the **CIE 1931 2° color-matching functions** → CIE *XYZ*.
235
+ 3. Map *XYZ* → linear sRGB with the D65 matrix.
236
+ 4. Clamp out-of-gamut negatives, normalize to constant luminance, gamma-encode.
237
+
238
+ The color-matching functions use the analytic multi-lobe Gaussian fit of
239
+ **Wyman, Sloan & Shirley (2013)**, which reproduces the tabulated CIE curves to
240
+ within ~1% with no embedded data table.
241
+
242
+ ### Is it accurate?
243
+
244
+ Yes — it lands on the textbook reference points:
245
+
246
+ | Temperature | `starhue` | meaning |
247
+ |------------:|:----------|:--------|
248
+ | 6500 K | xy ≈ (0.313, 0.324) | essentially the **D65** white point |
249
+ | 5772 K | `#fff1ea` | the Sun — a warm white, *not* yellow |
250
+ | 3500 K | `#ffc88c` | amber (an M-type red giant) |
251
+ | 12000 K | bluish white | hot B-type star |
252
+
253
+ The full Planckian locus matches Mitchell Charity's well-known blackbody-color
254
+ table closely.
255
+
256
+ ## Gallery
257
+
258
+ A cool red giant — note the spectral peak sliding into the infrared:
259
+
260
+ <p align="center">
261
+ <img src="starhue/assets/betelgeuse.png" alt="3500 K M-type star" width="560">
262
+ </p>
263
+
264
+ A hot blue star, peak pushed into the ultraviolet:
265
+
266
+ <p align="center">
267
+ <img src="starhue/assets/rigel.png" alt="12100 K B-type star" width="560">
268
+ </p>
269
+
270
+ ## References
271
+
272
+ - M. Planck, *On the Law of Distribution of Energy in the Normal Spectrum* (1901).
273
+ - CIE 1931 2° standard colorimetric observer.
274
+ - C. Wyman, P. Sloan & P. Shirley, *Simple Analytic Approximations to the CIE
275
+ XYZ Color Matching Functions*, **JCGT** 2(2), 2013.
276
+ - IEC 61966-2-1:1999 (sRGB).
277
+ - M. Charity, *What color is a blackbody?* — reference color table.
278
+
279
+ ## License
280
+
281
+ _To be decided by the project owner._
@@ -0,0 +1,12 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "starhue"
7
+ version = "1.0.0"
8
+ requires-python = ">=3.8"
9
+
10
+ # starhue is pure standard library (math, argparse, os, sys, typing).
11
+ # It has no third-party runtime dependencies.
12
+ dependencies = []
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,57 @@
1
+ """starhue — temperature → true star color + Planck spectrum.
2
+
3
+ Give it a temperature in kelvin; get back the actual sRGB color a blackbody of
4
+ that temperature would show your eye, the spectral curve behind it, and a pile
5
+ of derived physics (Wien peak, Stefan–Boltzmann exitance, spectral class).
6
+
7
+ >>> import starhue
8
+ >>> starhue.temperature_to_color(5772) # the Sun
9
+ '#fff1ea'
10
+ >>> star = starhue.Star(3500)
11
+ >>> star.spectral_type.letter
12
+ 'M'
13
+
14
+ See :class:`Star` for the high-level object and the ``color``/``physics``
15
+ submodules for the underlying functions.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .cct import color_to_temperature
21
+ from .classify import SpectralType, spectral_class
22
+ from .color import (
23
+ hex_to_rgb,
24
+ temperature_to_color,
25
+ wavelength_to_color,
26
+ )
27
+ from .physics import (
28
+ planck,
29
+ planck_nm,
30
+ spectrum,
31
+ stefan_boltzmann,
32
+ wien_peak_frequency,
33
+ wien_peak_wavelength,
34
+ )
35
+ from .star import Star
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "__version__",
41
+ "Star",
42
+ "SpectralType",
43
+ "spectral_class",
44
+ # color
45
+ "temperature_to_color",
46
+ "wavelength_to_color",
47
+ "hex_to_rgb",
48
+ # inverse CCT (color → temperature)
49
+ "color_to_temperature",
50
+ # physics
51
+ "planck",
52
+ "planck_nm",
53
+ "spectrum",
54
+ "stefan_boltzmann",
55
+ "wien_peak_wavelength",
56
+ "wien_peak_frequency",
57
+ ]
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m starhue``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,82 @@
1
+ """Inverse direction: a color → its correlated color temperature (CCT).
2
+
3
+ The CCT of a color is *defined* as the temperature of the blackbody whose
4
+ chromaticity is closest to it in the CIE 1960 UCS ``(u, v)`` plane. We find it by
5
+ searching our own Planckian locus, so ``T → color → T`` round-trips cleanly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import Tuple, Union
12
+
13
+ from . import color as _color
14
+
15
+ __all__ = ["color_to_temperature"]
16
+
17
+ #: Temperature range over which CCT is meaningful, in kelvin.
18
+ T_MIN = 1000.0
19
+ T_MAX = 40000.0
20
+
21
+
22
+ def _locus_uv(temperature_k: float) -> Tuple[float, float]:
23
+ return _color.xy_to_uv(*_color.xyz_to_xy(*_color.spectrum_to_xyz(temperature_k)))
24
+
25
+
26
+ def _dist2_at_mired(mired: float, u: float, v: float) -> float:
27
+ lu, lv = _locus_uv(1e6 / mired)
28
+ return (u - lu) ** 2 + (v - lv) ** 2
29
+
30
+
31
+ def _golden_min(f, a: float, b: float, iters: int = 40) -> float:
32
+ """Golden-section search for the minimizer of a unimodal ``f`` on ``[a, b]``."""
33
+ inv_phi = (math.sqrt(5.0) - 1.0) / 2.0
34
+ c = b - inv_phi * (b - a)
35
+ d = a + inv_phi * (b - a)
36
+ fc, fd = f(c), f(d)
37
+ for _ in range(iters):
38
+ if fc < fd:
39
+ b, d, fd = d, c, fc
40
+ c = b - inv_phi * (b - a)
41
+ fc = f(c)
42
+ else:
43
+ a, c, fc = c, d, fd
44
+ d = a + inv_phi * (b - a)
45
+ fd = f(d)
46
+ return (a + b) / 2.0
47
+
48
+
49
+ def _cct_from_uv(u: float, v: float) -> float:
50
+ """Nearest blackbody temperature (K) for a CIE 1960 ``(u, v)`` color."""
51
+ # Work in mired (10⁶/T): the Planckian locus is nearly straight there, so a
52
+ # coarse grid reliably brackets the minimum before we refine.
53
+ m_lo, m_hi = 1e6 / T_MAX, 1e6 / T_MIN
54
+ grid = 80
55
+ best_i, best_d2 = 0, float("inf")
56
+ mireds = [m_lo + (m_hi - m_lo) * i / (grid - 1) for i in range(grid)]
57
+ for i, m in enumerate(mireds):
58
+ d2 = _dist2_at_mired(m, u, v)
59
+ if d2 < best_d2:
60
+ best_i, best_d2 = i, d2
61
+
62
+ a = mireds[max(0, best_i - 1)]
63
+ b = mireds[min(grid - 1, best_i + 1)]
64
+ m_best = _golden_min(lambda m: _dist2_at_mired(m, u, v), a, b)
65
+ return 1e6 / m_best
66
+
67
+
68
+ def color_to_temperature(color: Union[str, Tuple[float, float, float]]) -> float:
69
+ """Correlated color temperature (K) of a color — the nearest blackbody on the
70
+ Planckian locus.
71
+
72
+ Round-trips with ``starhue.temperature_to_color``.
73
+
74
+ Args:
75
+ color (str | tuple[float, float, float]): A ``#rrggbb`` hex string or an
76
+ sRGB ``(r, g, b)`` triple (0–255).
77
+
78
+ Returns:
79
+ float: The correlated color temperature in kelvin.
80
+ """
81
+ rgb = _color.hex_to_rgb(color) if isinstance(color, str) else color
82
+ return _cct_from_uv(*_color.xy_to_uv(*_color.rgb_to_xy(rgb)))
@@ -0,0 +1,80 @@
1
+ """Harvard spectral classification and plain-language color names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, NamedTuple, Tuple
6
+
7
+ __all__ = ["SpectralType", "spectral_class", "appearance_name", "MK_CLASSES"]
8
+
9
+
10
+ class SpectralType(NamedTuple):
11
+ """A Harvard spectral classification result."""
12
+
13
+ letter: str #: O, B, A, F, G, K or M
14
+ description: str #: short prose description
15
+
16
+
17
+ # (min_temp_K, letter, description). Upper-open on the hot end.
18
+ MK_CLASSES: List[Tuple[float, str, str]] = [
19
+ (30000.0, "O", "blue, blistering and short-lived"),
20
+ (10000.0, "B", "blue-white, massive and luminous"),
21
+ (7500.0, "A", "white with strong hydrogen lines"),
22
+ (6000.0, "F", "yellow-white"),
23
+ (5200.0, "G", "yellow, Sun-like"),
24
+ (3700.0, "K", "orange, cooler than the Sun"),
25
+ (0.0, "M", "red, cool and abundant"),
26
+ ]
27
+
28
+
29
+ def spectral_class(temperature_k: float) -> SpectralType:
30
+ """Return the Harvard spectral type for an effective temperature.
31
+
32
+ Args:
33
+ temperature_k (float): Effective temperature in kelvin.
34
+
35
+ Returns:
36
+ SpectralType: The matching spectral class and its description.
37
+ """
38
+ for min_t, letter, desc in MK_CLASSES:
39
+ if temperature_k >= min_t:
40
+ return SpectralType(letter, desc)
41
+ # The last bucket starts at 0 K, so the loop always returns for a normal
42
+ # temperature; this only guards a non-finite value slipping through.
43
+ _, letter, desc = MK_CLASSES[-1]
44
+ return SpectralType(letter, desc)
45
+
46
+
47
+ # (max_temp_K exclusive, name). The colors people actually perceive.
48
+ _APPEARANCE: List[Tuple[float, str]] = [
49
+ (1200.0, "dim ember red"),
50
+ (2200.0, "deep red"),
51
+ (3200.0, "reddish orange"),
52
+ (4200.0, "warm amber"),
53
+ (5000.0, "pale gold"),
54
+ (5600.0, "soft yellow-white"),
55
+ (6600.0, "neutral white"),
56
+ (8000.0, "cool white"),
57
+ (12000.0, "blue-white"),
58
+ (float("inf"), "icy blue"),
59
+ ]
60
+
61
+
62
+ def appearance_name(temperature_k: float) -> str:
63
+ """A friendly, perceptual color name for a blackbody temperature.
64
+
65
+ This is the star's color as seen *in a vacuum* — the light leaving its
66
+ surface, before any atmosphere reddens it. So the Sun (5772 K) comes out a
67
+ neutral white here, not the yellow it looks from the ground (Earth's
68
+ atmosphere scatters away the blue and tints the disc yellow).
69
+
70
+ Args:
71
+ temperature_k (float): Absolute temperature in kelvin.
72
+
73
+ Returns:
74
+ str: A perceptual color name, e.g. ``"neutral white"``.
75
+ """
76
+ for max_t, name in _APPEARANCE:
77
+ if temperature_k < max_t:
78
+ return name
79
+ # The final bucket has max_t == inf, so the loop always returns above.
80
+ return _APPEARANCE[-1][1]
@@ -0,0 +1,125 @@
1
+ """Command-line interface for starhue.
2
+
3
+ starhue 5772 # one star card
4
+ starhue 3000 5772 9940 # several cards
5
+ starhue --from-color '#ffd1a3' # inverse: a color → its nearest blackbody
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from typing import List, Optional, Sequence
13
+
14
+ from . import __version__
15
+ from .cct import color_to_temperature
16
+ from .color import hex_to_rgb
17
+ from .render import star_card, supports_color
18
+ from .star import Star
19
+
20
+
21
+ def _build_parser() -> argparse.ArgumentParser:
22
+ """Construct the argparse parser for the CLI.
23
+
24
+ Returns:
25
+ argparse.ArgumentParser: The configured parser.
26
+ """
27
+ p = argparse.ArgumentParser(
28
+ prog="starhue",
29
+ description="Temperature → true star color + Planck spectrum.",
30
+ epilog="Give a temperature in kelvin (e.g. 5772 for the Sun) and see its real color.",
31
+ formatter_class=argparse.RawDescriptionHelpFormatter,
32
+ )
33
+ p.add_argument("temperatures", nargs="*", type=float, help="one or more temperatures in kelvin")
34
+ p.add_argument(
35
+ "--no-spectrum", dest="spectrum", action="store_false", help="hide the spectrum sparkline"
36
+ )
37
+ p.add_argument(
38
+ "--from-color",
39
+ metavar="COLOR",
40
+ help="inverse mode: a color (#rrggbb or r,g,b) → its nearest blackbody",
41
+ )
42
+ color = p.add_mutually_exclusive_group()
43
+ color.add_argument("--color", dest="color", action="store_true", default=None, help="force ANSI color")
44
+ color.add_argument("--no-color", dest="color", action="store_false", help="disable ANSI color")
45
+ p.add_argument("--version", action="version", version=f"starhue {__version__}")
46
+ return p
47
+
48
+
49
+ def _validate_temps(temps: Sequence[float]) -> None:
50
+ """Reject any non-positive temperature.
51
+
52
+ Args:
53
+ temps (Sequence[float]): Temperatures in kelvin to validate.
54
+
55
+ Raises:
56
+ SystemExit: If any temperature is not positive.
57
+ """
58
+ for t in temps:
59
+ if t <= 0:
60
+ raise SystemExit(f"starhue: temperature must be positive, got {t:g}")
61
+
62
+
63
+ def _parse_color(value: str) -> tuple:
64
+ """Parse a CLI color: ``#rrggbb``/``#rgb`` or ``r,g,b``.
65
+
66
+ Args:
67
+ value (str): The color string from the command line.
68
+
69
+ Returns:
70
+ tuple: The ``(r, g, b)`` channels as 0–255 ints.
71
+
72
+ Raises:
73
+ SystemExit: If the color cannot be parsed.
74
+ """
75
+ s = value.strip()
76
+ if "," in s:
77
+ parts = s.split(",")
78
+ if len(parts) != 3:
79
+ raise SystemExit(f"starhue: expected 'r,g,b', got {value!r}")
80
+ try:
81
+ return tuple(max(0, min(255, int(p))) for p in parts)
82
+ except ValueError:
83
+ raise SystemExit(f"starhue: invalid r,g,b color {value!r}") from None
84
+ try:
85
+ return hex_to_rgb(s)
86
+ except ValueError as exc:
87
+ raise SystemExit(f"starhue: {exc}") from None
88
+
89
+
90
+ def main(argv: Optional[List[str]] = None) -> int:
91
+ """Run the starhue command-line interface.
92
+
93
+ Args:
94
+ argv (list[str] | None): Argument vector; defaults to ``sys.argv`` when
95
+ None.
96
+
97
+ Returns:
98
+ int: Process exit code (0 on success).
99
+ """
100
+ args = _build_parser().parse_args(argv)
101
+ use_color = supports_color() if args.color is None else args.color
102
+
103
+ # --- inverse mode: a color → its nearest blackbody --------------------
104
+ if args.from_color:
105
+ rgb = _parse_color(args.from_color)
106
+ temperature = color_to_temperature(rgb)
107
+ print(
108
+ f"# {args.from_color.strip()} → nearest blackbody {temperature:.0f} K",
109
+ file=sys.stderr,
110
+ )
111
+ temps: List[float] = [temperature]
112
+ else:
113
+ temps = args.temperatures or [5772.0] # default: show the Sun
114
+ _validate_temps(temps)
115
+
116
+ for i, t in enumerate(temps):
117
+ if i:
118
+ print()
119
+ print(star_card(Star(t), color=use_color, spectrum=args.spectrum))
120
+
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__": # pragma: no cover
125
+ raise SystemExit(main())