Solar Module — Day/Night Terminator & Tissot Indicatrix#
The cleopatra.basemap.solar module is the flat-2D counterpart to the 3-D globe's directional
lighting (TexturedGlobeGlyph, which shades a sphere and produces no
geometry). It has two layers:
-
CRS-free solar geometry — pure lon/lat maths that depends on nothing but the clock, with no ephemeris dependency:
subsolar_point(when)— the point where the sun is overhead (declination + the equation of time), via the low-precision NOAA/Meeus formulae (sub-degree over the relevant centuries).terminator(when, ...)— the day/night terminator as a small circle at angular distance90 - refractionfrom the subsolar point (a great circle only whenrefraction == 0); passrefraction=-6/-12/-18for the civil/nautical/astronomical twilight lines.night_polygon(when, ...)— the filled night region as lon/lat rings, closed along the dark pole's edge when a pole is in darkness and otherwise split at the antimeridian so a flat map does not get a band smeared across the whole world.tissot_circles(lons, lats, radius_m, ...)— geodesic circles of a fixed ground radius, in lon/lat, for building a Tissot indicatrix.
-
Artists that draw in data coordinates —
add_nightshadebuildsnight_polygonand draws it as aPolyCollection, preserving the current axis limits and returning the artist;add_tissotdraws a set of rings the caller supplies (already in the axes' coordinates).
Scope boundary. cleopatra computes lon/lat and draws — it never resolves a CRS or decides the
axes' projection. On a plain lon/lat axes the rings are drawn directly. On a projected axes the
consumer either pre-projects the vertices and passes them, or passes a transform callable
(lon, lat) -> (x, y). The one convenience is an optional crs= shortcut (mutually exclusive with
transform=) that reprojects from EPSG:4326 through pyproj — the same optional
cleopatra[tiles] extra add_features uses.
Usage#
import matplotlib
matplotlib.use("Agg") # any backend; Agg shown for headless rendering
from datetime import UTC, datetime
import matplotlib.pyplot as plt
from cleopatra.basemap.solar import add_nightshade, add_tissot, tissot_circles
when = datetime(2026, 6, 21, 12, tzinfo=UTC)
fig, ax = plt.subplots()
ax.set_xlim(-180, 180)
ax.set_ylim(-90, 90)
# Shade the night region on a plain lon/lat axes.
add_nightshade(ax, when, alpha=0.35, color="black", zorder=5)
# Tissot indicatrix: geodesic circles in lon/lat. On a lon/lat axes they can be
# drawn directly; on a projected axes, project each ring first (that distortion
# is exactly what the indicatrix shows).
circles = tissot_circles(lons=[-120, 0, 120], lats=[-45, 0, 45], radius_m=5e5)
add_tissot(ax, circles, facecolor="none", edgecolor="crimson")
fig.savefig("nightshade.png")
Note
add_nightshade and add_tissot draw in data coordinates and reproject nothing unless you
ask: pass a transform callable, or the optional crs= shortcut (which needs the
cleopatra[tiles] extra and raises an actionable ImportError without it). transform= and
crs= are mutually exclusive. The night region always spans about half the globe, so a
non-global projection (orthographic, azimuthal, a regional CRS) maps the far side outside its
domain, to non-finite coordinates; add_nightshade drops those vertices, so the drawn region is
then only the part that lies inside the projection — choose a projection whose domain covers the
area you are shading. If an out-of-domain mapping drops every vertex, the returned artist is
empty (nothing is drawn). A conformal projection such as Web Mercator (crs=3857) instead maps
the poles to very large but finite coordinates: nothing is dropped, and a pole-covering fill is
simply drawn far outside the usual map extent (the axis limits, which add_nightshade preserves,
crop it).
Module Documentation#
cleopatra.basemap.solar
#
Day/night terminator and Tissot-indicatrix artists for flat 2-D axes.
cleopatra can already shade a day/night terminator, but only as lighting on a
3-D sphere (cleopatra.glyphs.globe.textured_globe_glyph.TexturedGlobeGlyph):
the input is a world-space light direction, the output is per-face shaded
facecolors on an Axes3D, and no lon/lat geometry is ever produced. This
module is the flat-map counterpart: it computes the terminator as a small circle
about the subsolar point in lon/lat and draws it (and the filled night region,
and Tissot distortion circles) on an ordinary matplotlib.axes.Axes.
Scope boundary -- the same split the rest of basemap keeps:
- Solar geometry is CRS-free maths, so it lives here. The subsolar point for a datetime (solar declination plus the Greenwich hour angle via the equation of time) and the terminator small circle depend on nothing but the clock; they are not CRS-dependent. Likewise the geodesic circles behind a Tissot indicatrix are generic spherical geometry.
- Any CRS/projection transform is the consumer's. These helpers compute
lon/lat and draw in data coordinates. A consumer whose axes is not lon/lat
either pre-projects the vertices and passes them, or passes a
transformcallable(lon, lat) -> (x, y). This module never resolves a CRS, never decides the axes' projection, and never grows a projection engine -- the same contractcleopatra.basemap.projection.apply_projection_framestates. - The one convenience is an optional
crs=, mirroringcleopatra.basemap.reference.add_features: it reprojects from EPSG:4326 through pyproj (the[tiles]extra) for the common EPSG case, and is mutually exclusive withtransform=. It is a shortcut, not a licence to own projections.
Accuracy is deliberately low-precision: the NOAA/Meeus solar-position formulae are sub-degree over the relevant centuries, which is far finer than a shaded overlay needs. No ephemeris dependency.
Example (lon/lat axes)::
from datetime import UTC, datetime
from cleopatra.basemap.solar import add_nightshade
when = datetime(2026, 6, 21, 12, tzinfo=UTC)
add_nightshade(ax, when, alpha=0.35, color="black", zorder=5) # shade the night
Consumers whose axes is not lon/lat pass a transform callable, or the
optional crs= pyproj shortcut, so cleopatra never resolves a projection.
See also cleopatra.glyphs.globe.textured_globe_glyph for the 3-D globe's
directional lighting, which answers a different question (shading on a sphere,
not geometry on a flat map).
add_nightshade(ax, when, *, refraction=DEFAULT_REFRACTION, n=DEFAULT_TERMINATOR_SAMPLES, transform=None, crs=None, **style)
#
Shade the night region on ax and return the artist.
Builds the night_polygon rings, maps them into the axes' coordinates,
draws them, preserves the current axis limits (the way
cleopatra.basemap.reference.add_features does), and returns the artist so
the caller can restyle or remove it. Several calls on one axes are fine;
each returns its own artist.
Coordinate mapping is the consumer's responsibility:
- Neither
transformnorcrs: the rings are drawn as lon/lat, i.e. the axes is assumed to be in EPSG:4326 data coordinates. transform: a callable applied to each(m, 2)lon/lat ring, returning(m, 2)axes coordinates.crs: the optional pyproj shortcut -- reproject from EPSG:4326 tocrs(reusescleopatra.basemap.reference._make_transformer, so it needs the[tiles]extra).
Vertices that a transform or crs maps to non-finite values -- the
night region always spans ~half the globe, so a non-global projection sends
its far side outside the projection's domain -- are dropped so the fill stays
valid (as cleopatra.basemap.reference.add_features does). If a mapping drops
every vertex, the returned artist has no polygons (nothing is drawn).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ax
|
Any
|
A matplotlib |
required |
when
|
datetime
|
An aware |
required |
refraction
|
float
|
Solar altitude in degrees; see |
DEFAULT_REFRACTION
|
n
|
int
|
Number of samples along the terminator. |
DEFAULT_TERMINATOR_SAMPLES
|
transform
|
Callable[[ndarray], ndarray] | None
|
Optional |
None
|
crs
|
int | str | None
|
Optional target CRS (EPSG int or CRS string) for the pyproj
shortcut. Mutually exclusive with |
None
|
**style
|
Any
|
Style overrides forwarded to the |
{}
|
Returns:
| Type | Description |
|---|---|
PolyCollection
|
matplotlib.collections.PolyCollection: The night-shade artist. |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If both |
ImportError
|
If |
Examples:
- Shade the night region on a lon/lat axes and keep the artist; the axis
limits are preserved:
>>> import matplotlib >>> matplotlib.use("Agg") >>> import matplotlib.pyplot as plt >>> from datetime import UTC, datetime >>> fig, ax = plt.subplots() >>> _ = ax.set_xlim(-180, 180) >>> _ = ax.set_ylim(-90, 90) >>> art = add_nightshade(ax, datetime(2026, 6, 21, 12, tzinfo=UTC), alpha=0.3) >>> art in ax.collections True >>> bool(ax.get_xlim() == (-180.0, 180.0)) True - A consumer whose axes is not lon/lat passes a
transformcallable (here a trivial scale), so cleopatra never resolves the projection:
Source code in src/cleopatra/basemap/solar.py
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 | |
add_tissot(ax, ellipses, **style)
#
Draw pre-projected Tissot rings on ax and return the artist.
cleopatra draws what it is given and computes no distortion: the shape a
circle takes is a property of the consumer's projection, so the consumer
projects tissot_circles output through its own transform and passes the
result here. It draws the rings as an unfilled PolyCollection (outline only
by default) -- the same "sequence of polygons with differing vertex counts"
shape cleopatra.glyphs.primitives.polygon_glyph.PolygonGlyph handles -- and
preserves the current axis limits.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ax
|
Any
|
A matplotlib |
required |
ellipses
|
Sequence[ndarray]
|
A sequence of |
required |
**style
|
Any
|
Style overrides forwarded to the underlying collection
( |
{}
|
Returns:
| Type | Description |
|---|---|
PolyCollection
|
matplotlib.collections.PolyCollection: The Tissot artist. |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If an |
Examples:
- Draw two supplied rings (already in axes coordinates) and keep them:
>>> import matplotlib >>> matplotlib.use("Agg") >>> import matplotlib.pyplot as plt >>> import numpy as np >>> theta = np.linspace(0, 2 * np.pi, 16) >>> circle = np.column_stack([np.cos(theta), np.sin(theta)]) >>> fig, ax = plt.subplots() >>> art = add_tissot(ax, [circle, circle + 3.0], edgecolor="crimson") >>> len(art.get_paths()) 2 >>> art in ax.collections True - Generate geodesic circles with
tissot_circlesand draw them on a lon/lat axes:>>> import matplotlib >>> matplotlib.use("Agg") >>> import matplotlib.pyplot as plt >>> from cleopatra.basemap.solar import tissot_circles >>> circles = tissot_circles([-90.0, 0.0, 90.0], [0.0, 0.0, 0.0], 5e5) >>> fig, ax = plt.subplots() >>> art = add_tissot(ax, circles, edgecolor="navy") >>> len(art.get_paths()) 3
Source code in src/cleopatra/basemap/solar.py
night_mask(lon, lat, when, *, refraction=DEFAULT_REFRACTION)
#
Return a boolean field that is True where lon/lat is in night.
A position is in night when its solar_altitude is below refraction --
the same threshold that defines the terminator and night_polygon. The
comparison is strict (<): a sample counts as night only when its altitude
is strictly below refraction, so the boundary is excluded. Whether a
computed altitude lands exactly on the boundary is a matter of floating-point
rounding; when it does, the strict < puts it on the day side, which for a
shaded overlay is immaterial. This is the field form of the night region:
where night_polygon gives the night
side as a ring (which a clipped or orthographic display cannot fill, because
half of it is on the far side), the mask gives it per sample, so a consumer
that has already inverted its own display grid to lon/lat can shade the night
by testing each pixel. cleopatra owns the solar geometry; the projection and
the fill stay with the caller.
Non-finite inputs are not in night: a nan altitude (an off-globe pixel)
yields False, so an inverse-projection grid's off-disc positions are
simply left unmasked.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lon
|
Any
|
Longitude(s) in degrees; scalar or array-like, broadcast with
|
required |
lat
|
Any
|
Latitude(s) in degrees; scalar or array-like, broadcast with
|
required |
when
|
datetime
|
An aware |
required |
refraction
|
float
|
Solar altitude in degrees defining the terminator; see
|
DEFAULT_REFRACTION
|
Returns:
| Type | Description |
|---|---|
ndarray
|
numpy.ndarray: A boolean array with the broadcast shape of |
ndarray
|
|
ndarray
|
|
ndarray
|
non-finite. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
- The subsolar point is in full day and its antipode is in night:
- It broadcasts over a grid and leaves off-globe (non-finite) samples out of the mask:
Source code in src/cleopatra/basemap/solar.py
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 | |
night_polygon(when, *, refraction=DEFAULT_REFRACTION, n=DEFAULT_TERMINATOR_SAMPLES)
#
Return the filled night region as lon/lat rings.
The rings are pre-split at the antimeridian so that a flat (lon/lat) map does not get a band smeared across the whole world when the night region wraps.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
when
|
datetime
|
An aware |
required |
refraction
|
float
|
Solar altitude in degrees; see |
DEFAULT_REFRACTION
|
n
|
int
|
Number of samples along the terminator used to build the region. |
DEFAULT_TERMINATOR_SAMPLES
|
Returns:
| Type | Description |
|---|---|
list[ndarray]
|
list[numpy.ndarray]: One or more |
list[ndarray]
|
night side; more than one where the region crosses the antimeridian. |
list[ndarray]
|
The rings are open (first vertex != last); matplotlib closes polygons |
list[ndarray]
|
on fill. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
- At a solstice one pole is in darkness, so the night region is a single ring that runs to that pole (the south pole, lat -90, in June):
- When the night region straddles the antimeridian it is split into two rings so a flat map does not smear a band across the world:
Source code in src/cleopatra/basemap/solar.py
solar_altitude(lon, lat, when)
#
Return the sun's altitude angle (degrees) at lon/lat for when.
The altitude is the angle between the sun and the local horizontal: +90
at the subsolar point, 0 on the geometric horizon, and negative at night.
It is pure spherical geometry about the subsolar point (subsolar_point) --
the very quantity whose refraction threshold defines the terminator --
with no CRS, projection, or atmospheric-refraction correction applied.
The inputs broadcast against each other with numpy's rules, so a scalar, a
1-D track, or a 2-D grid all work and the result takes the broadcast shape.
Non-finite inputs propagate: a nan/inf longitude or latitude yields
nan at that position. That lets a caller feed the inverse of a clipped or
orthographic projection -- where off-globe pixels come back non-finite -- and
get nan there rather than a spurious value or an error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lon
|
Any
|
Longitude(s) in degrees; scalar or array-like, broadcast with
|
required |
lat
|
Any
|
Latitude(s) in degrees; scalar or array-like, broadcast with
|
required |
when
|
datetime
|
An aware |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
numpy.ndarray: The solar altitude in degrees, in |
ndarray
|
broadcast shape of |
ndarray
|
with a non-finite |
Examples:
- The altitude peaks at
+90directly under the sun (the subsolar point): - It is negative on the night side and broadcasts over arrays, so a whole grid can be classified at once:
- A non-finite input comes back as
nanat that position, so an off-globe pixel from an inverse projection stays untouched:
Source code in src/cleopatra/basemap/solar.py
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | |
subsolar_point(when)
#
Return the subsolar point (lon, lat) in degrees for when.
The subsolar point is where the sun is directly overhead: its latitude is the solar declination, its longitude is derived from the Greenwich hour angle (apparent solar time via the equation of time). Pure maths -- no matplotlib import needed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
when
|
datetime
|
An aware |
required |
Returns:
| Type | Description |
|---|---|
float
|
tuple[float, float]: |
float
|
|
Examples:
- At the June solstice the sun is overhead near the Tropic of Cancer (~23.4 deg N), close to Greenwich at 12:00 UTC:
- The subsolar meridian tracks the sun ~15 deg westward each hour:
Source code in src/cleopatra/basemap/solar.py
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | |
terminator(when, *, refraction=DEFAULT_REFRACTION, n=DEFAULT_TERMINATOR_SAMPLES)
#
Return the day/night terminator as lon/lat vertices.
The terminator is the small circle at angular distance 90 - refraction
from the subsolar point -- a great circle only when refraction == 0.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
when
|
datetime
|
An aware |
required |
refraction
|
float
|
Solar altitude in degrees defining the terminator; see
|
DEFAULT_REFRACTION
|
n
|
int
|
Number of samples along the circle. The ring is closed, so |
DEFAULT_TERMINATOR_SAMPLES
|
Returns:
| Type | Description |
|---|---|
ndarray
|
numpy.ndarray: An |
ndarray
|
for a smooth curve. The ring is closed (last vertex equals the first). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
- The default terminator is a closed ring of 720 lon/lat vertices:
- A coarser ring stays within the lon/lat bounds;
refractionshifts the ring's angular radius (-6gives the civil-twilight line):
Source code in src/cleopatra/basemap/solar.py
tissot_circles(lons, lats, radius_m, *, n=DEFAULT_TISSOT_SAMPLES)
#
Return geodesic circles of a fixed ground radius as lon/lat rings.
Generic spherical geometry: each circle is the locus of points a fixed
great-circle distance (radius_m on a sphere of MEAN_EARTH_RADIUS_M)
from its centre. No CRS is involved; the consumer projects these rings to
show how a projection distorts them (that is the Tissot indicatrix).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lons
|
Sequence[float] | ndarray
|
Circle-centre longitudes in degrees. |
required |
lats
|
Sequence[float] | ndarray
|
Circle-centre latitudes in degrees. Same length as |
required |
radius_m
|
float
|
Ground radius of each circle in metres. |
required |
n
|
int
|
Number of vertices per circle. |
DEFAULT_TISSOT_SAMPLES
|
Returns:
| Type | Description |
|---|---|
list[ndarray]
|
list[numpy.ndarray]: One |
list[ndarray]
|
order. Each ring is closed (the last vertex duplicates the first). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
- One ~500 km circle around the origin, sampled coarsely:
- The ground radius maps to a fixed angular radius (~4.5 deg at 500 km):