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
310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 | |
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_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
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):